Привет, друзья!


В последнее время мне на глаза часто попадаются статьи, посвященные работе с формами в React и разработке соответствующих кастомных хуков. Вот парочка таких статей:



Я тоже решил попробовать в этом свои силы, чему и посвящена настоящая статья. Под "этим" я подразумеваю разработку кастомного хука для работы с формами в React.


Сначала мы разработаем хук, затем используем его в формах для регистрации и авторизации.


Для большей правдоподобности мы напишем простой express-сервер, который будет возвращать некоторые пользовательские данные (например, jwt-токен и хешированный пароль), а также некоторые типичные для процесса авторизации ошибки (например, 404 User not found или 409 Email already in use).


При разработке хука и компонентов приложения мы применим несколько продвинутых паттернов, которые, смею надеяться, покажутся вам интересными.


Исходный код проекта находится здесь.


Демо приложения:



Возможно, для того, чтобы в песочнице все заработало, потребуется ввести команду yarn dev в терминале.


Хук в форме npm-пакета — simple-form-react.


Разработка хука


Обратите внимание: хук, который мы с вами реализуем, предназначен для работы с формами небольших и, возможно, средних размеров. Если в ваша форма содержит 100 полей (условно), вероятно, вам лучше присмотреться к React Hook Form, Formik или другим аналогичным решениям.


Давайте начнем с определения того, какие пропы должен принимать наш хук.


Несмотря на то, что, как справедливо отмечается в статье Using Forms in React, использование неуправляемых "инпутов" (доступ к которым можно получать как через рефы, так и напрямую), вероятно, является более предпочтительным, чем использование управляемых инпутов, наша форма будет управляемой, поэтому первым пропом, принимаемым хуком, должны быть начальные данные инпутов. Назовем их initialData.


Форма должна куда-то отправляться (имеется в виду конечная точка на сервере), поэтому вторым пропом будет url.


По умолчанию данные будут отправляться методом POST, но у нас должна быть возможность это изменять. Поэтому следующим пропом будет method с дефолтным значением POST.


Инпуты могут быть как обязательными, так и опциональными, следовательно, нам нужен какой-то индикатор того, все ли инпуты являются обязательными, например, required с дефолтным значением true. Это четвертый проп нашего хука.


Наш хук будет почти безголовым (headless). Это означает, что он будет проводить только базовую валидацию инпутов, например, осуществлять простое обеззараживание (sanitizing) данных, введенных пользователем, и проверку заполненности обязательных полей. Вместе с тем хук должен иметь возможность принимать объект с валидаторами и выполнять их над соответствующими инпутами. Для этого нам потребуется проп validators.


Тот факт, что хук принимает объект с валидаторами, обуславливает необходимость наличия некоторого объекта с сообщениями об ошибках в случае, когда инпут после валидации признается невалидным. Шестой проп — messages.


По умолчанию хук будет самостоятельно управлять инпутами и отправкой формы по указанному url. Однако у нас должна быть возможность изменять это поведение. Следовательно, нам нужны еще два пропа для соответствующих функций — onChange и onSubmit.


Для отправки формы хук будет использовать разработанную мной утилиту very-simple-fetch, о которой я писал в этой статье. Поэтому наш хук будет принимать еще один проп — объект с настройками для названной утилиты. Назовем этот проп fetchOptions.


Приступаем к реализации.


Функция для обеззараживания введенных пользователем данных и удаления из них лишних пробелов может выглядеть так:


