Pull to refresh
1985.92
Timeweb Cloud
То самое облако

React: немного о работе с формами

Reading time19 min
Views13K


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


В последнее время мне на глаза часто попадаются статьи, посвященные работе с формами в 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 уже зарегистрирован:




Результат:



Заключение


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


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


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


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




Tags:
Hubs:
Total votes 6: ↑4 and ↓2+2
Comments22

Articles

Information

Website
timeweb.cloud
Registered
Founded
Employees
201–500 employees
Location
Россия
Representative
Timeweb Cloud