// функция принимает строку
export const escapeAndTrim = (str) =>
 str
   .replace(/[<>&'"/]/g, '')
   .replace(/\s{2,}/g, ' ')
   .trim()

А функция для проверки заполненности обязательных полей так:


// функция принимает объект
// поля объекта будут предварительно проходить через `escapeAndTrim()`
export const isEmpty = (fields) => Object.values(fields).some((field) => !field)

Начинаем писать хук:


export default function useSimpleForm({
 initialData,
 url,
 method = 'POST',
 required = true,
 validators,
 messages,
 onChange,
 onSubmit,
 fetchOptions
}) {
 // реализация хука
}

Для того, чтобы понять, какую логику должен инкапсулировать наш хук, необходимо определиться с тем, что он должен возвращать.


Первое, что приходит на ум, учитывая управляемость нашей формы, — это объект со значениями инпутов, который по форме должен совпадать с пропом initialData. Назовем этот объект fields.


Функции для изменения значений инпутов и отправки формы должны вызываться из формы, поэтому хук должен возвращать обе эти функции — change и submit.


Также нам потребуется кастомная функция для сброса формы — reset. Вы поймете, почему нам недостаточно нативного метода HTMLFormElement.reset, когда мы дойдем до реализации этой функции.


Поскольку хук будет выполнять проверку заполненности обязательных полей, нам требуется соответствующий индикатор. Как мы можем использовать этот индикатор (это определит его название)? Самый простой вариант — использовать его для отключения кнопки для отправки формы. Поэтому данный индикатор будет называться disabled.


Отправка формы предполагает обращение к серверу. Обращение к API занимает какое-то время. Чем должен заниматься пользователь в это время? Конечно же, любоваться "лоадером". Для этого нужен соответствующий индикатор — loading.


Ну и последнее по порядку, но не по значению, что должен возвращать наш хук, — это объект ответа от сервера response и объект с ошибками валидации errors.


Обратите внимание: утилита very-simple-fetch спроектирована таким образом, что в объект ответа включается не только успешный ответ (свойство data), но также кастомные ошибки и исключения (например, полученные в результате res.status(400).json({ message: 'Custom error' }) или throw new Error('Exception'); свойство error). Также объект ответа содержит дополнительную информацию, в частности статус-код ответа, который мы будем использовать для определения характера ошибки, возникшей на сервере, в соответствующем компоненте приложения (свойство info).


Поэтому объект errors, возвращаемый хуком, — это ошибки, связанные только с валидацией инпутов.


Таким образом, последняя строка реализации хука будет выглядеть следующим образом:


return { fields, change, submit, reset, disabled, loading, response, errors }

Для реализации хука мы будем использовать только 2 основных React-хука. Импортируем их в начале файла:


import { useState, useEffect } from 'react'

Устанавливаем very-simple-fetch:


yarn add very-simple-fetch
# or
npm i very-simple-fetch

Импортируем ее после React-хуков и экспортируем для обеспечения возможности прямого использования (мы воспользуемся этим для определения URL сервера с помощью сеттера simpleFetch.baseUrl):


import simpleFetch from 'very-simple-fetch'
export { simpleFetch }

Для выполнения валидаторов (validators) над соответствующими инпутами с выбором соответствующих сообщений об ошибках (messages) нам требуется некоторая общая функция валидации. Эту функцию лучше вынести за пределы хука, во избежание ее создания при каждом рендеринге. Конечно, мы могли бы просто мемоизировать ее с помощью useCallback с пустым массивом зависимостей, но это привело бы к неоправданным дополнительным расходам на производительность.


Размышляя о сигнатуре данной функции, я посчитал хорошей идеей реализовать в ней автоматическую проверку совпадения значений полей с их парами для подтверждения (например, password и confirmPassword). Функция также должна учитывать, что поля, для которых в validators отсутствуют валидаторы и которые не имеют пар для подтверждения, всегда являются валидными. Вот как она может выглядеть:


// функция принимает поля, валидаторы и сообщения об ошибках
const validate = (fields, validators, messages) => {
 // функция определения валидности поля, принимает название поля и его значение
 const isValid = (key, value) =>
   // для поля нет валидатора, и оно не является парой для подтверждения
   (!validators[key] && !key.includes('confirm')) ||
   // для поля есть валидатор, и оно проходит проверку с помощью этого валидатора
   (validators[key] && validators[key](value)) ||
   // поле является парой для подтверждения, и его значение совпадает со значением пары - одноименного (без учета `confirm`) поля
   (key.includes('confirm') &&
     value === fields[key.replace('confirm', '').toLowerCase()])

 // функция возвращает объект с ошибками
 return Object.entries(fields).reduce((errors, [key, value]) => {
   // если поле не является валидным
   if (!isValid(key, value)) {
     // если для поля имеется сообщение об ошибке, записываем его в одноименное поле объекта с ошибками
     // иначе просто обозначаем ошибку с помощью логического значения
     errors[key] = messages[key] || true
   }
   return errors
 }, {})
}

Таким образом, если validators содержит email: isEmail, а messagesemail: 'Wrong email!', при невалидности поля email в объект с ошибками будет записано email: 'Wrong email!'. Если имеется поле confirmPassword и его значение не совпадает со значением поля password, а messages не включает поле confirmPassword, то в errors будет записано confitmPassword: true.


Здесь же имеет смысл определить функцию для получения обязательных полей в случае, когда настройка required имеет значение false, т.е. когда одни поля являются обязательными, а другие — опциональными:


const getRequiredFields = (target) =>
 [...target.closest('form').querySelectorAll('input[required]')].reduce(
   (obj, { name, value }) => {
     obj[name] = value
     return obj
   },
   {}
 )

Теперь можно по-настоящему приступить к реализации.


Начнем с определения начальных значений:


// внутри `useSimpleForm()`

// все поля
const [fields, setFields] = useState(initialData)
// обязательные поля
// на данном этапе мы не можем определить, какие поля являются таковыми
const [requiredFields, setRequiredFields] = useState(null)
// начальное значение индикатора незаполненности хотя бы одного обязательного поля является неслучайным
// это связано с "на данном этапе..."
const [disabled, setDisabled] = useState(true)
const [loading, setLoading] = useState(false)

const [response, setResponse] = useState(null)
// допускаем, что потенциально любое поле может быть невалидным,
// поскольку все или большинство полей являются обязательными
const [errors, setErrors] = useState(initialData)

В качестве побочного эффекта при каждом изменении значения любого поля мы проверяем заполненность обязательных полей и на основе этого переключаем индикатор disabled:


useEffect(() => {
 // если только некоторые инпуты являются обязательными
 if (!required && requiredFields) {
   setDisabled(isEmpty(requiredFields))
 // если все инпуты являются обязательными или мы еще не получили обязательные поля
 } else {
   setDisabled(isEmpty(fields))
 }
}, [required, fields, requiredFields])

Функция изменения значения поля:


const change = (e) => {
 // если не все поля являются обязательными, и мы еще не получали обязательных полей
 if (!required && !requiredFields) {
   setRequiredFields(getRequiredFields(e.target))
 }

 // сбрасываем объект ответа
 setResponse(null)
 // сбрасываем объект с ошибками
 setErrors(initialData)

 // если передана кастомная функция, вызываем ее с событием в качестве аргумента
 if (typeof onChange === 'function') {
   return onChange(e)
 }

 // извлекаем название поля и его значения из цели события изменения
 const { name, value } = e.target

 // если изменилось обязательное поле
 if (requiredFields && requiredFields.hasOwnProperty(name)) {
   setRequiredFields({ ...requiredFields, [name]: escapeAndTrim(value) })
 }

 // если изменилось любое поле
 setFields({ ...fields, [name]: escapeAndTrim(value) })
}

Функция для отправки формы:


const submit = async (e) => {
 e.preventDefault()

 // защита от дурака на случай,
 // если кто-то додумается включить кнопку для отправки формы через инструменты разработчика в браузере
 // я покажу, как это сделать в следующем разделе
 if (disabled) return

 // обновляем индикатор загрузки
 setLoading(true)

 // если имеются валидаторы
 if (validators) {
   // определяем поля для валидации
   const fieldsToValidate = requiredFields ? requiredFields : fields
   // проводим валидацию и получаем ошибки
   const validationErrors = validate(fieldsToValidate, validators, messages)
   // если имеются ошибки
   if (Object.keys(validationErrors).length > 0) {
     // обновляем индикатор загрузки
     setLoading(false)
     // возвращаем объект с ошибками и прекращаем выполнение скрипта
     return setErrors(validationErrors)
   }
 }

 // если передана кастомная функция, вызываем ее с полями в качестве аргумента
 if (typeof onSubmit === 'function') {
   await onSubmit(fields)
 } else {
   // иначе отправляем запрос на сервер, получаем ответ и возвращаем его
   const response = await simpleFetch({
      url,
      method,
      body: fields,
      ...fetchOptions
    })
   setResponse(response)
 }

 // обновляем индикатор загрузки
 setLoading(false)
}

Наконец, реализуем функцию для сброса формы:


const reset = () => {
 // сбрасываем значения полей
 setFields(initialData)
 // сбрасываем объект ответа
 setResponse(null)
 // сбрасываем объект с ошибками
 setErrors(initialData)
}

Пожалуй, это все, что нам нужно для обработки форм.


У вас может возникнуть два вопроса:


  1. Разве валидацией инпутов должен заниматься не сервер? Что если пользователь отключит JavaScript в браузере? Сможет ли он отправить пустую форму? Мы попробуем это сделать и посмотрим, что получится.
  2. Почему мы инициализируем объект с ошибками начальными значениями инпутов? Разве начальным значением этого объекта должно быть не null или хотя бы пустой объект? По большому счету, начальным значением данного объекта действительно должно быть null, поскольку мы еще не проводили валидации. Однако, как мы увидим далее, null требует дополнительной проверки перед передачей ошибки в соответствующий компонент и когда таких компонентов (читай инпутов) много, это приводит к многочисленному дублированию кода. Эту проблему как раз и решает инициализация объекта с ошибками начальными значениями инпутов.

Подготовка проекта и реализация сервера


Формируем общую структуру проекта:


simple-form-react
 - client
 - server

Находясь в корневой директории, инициализируем проект и устанавливаем общую зависимость:


yarn init -y

yarn add concurrently

  • concurrently — это утилита для одновременного выполнения команд, определенных в package.json

Определяем команду для запуска серверов в package.json:


"scripts": {
 "dev": "concurrently \"yarn --cwd client start\" \"yarn --cwd server dev\""
}

Переходим в директорию server, инициализируем проект и устанавливаем зависимости:


yarn init -y

yarn add bcrypt cors express jsonwebtoken nodemon

  • bcrypt — утилита для хеширования паролей
  • cors — утилита для установки HTTP-заголовков, связанных с CORS (обмен ресурсами между разными источниками)
  • express — Node.js-фреймворк для разработки серверов
  • jsonwebtoken — утилита для генерации и проверки jwt-токенов
  • nodemon — утилита для запуска сервера для разработки

Как видите, у нас все серьезно. Мы реализуем фиктивный, но довольно близкий к реальному сервис для аутентификации.


Определяем команды для запуска сервера в server/package.json:


"dev": "nodemon index.js",
"start": "node index.js"

Создаем в директории server файл index.js следующего содержания:


const express = require('express')
const cors = require('cors')
const { randomBytes } = require('crypto')
const bcrypt = require('bcrypt')
const jwt = require('jsonwebtoken')

// создаем экземпляр приложения
const app = express()

// искусственная задержка для имитации поведения реального сервера
const sleep = (ms) =>
 new Promise((resolve) => {
   const timerId = setTimeout(() => {
     resolve()
     clearTimeout(timerId)
   }, ms)
 })

// роуты для аутентификации
const authRouter = express
 .Router()
 // роут для регистрации
 .post('/register', async (req, res, next) => {
   // выполняем задержку
   await sleep(1000)

   // извлекаем из тела запроса адрес электронной почты и пароль
   const { email, password } = req.body

   try {
     // предположим, что в нашей базе данных уже имеется пользователь с адресом электронной почты `test@mail.com`
     // если пользователь указал такой email, возвращаем ошибку 409 - пользователь уже зарегистрирован
     if (email === 'test@mail.com') {
       return res.sendStatus(409)
     }

     // генерируем идентификатор пользователя
     const userId = randomBytes(16).toString('hex')

     // генерируем токен
     const token = jwt.sign({ email }, 'secret', {
       expiresIn: '1h'
     })

     // хешируем пароль
     const hashedPassword = await bcrypt.hash(password, 10)

     // создаем нового пользователя
     const user = {
       userId,
       email,
       hashedPassword,
       token,
       createdAt: new Date().toISOString()
     }

     // и возвращаем его
     res.status(201).json(user)
   } catch (err) {
     next(err)
   }
 })
 // авторизация
 .post('/login', async (req, res, next) => {
   // извлекаем адрес электронной почты и пароль из тела запроса
   const { email, password } = req.body

   try {
     // выполняем задержку
     await sleep(1000)

     // для тестирования приложения
     // throw new Error('error')

     // email пользователя должен иметь значение `test@mail.com`
     // если это не так, возвращаем ошибку 404 - пользователь не найден
     if (email !== 'test@mail.com') {
       return res.sendStatus(404)
     }

     const testPassword = await bcrypt.hash('test', 10)
     const correctPassword = await bcrypt.compare(password, testPassword)
     // пароль пользователя должен иметь значение 'test'
     // если это не так, возвращаем ошибку 403
     if (!correctPassword) {
       return res.sendStatus(403)
     }

     // генерируем токен
     const token = jwt.sign({ email }, 'secret', {
       expiresIn: '1h'
     })

     // и возвращаем его
     res.status(200).json({ token })
   } catch (err) {
     next(err)
   }
 })

// отключаем CORS
app.use(cors())
// парсим тело запроса
app.use(express.json())

// решаем проблему с фавиконкой
app.get('/favicon.ico', (_, res) => {
 res.sendStatus(200)
})
// подключаем роуты
// конечная точка для аутентификации будет иметь значение `http://localhost:5000/api/auth`
app.use('/api/auth', authRouter)

// подключаем обработчик ошибок
app.use(errorHandler)

// а вот и он
function errorHandler(err, req, res, next) {
 console.error(err)
 res.sendStatus(500)
}

const PORT = process.env.PORT || 5000
app.listen(PORT, () => {
 console.log(` Server started on http://localhost:${PORT}`)
})

Реализация фронтенда


Находясь в корневой директории, создаем шаблон React-приложения с помощью [create-react-app]() и устанавливаем 2 дополнительные зависимости:


yarn create react-app client

yarn add react-router-dom simple-form-react


Структура нашего React-приложения будет такой:


client
 - public
   - index.html
 - src
   - components
     - Form
       - Button.js - кнопка
       - Error.js - серверная ошибка
       - Field.js - поле
       - index.js - форма
       - Result.js - результат
       - validators.js - валидаторы и сообщения
     - index.js - агрегатор компонентов
     - Loader.js - индикатор загрузки
     - Login.js - форма для авторизации
     - Register.js - форма для регистрации
   - pages
     - Auth.js - страница регистрации/авторизации
     - Home.js - домашняя страница
     - index.js - агрегатор страниц
   - App.js - основной компонент
   - index.css - стили
   - index.js - основной файл
 - jsconfig.json

Для основной стилизации мы будем использовать Bootstrap. Нам также потребуются иконки из данного CSS-фреймворка и кастомный шрифт. Подключаем их в public/index.html:


<link
 href="https://fonts.googleapis.com/css2?family=Montserrat&display=swap"
 rel="stylesheet"
/>
<link
 href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.1/dist/css/bootstrap.min.css"
 rel="stylesheet"
 integrity="sha384-+0n0xVW2eSR5OomGNYDnhzAbDsOXxcvSN1TPprVMTNDbiYZCxYbOOl7+AMvyTG2x"
 crossorigin="anonymous"
/>
<link
 rel="stylesheet"
 href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.5.0/font/bootstrap-icons.css"
/>

Немного поправим стили в src/index.css:


* {
 font-family: 'Montserrat', sans-serif;
 text-align: center;
}

h1,
h2,
h3 {
 margin: 1rem 0;
}

.field {
 margin: 1rem 0;
 text-align: left;
}

input {
 text-align: left;
}

p {
 margin: 0.5rem 0;
 word-break: break-all;
}

i {
 margin-right: 0.5rem;
}

Проксируем запросы в package.json:


"proxy": "http://localhost:5000"

Начнем с основных файлов.


index.js:


import React, { StrictMode } from 'react'
import { render } from 'react-dom'
import './index.css'
import { App } from './App'

render(
 <StrictMode>
   <App />
 </StrictMode>,
 document.getElementById('root')
)

Тут все стандартно.


В App.js мы делаем следующее:


  • импортируем нужные компоненты из react-router-dom
  • импортируем страницы из pages
  • определяем роуты
  • рендерим компонент

import {
 BrowserRouter as Router,
 NavLink,
 Route,
 Switch
} from 'react-router-dom'

import { Home, Auth } from 'pages'

const routes = [
 {
   name: 'Home',
   path: '/',
   exact: true,
   component: Home
 },
 {
   name: 'Auth',
   path: '/auth',
   component: Auth
 }
]

export const App = () => (
 <div className='container d-flex flex-column align-items-center'>
   <Router>
     <nav className='navbar navbar-light bg-light'>
       <ul className='nav'>
         {routes.map(({ name, path }) => (
           <li className='nav-item' key={name}>
             <NavLink to={path} className='nav-link' activeClassName='active'>
               {name}
             </NavLink>
           </li>
         ))}
       </ul>
     </nav>
     <h1>Simple Form React</h1>
     <Switch>
       {routes.map(({ name, ...rest }) => (
         <Route key={name} {...rest} />
       ))}
     </Switch>
   </Router>
 </div>
)

Тоже ничего особенного.


pages/Home.js:


export const Home = () => <h2>Home Page</h2>

Без комментариев.


pages/Auth.js:


import { useState } from 'react'
// импортируем компоненты
import { Login, Register } from 'components'

export const Auth = () => {
 // мы используем одну страницу и для авторизации и для регистрации
 // с помощью условного рендеринга
 const [login, setLogin] = useState(true)

 return (
   <>
     <h2>Auth Page</h2>
     {login ? <Login /> : <Register />}
     {/* кнопка для переключения между формами */}
     <button className='btn btn-link mt-2' onClick={() => setLogin(!login)}>
       {login ? 'Register' : 'Login'}
     </button>
   </>
 )
}

components/Loader.js:


export const Loader = () => (
 <div className='spinner-border text-primary' role='status'>
   <span className='visually-hidden'>Loading...</span>
 </div>
)

В components/Login.js мы делаем следующее:


  • импортируем компонент формы
  • определяем поля формы
  • определяем начальные данные инпутов
  • определяем пропы для формы
  • рендерим компонент

import { Form } from 'components'

const inputs = [
 {
   label: 'Email',
   name: 'email',
   type: 'email'
 },
 {
   label: 'Password',
   name: 'password',
   type: 'password'
 }
]

const initialData = {
 email: '',
 password: ''
}

const formProps = {
 initialData,
 url: '/login',
 inputs,
 submitLabel: 'Login'
}

export const Login = () => (
 <>
   <h3>Login</h3>
   <Form {...formProps} />
 </>
)

В components/Register.js мы делаем почти то же самое, за исключением следующего:


  • не все поля формы являются обязательными; обязательные инпуты должны иметь атрибут required
  • мы определяем дополнительные валидаторы и сообщения об ошибках
  • пропы для формы включают поле required со значением false для хука
  • мы не лишаем браузер возможности выполнять валидацию инпутов с помощью стандартных атрибутов типа minlength, min, max и т.д., включая тот атрибут required

import { Form } from 'components'

const inputs = [
 // опциональное поле
 {
   label: 'Name',
   name: 'name',
   type: 'text',
   minlength: 2
 },
 // опциональное поле
 {
   label: 'Age',
   name: 'age',
   type: 'number',
   min: 18,
   max: 65,
   step: 1
 },
 // обязательные поля
 {
   label: 'Email',
   name: 'email',
   type: 'email',
   required: true
 },
 {
   label: 'Password',
   name: 'password',
   type: 'password',
   required: true
 },
 {
   label: 'Confirm password',
   name: 'confirmPassword',
   type: 'password',
   required: true
 }
]

// дополнительные валидаторы
// просто для примера
const validators = {
 // имя должно состоять как минимум из 2 символов
 name: (value) => value.length > 1,
 // возраст должен быть в диапазоне 18-65
 age: (value) => value > 17 && value < 66
}

// дополнительные сообщения
const messages = {
 name: 'Your name is too short!',
 age: `You're too young or too old!`
}

const initialData = {
 name: '',
 age: '',
 email: '',
 password: '',
 confirmPassword: ''
}

const formProps = {
 initialData,
 url: '/register',
 inputs,
 required: false,
 validators,
 messages,
 submitLabel: 'Register'
}

export const Register = () => (
 <>
   <h3>Register</h3>
   <Form {...formProps} />
 </>
)

Теперь переходим к самому интересному — форме.


Начнем с определения валидаторов и сообщений об ошибках (Form/validators.js):


// валидатор
const isEmail = (email) => /\S+@\S+\.\S+/.test(email)

// объект с дефолтным валидатором
export const defaultValidators = {
 email: isEmail
}

// сообщения об ошибках, возникших на стороне клиента, - ошибках валидации инпутов
// ключами объекта являются названия полей
export const errorMessagesClient = {
 email: 'Wrong email!',
 confirmPassword: 'Passwords must be the same!'
}

// сообщения об ошибках, возникших на стороне сервера
// ключами объекта являются статус-коды
export const errorMessagesServer = {
 404: 'User not found!',
 403: 'Wrong credentials!',
 409: 'Email already in use!',
 500: 'Something went wrong. Try again later'
}

Form/Button.js:


const buttons = ((b) => ({
 success: `${b}-success`,
 warning: `${b}-warning`
}))('btn btn')

export const Button = ({ label, variant, ...rest }) => (
 <button className={buttons[variant]} {...rest}>
   {label}
 </button>
)

Обратите внимание на то, как мы определяем классы для кнопок. Мы используем самовызываемую функцию (IIFE), возвращающую объект, ключами которого является вариант кнопки. Такую же технику мы используем для определения классов иконок.


Form/Error.js:


import { errorMessagesServer } from './validators'

export const Error = ({ status }) => (
 <div className='text-danger'>
   <p>{errorMessagesServer[status]}</p>
 </div>
)

Обратите внимание на то, как мы получаем доступ к конкретному сообщению об ошибке.


Form/Result.js:


// проп `result` - это объект
export const Result = ({ result }) => (
 <>
   <h4>Result</h4>
   <div>
     {Object.entries(result).map(([key, value], index) => (
       <p key={index}>
         {key}: {value}
       </p>
     ))}
   </div>
 </>
)

Form/Field.js:


const icons = ((i) => ({
 name: `${i}-person`,
 age: `${i}-person-fill`,
 email: `${i}-envelope`,
 password: `${i}-key`,
 confirmPassword: `${i}-key-fill`
}))('bi bi')

export const Field = ({ label, name, error, ...rest }) => (
 <div className='field'>
   <label htmlFor={name} className='form-label'>
     <i className={icons[name]}></i>
     <span>{label}</span>
   </label>
   <input name={name} id={name} className='form-control' {...rest} />
   {/* ошибка валидации */}
   <p className='text-danger'>{error}</p>
 </div>
)

В компоненте формы (Form/index.js) мы делаем следующее:


  • импортируем компоненты формы и лоадер
  • импортируем дефолтные валидаторы и сообщения о клиентских ошибках
  • импортируем наш хук и simpleFetch
  • определяем основной адрес сервера с помощью сеттера baseUrl
  • извлекаем дочерние компоненты, инпуты, валидаторы, сообщения, подпись для кнопки отправки формы и остальное из пропов
  • определяем пропы для хука
  • вызываем хук и извлекаем все возвращаемые им значения
  • при loading: true возвращаем лоадер
  • при наличии данных от сервера возвращаем результат
  • рендерим форму

// import { useState } from 'react'
import { Loader } from 'components'

import { Field } from './Field'
import { Button } from './Button'
import { Result } from './Result'
import { Error } from './Error'

import { defaultValidators, errorMessagesClient } from './validators'

import useSimpleForm, { simpleFetch } from 'useSimpleForm'
simpleFetch.baseUrl = 'http://localhost:5000/api/auth'

export const Form = (props) => {
 // const [data, setData] = useState(null)

 const { children, inputs, validators, messages, submitLabel, ...rest } = props

 const hookProps = {
   ...rest,
   validators: { ...defaultValidators, ...validators },
   messages: { ...errorMessagesClient, ...messages }
 }

 /*
 hookProps.fetchOptions = {
     handlers: {
       onSuccess: (response) => {
         setData(response.data)
       }
     }
   }
 */

 /*
 hookProps.onSubmit = async (fields) => {
   const response = await simpleFetch.post(rest.url, fields)
   setData(response.data)
 }
 */

 const { fields, change, submit, reset, disabled, loading, response, errors } =
   useSimpleForm(hookProps)

 if (loading) return <Loader />
 if (response?.data) return <Result result={response.data} />
 // if (data) return <Result result={data} />

 return (
   <form onSubmit={submit} onReset={reset}>
     {inputs.map((input, index) => (
       <Field
         key={index}
         value={fields[input.name]}
         onChange={change}
         error={errors[input.name]}
         {...input}
       />
     ))}
     {children}
     {response?.error && <Error status={response.info.status} />}
     <div>
       <Button label={submitLabel} variant='success' disabled={disabled} />
       <Button label='Reset' type='reset' variant='warning' />
     </div>
   </form>
 )
}

Обратите внимание на закомментированные фрагменты: в них приводятся примеры определения настроек для simpleFetch и кастомной функции для отправки формы. В обоих случаях возникает необходимость самостоятельной обработки данных, возвращаемых сервером. Также обратите внимание на то, как мы проверяем наличие данных и ошибки от сервера. Мы используем оператор опциональной последовательности для безопасного доступа к свойству потенциально несуществующего объекта вместо response && response.data, например.


Проверка работоспособности


Пришло время проверить работоспособность нашего хука и формы.


Находясь в корневой директории, выполняем команду yarn dev или npm run dev.


Эта команда запускает серверы и открывает вкладку браузера по адресу http://localhost:3000:




Переходим на страницу аутентификации:




Попробуем отправить пустую форму, отключив JavaScript. Для этого в инструментах разработчика нажимаем Cmd + Shift + P, вводим j и выбираем Debugger Disable JavaScript:




Видим, что кнопка для отправки формы осталась заблокированной. Находим ее в разделе Elements инструментов разработчика и удаляем у нее атрибут disabled:




Видим, что кнопка стала активной. Нажимаем ее и… получаем сообщение You need to enable JavaScript to run this app.:




А что если удалить атрибут disabled с включенным JavaScript? Возможно, в этом случае получится обойти систему.


Включаем JavaScript, удаляем disabled, нажимаем кнопку и… ничего не происходит, потому что срабатывает защита от дурака, которую мы определили в нашем хуке — if (disabled) return.


Вводим невалидный email и какой-нибудь пароль (кроме test), нажимаем Enter или кнопку Login:




Получаем сообщение от клиента (хука) о неправильном email.


Вводим test@mail.com:




Получаем сообщение от сервера о неправильных данных для авторизации.


Вводим test в поле для пароля:




Получаем результат с токеном.


Парочка скриншотов с формой для регистрации.


Значение поля для подтверждения пароля не совпадает со значением поля для пароля:




Пользователь с таким email уже зарегистрирован:




Результат:



Заключение


Не люблю писать заключения, поэтому не буду.


Надеюсь, что вы не зря потратили время и нашли для себя что-то интересное.


Любая конструктивная критика приветствуется. Любые замечания и предположения по улучшению хука в форме личных сообщений или пул-реквестов непременно будут учтены в будущих версиях пакета.


Благодарю за внимание и хорошего дня!




Комментарии (22)


  1. nin-jin
    23.08.2021 12:17
    +2

    Боюсь вы тут сделали неправильно всё, что только можно.

    1. В библиотеку работы с формами зачем-то засунули отправку их по http.

    2. Используете поиск по дому для поиска обязательных полей. Причём делаете это некорректно. Банальную текстарию вы так не найдёте. Про кастомные контролы и говорить не приходится.

    3. Повреждаете пользовательские данные при сохранении вырезая "не нужные" символы.

    4. Сообщения касающиеся конкретного поля отображаются вдалеке от него.

    5. Валидация происходит лишь после заполнения и отправки всей формы.

    6. Захардкодили в библиотеку проверку совпадения полей. Это если и нужно, то в 1-2 форме на весь проект. Но даже в них это UX-антипаттерн.

    7. Вешаете стили на теги.

    8. Выводите одно и то же сообщение для любых ошибок валидации.

    9. Собираете имена классов из строк.

    10. Во время запроса форма вообще пропадает, а потом снова показывается, чтобы показать ошибку.


    1. aio350 Автор
      23.08.2021 12:37

      спасибо за критику, жду вашей версии хука


      1. nin-jin
        23.08.2021 13:39
        -2

        0. Выбрали для реализации Реакт, где классы эмулируются на хуках, а для простейшей формы приходится обмазываться костылями.


        1. Vlad_Murashchenko
          23.08.2021 14:54

          А зачем в JS классы, если объекты можно создавать без них?
          Не считая, конечно, случая когда кто-то перешел из другого языка и не может без классов.


          1. nin-jin
            23.08.2021 15:15

            А зачем их эмулировать, если можно использовать нативные синтаксические конструкции?


            1. aio350 Автор
              23.08.2021 15:31

              Классы эмулируются на хуках? Что вы имеете ввиду? Хуки - это обычные функции. Современный React полностью функциональный, если можно так выразиться. Кроме того, к вашему сведению, в JS классов как таковых не существует. Класс в JS - это разновидность функции, обертка (синтаксический сахар) над функцией-конструктором. Так что эмуляция классов с помощью функций - это вполне в духе JS


              1. nin-jin
                23.08.2021 15:43

                Хуки - это функции с сайд эффектами. К ФП они имеют такое же отношение, как морские свинки к морю и свинкам.

                Комбинация функции-конструктора и прототипа - это и есть класс. Недавно лишь позволили на уровне синтаксиса называть вещи своими именами. Обычным функциям (не классам) прототипы нафиг не нужны. Именно поэтому, например, у стрелочных функций вы никакого прототипа уже не найдёте и создавать с их помощью объекты не сможете.


                1. Vlad_Murashchenko
                  23.08.2021 16:23

                  Комбинация функции-конструктора и прототипа - это и есть класс

                  В большенстве случаев, почти во всех, нам не нужны прототипы. Достаточно просто функций которые создают объекты. А иногда можно и сразу создать объект синглтон без всяких функций и классов.

                  Хуки - это функции с сайд эффектами.

                  Это не совсем так. Хуки позволяют общатся с ядром реакта, мы не можем делать сайд эффекты в хуках, но можем попросить реакт сделать сайд эффект за нас.


                  1. nin-jin
                    23.08.2021 18:23

                    Ну да, для каждого свойства по отдельному объекту - это очень прям нужно в большинстве случаев.

                    Ох уж эта словесная эквилибристика. Мы и переменной значение присвоить не можем, а можем лишь попросить JS движок сделать это за нас.


                    1. Vlad_Murashchenko
                      24.08.2021 16:29

                      Наверное, вы хотели сказать, для каждого объекта по отдельному свойству? Эти оптимизации с экономией памяти не нужны в большинстве случаев.

                      Я имел ввиду, что напрямую сайд эффекты делать нельзя. Правила такие же как в компоненте. Компонент это ведь не просто функция с сайд эффектами. А значит хуки не равно функции с сайд эффектами.


                      1. nin-jin
                        24.08.2021 20:05

                        Да нет, для каждого свойства по объекту `{ state, setState }`. Не удивительно, что на Реакте приложения так тормозят.

                        Сайд-эффект - они и в Африке сайд-эффект, как его ни вызывай.


              1. monochromer
                23.08.2021 19:19

                В JavaScript классы существуют. Просто они не являются эквивалентами для классов из других языков. К тому же es6-классы - "не просто синтаксический сахар"(перевод).


          1. AxisPod
            23.08.2021 18:21
            +1

            Эмуляция объекта работает однозначно медленее, чем объект на базе прототипа. А по поводу хуков я согласен с 0м сообщением.

            Хуки сделали для того, чтобы ещё снизить порог вхождения, но низкий порог вхождения чреват тем, что появляется куча народу "знающих" как надо программировать.

            Класс даёт очень хорошие возможности по инкапсуляции. В итоге всё в одном месте. Функциональный же подход привёл лишь к тому, что постоянно есть куча функций, а когда их становится сотни и более разбираться становится ну очень не просто.

            Да и хуки привели лишь к тому, что цикл работы компонента стал ещё менее понятен и куда хуже поддаётся контролю.

            В итоге сделать прототипчик проще, заставить его хорошо работать стало куда сложнее.


            1. aio350 Автор
              23.08.2021 20:42

              А вы смотрели исходный код React? Ни одного класса, только функции. Как же они без классов-то справляются? Или им, по-вашему, инкапсулировать нечего? Я писал на "классовом" React, сейчас пишу на "функциональном" и нисколько не жалею об отсутствии классов со всеми их this, bind и прочим. И не кажется ли вам, что "все в одном месте" противоречит самой сути компонентной разработки?


              1. nin-jin
                23.08.2021 21:17

                Ага, только вместо bind теперь вы используете useCallback.

                И классы как бы тоже не заставляют вас всё совать в один объект.


            1. YSpektor
              23.08.2021 21:20

              Хуки тоже дают инкапсуляцию на самом деле. То, что они принимают и возвращают - это публичный интерфейс из данных и методов для работы с ними, а что они скрывают внутри - вызывающий код и знать не знает. Другое дело что да, удобство хуков по сравнению с классами работает хорошо до тех пор, пока этих самых вызовов хуков немного. И пока в них направо и налево не начинают передаваться коллбеки. Поэтому я, например, стараюсь избегать использование всяких там useEffect/useCallback и прочих в компоненте напрямую, оборачивая их во что-то более удобоваримое (кастомные хуки/HOC)


      1. dopusteam
        23.08.2021 22:45

        Вообще, товарищ @nin-jinвысказал все очень правильно

        Вы намешали в кучу всего что можно и выглядит это трешово

        Как мне сделать разные сообщения на разные ошибки? Сколько будет стоить расширить хук кастомным контролом? Могу я опустить последний параметр в хуке если перекрою onSubmit? Зачем делить messages и validators, выглядит так, будто они связаны. Какие поля должны быть в initialData? Обязательно все перечислять?

        Вы проигнорили все 10 пунктов, что перечислил товарищ выше, может ответите по ним что нибудь? Вы действительно советуете юзать Вашу либу для форм?

        Дополню ещё, что мало того, что в либу с формами добавили http зачем то, так еще и зависимость от сторонней либы =/


  1. Vlad_Murashchenko
    23.08.2021 14:10
    +1

    Спасибо за статью и ваш труд.
    Но я не считаю решение описанное здесь гибким и универсальным.

    Вы создали один хук который умеет делать все на свете, а коду который его использует нужно подстраиваться под него. При этом часть функционала может быть не нужна в каждой форме. Например reset.

    На мой взгляд, лучше создать много маленьких хуков и утилит и использовать в каждой из форм только те, которые нужны конкретно в ней. При этом само состояние я бы оставил под контролем самой формы, чтобы можно было отказаться от использования хука и написать кастомный обработчик в случае необходимости.

    То что вы делаете с input-ами, выражая их через массив обычных объектов и рендеря через map в своей статье я называл "формы из замоканных конфигов". И я по прежнему считаю это антипаттерном. Вот ссылка на мою статью о формах:
    https://habr.com/ru/post/495518/

    Еще один пример моего подхода можно посмотреть здесь:
    https://codesandbox.io/s/github/VladislavMurashchenko/use-prop-change-sandbox?file=/src/containers/UserForm.tsx
    К сожелению, пока нет примера валидации.


  1. aio350 Автор
    23.08.2021 15:43

    Спасибо за комментарий.
    Давайте по порядку.
    1. "Вы создали один хук который умеет делать все на свете...".
    Во-первых, не все на свете, а только работать с формами. Во-вторых, разве кастомные хуки разрабатываются не для того, чтобы повсюду "переиспользоваться" без необходимости переписывания для конкретного кейса?
    2. "При этом само состояние я бы оставил под контролем самой формы...".
    Пожалуйста, реализуйте собственную функцию "onSubmit" и передайте ее в хук, и будет вам "кастомный обработчик".
    3. "И я по прежнему считаю это антипаттерном...".
    Ну, а я считаю это хорошим паттерном)
    Да, я читал вашу статью, интересная.


    1. Vlad_Murashchenko
      23.08.2021 16:41

      Во-первых, не все на свете, а только работать с формами.

      Да, но я немного о другом. Есть 2 подхода к переиспользованию кода.
      1) Написать один хук который принимает большой конфиг и может делать абсолютно все, что связано с формами. А потом мы используем его в каждой нашей форме, берем только половину от возможностей, какие-то вещи переопределяем или используем хаки вроде setFieldValue из Formik. В итоге складывается впечатление, что инструмент не помогает, а мешает в некоторых ситуациях.
      2) Создать много маленький хуков и утилит которые хорошо сочетаются между собой и собирать разные формы используя их как строительные блоки, которые стараются помочь, но не диктуют архитектуру и не ограничивают, так как любой из блоков можно легко выкинуть и заменить кодом который делает что-то вручную. При этом, эти строительные блоки не догадываются о существовании друг друга, если какой-то из них недостаточно хорош для текущей задачи, остальные все же полезны. В худьшем случае ниодна из утилит не подойдет и мы напишем абсолютно все вручную, но они хотя бы не мешают


      1. nin-jin
        23.08.2021 18:35

        На самом деле есть ещё и 3 способ: Не пытаться предусмотреть всё на свете, но предоставить разумное поведение по умолчанию с возможностью настроить поведение любого аспекта под себя. Но на Реакте это сделать сложно, поэтому обычно получается (1), потом разработчики упарываются по (2), на базе которого всё-равно потом собирают (1), ибо каждый раз собирать типовую форму из микро-кирпичиков задалбывает.


  1. YSpektor
    23.08.2021 16:18

    Тоже делал что-то подобное только несколько иначе. Что мне не нравится в предложенной реализации (уже обо многом сказали выше):

    • Отправкой формы и то, как это реализуется (урл и т.д.) - это не задача этого хука, нарушается SRP.

    • Форма собирается из массива данных - тут две проблемы: во-первых мешаются в кучу данные и представление. Я предпочитаю, когда представление реализуется в jsx/html-темплейте, а не в Json-е. Иначе такой код очень трудно воспринимать. Во-вторых, подумайте что вы будете делать, если некоторые ваши инпуты перестанут быть обычными инпутами, а станут селектами, мультиселектами, текстареями, автокомплитами и т.д. Во что превратиться ваша конфигурация такой формы и как дорого вам обойдется добавление нового типа поля? А если бы вы просто собирали вашу форму в jsx и подвязывали все нужные пропсы ручками - реализовали бы все что угодно без лишней головной боли

    • Валидация может быть какая угодно, не только required/minLength и т.д. Если мы настраиваем модель валидации, и получаем объект с ошибками валидации, то мы должны учесть любые кастомные правила и то, что у одного поля может быть несколько ошибок валидации, которые неплохо бы отобразить одновременно

    Я тоже делал что-то подобное на TypeScript. Там я описывал саму модель, схему валидации, например:

    interface SoilLayerVM {
        soil_id: string;
        depth: string;
    }
    
    const validationScheme: ValidationScheme<SoilLayerVM> = {
        soil_id: {
            required: Validators.required
        },
        depth: {
            required: Validators.required,
            number: Validators.float,
            range: Validators.range(0, 1000)
        }
    };

    И тоже делал похожий хук:

    const { errors, setFormField, validate } = useForm(model);

    Вызывал validate когда нужно (здесь onValidate - это кастомный проп, не то же самое что одноименный HTML-атрибут, ну да не важно, можно в onSubmit если хочется)

    <Form onValidate={() => validate(validationScheme)} ...>

    а errors, setFormField подвязывал к компонентами вручную, типа

    <FormTextField label="Глубина, м" value={model.depth} onChange={e => setFormField('depth', e.target.value)} 
        error={!!errors?.depth} errorHintComponent={
            <>
                {errors?.depth?.required && <FormHintText>Поле является обязательным</FormHintText>}
                {errors?.depth?.number && <FormHintText>Введенное значение не является числом</FormHintText>}
                {errors?.depth?.range && <FormHintText>Значение должно быть в диапазоне [0-1000]</FormHintText>}
            </>
        } />