Пишем систему валидации форм для React приложений (и не только).

Мотивация

Cтатья основана на практике 2х проектов, которые имели следующие условия, повлиявшие на итоговое решение по реализации фичи валидации:

  • полей ввода и форм было много

  • полей ввода и форм могло стать ещё больше

  • есть сложные кастомные валидации (например файлы)

  • есть взаимосвязанные поля с асинхронной проверкой

  • есть разные окружения (браузер, node.js, потенциально могла прибавиться мобилка)

И конечно же все некоторые аспекты проектов могли в любой момент измениться (привет agile).

Всё это привело к формированию следующих требований:

  • гибкость и расширяемость

    • т.к полей ввода и форм много и может стать ещё больше, нужна легко переиспользуемая система, которую можно легко и адаптивно настраивать под потребоности интерфейсов и пользователей.

    • нам должно быть легко дополнять систему по требованию. Мы должны быть способны в любой момент напилить обработку новых полей с различной логикой (асинхронной, взаимосвязанной и т.д)

  • кроссплатформенность

    • мы должны уметь запускаться в разных окружениях. Например на бэкенде (если вдруг мне понадобится написать proxy gateaway сервер с простеньким ssr).

    • нам не надо зависеть от платформо-специфических api (в том числе от браузерных)

  • zero-dependency

    • не хотелось добавлять ещё одну зависимость в проект/проекты

Какой вектор решения выбрать

Как и всегда, для того, чтобы решить какой путь решения задачи выбрать, мне пришлось:

  • обратился к своему опыту

  • обратится к опыту коллег

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

Опыт коллег показал, что в основном, для построения стабильной системы люди выбирали самописные решения, которые строились по формату: одна штука валидирует, вторая штука привязывает это к ui. Благо в тех компаниях, на проектах которых я работал, были тысячи IT специалистов с большим опытом и было куда подглядеть и кого поспрашивать).

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

Оставался только один вопрос - надо пилить вообще всё кастомное или же использовать HTML5 Constraint Validation API. Если ещё не читали этот туториал, то очень рекомендую это сделать.

Размышления были долгими, споры были жаркими и не все ребята на проекте со мной согласились, но проведя сравнительный анализ, всё же было принято решение в пользу разработки полностью кастомной и не завязанной ни на какое API системы.
И вот почему:

Категория

Критерий

Custom validation

Constraint Validation API

Консистентность

Единый подход при работе с разными типами проверяемых значений

все валидаторы имеют одинаковую структуру

Для файлов и подобных штук всё равно выносить нужно в отдельные валидаторы. Часть валидаторов "вшита" в разметку

Разделение ответственности

Разделение логики валидации и логики представления данных

логика валидации мапится на вью в компоненте могут существовать отдельно друг от друга

логика валидации "прибита гвоздями" к html - одно без другого не существует

Масштабируемость

перенос на веб

легко

легко

Масштабируемость

перенос на бэк

легко

невозможно

Масштабируемость

перенос на мобилки

легко

частично

Масштабируемость

возможность использовать применять валидацию к нестандартным элементам (канвас например или кастомные элементы)

да

нет, только поддерживаемые спекой форматы https://developer.mozilla.org/en-US/docs/Learn/Forms/Form_validation#the_constraint_validation_api

Платформо-зависимость

Зависимость от браузера

запускаемся где хотим

запускаемся только в браузере и при этом имеем ограничения: 1) ie частично 2) opera mini не поддерживается 3) minlength вообще в ie имеет проблемы и т.п

Гибкость

возможность показывать кастомные ошибки

да

да

Гибкость

возможность управлять очерёдностью проверок

да

нет

Гибкость

возможность делать связанные/динамические поля и валидации

да

да

Прочее

наличие валидаторов

нет, надо писать с нуля

многие есть, но что-то чуть посложнее надо писать самому

Прочее

функция запуска проверок

нет, надо писать с нуля

всё готово к употреблению

Прочее

порог входа

низкий, надо написать пару функций уровня Junior+

надо вызубрить всё api

Плюс в спеке так же говорится про кастомные контролы https://developer.mozilla.org/en-US/docs/Learn/Forms/How_to_build_custom_form_controls, что снова кладёт на нашу чашу весов ещё одну монетку.

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

Итак, поехали!

PS:
Для сравнения доступности апи как всегда использовался убер ресурс caniuse.com

Структура

Глобально статья состоит из 2х частей: база и react

База
Эта часть про систему валидации, которая занимается только валидацией и больше ничем.
Максимум независимости от всего, чего только можно. Нам нужен только JS движок для исполнения нашего кода.

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

База: Валидация значений

Валидаторы это простые функции, задача которых провести соответствующие проверки над переданным в неё значением.

Валидация вынесена в отделяемую абстракцию, для того, чтобы быть независимой от платформы, разметки/визуальных элементов и иметь возможность легко переиспользоваться на node.js (и в любом окружении, где работает js).

Валидаторы

Каждая функция валидатор состоит из 3х частей:

// Обёртка, которая позволяет кастомизировать сообщения об ошибках
type GetValidator<Options, Params> = (options: Options) => Validator<Params>;

// Валидатор, который непосредственно проводит валидацию
type Validator<T> = (params?: T) => Promise<ValidationResult>;

// Результат валидации:
type ValidationResult = string | null;

Все валидаторы по умолчанию являются асинхронными и должны возвращать промис с результатом валидации Promise<ValidationResult>. Даже в кейсах, когда внутри валидатора не требуется асинхронная логика. Такая реализация позволяет одновременно использовать в одной подборке
валидаторов для поля как синхронные, так и асинхронные валидаторы, при этом не усложняя кодовую базу.

Запуск валидации

Функция validate поочерёдно запускает массив валидаторов с переданным значением.

const validate: (value: any, validators: Validator[]) => Promise<ValidationResult>;

Данная функция не выполняет "лишней" работы, а останавливается на первом невалидном результате. Т.е. Если в наборе из 4х валидаторов у нас валидатор №1 вернул ошибку, то валидаторы 2, 3, 4 уже не будут запущены в этой итерации валидации (так как в этом нет смысла - пользователю нужно исправить сначала ошибку, которую мы словили на валидаторе №1 и уже после этого переходить в последующим проверкам).

Полный код функции validate:

type ValidationResult = string | null;
type Validator<T> = (params: T) => Promise<ValidationResult>;
type GetValidator<Options, Params> = (options?: Options) => Validator<Params>;

const validateValue = async <T>(
  value: T,
  validators: Validator<T>[]
): Promise<ValidationResult> => {
  let validationResult: ValidationResult = null;
  let i = 0;

  while (validationResult === null && i < validators.length) {
    const res = await validators[i](value);

    res && (validationResult = res);
    i++;
  }

  return validationResult;
};

Пример использования системы валидации

Рассмотрим валидацию на примере загрузки файлов с пользовательской машины. Предположим у нас есть <input type="file" /> и пара бизнес требований:

  1. ограничить максимальный вес загружаемого файла в 10мб

  2. ограничить максимальное разрешение загружаемого изображения в 25мп (перемножение ширины на высоту должно быть не более 25 000 000 пикселей)

Первое требование реализуется через синхронный валидатор. Второе - через асинхронный (например если реализовывать через img.decode() метод - https://developer.mozilla.org/en-US/docs/Web/API/HTMLImageElement/decode).

И вот как раз для того, чтобы не усложнять кодовую базу валидаторов, ровно как и код, который использует эти валидаторы, мы "приводим" все валидаторы к одному типу - делаем их все асинхронными.
В противном случае нам пришлось бы "ветвить" код использования синхронных и асинхронных валидаторов, плюс реализовывать пару вариантов validateValue с соответствующими типами.

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

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

Например:

import type { GetValidator } from './index';

const required: GetValidator<string, string> = (
  message = 'Обязательное поле'
) => {
  return async (value) => (value ? null : message);
};

export default required;

Пример использования системы валидации

import type { Validator } from './utils/validators';
import validate, {
  required,
  maxLength,
  minLength,
} from './utils/validators';

const validators = [required(), minLength(5), maxLength(150)]
const value: string = '123456'

const validationResult = validate(value, validators)

/* do with your **validationResult** whatever you want */
...

В этом примере значение validationResult равно null, что означает успешно пройденную валидацию по переданному массиву валидаторов. Проверяемое значение value:

  • не пустое (валидатор required())

  • имеет длинну более 5 символов (валидатор minLength(5))

  • имеет длинну не более 150 символов (валидатор maxLength(150))

Итого

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

Несколько примеров валидаторов с тестами можно найти по ссылке в тестовом репозитории src/validators. Ну а дальше по аналогии пишутся свои валидаторы под ваши нужды (телефоны, мейлы, маскированные инпуты и т.д и т.п.). Главное не забывать про тесты и логика будет легко переносима и управляема в процессе применения в разных проектах)

Валидация форм в React

Теперь, когда у нас есть простая как js (тут можно посмеятся), атомарная, масштабируемся и отделяемая система валидации мы можем подумать над тем, как это дело запустить в контексте нашей любимой библиотеки интерфейсов React.

Требования

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

  • иметь возможность быстро компоновать нужные нам формы

  • не привязываться к конкретному представлению (view)

  • иметь возможность валидировать как отдельное поле, так и всю форму

  • реализовывать best practice по работе с формами

Так же неплохо бы иметь возможность в любой момент проверить наличие ошибок в поле/форме. Это может пригодится в различных более сложных кейсах со связанными и/или динамическими полями.

Триггеры запуска валидации

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

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

Поэтому обозначим несколько ключевых моментов, когда хотим запускать валидацию:

  • Для поля

    • при "касании" (blur)

    • по мере ввода данных (change)

    • по кастомному триггеру

  • Для формы

    • при отправке формы (submit)

    • по кастомному триггеру

По каждому из этих триггеров мы будем запускать валидацию.

Хук useTextFormField

Теперь представим, что мы пилим форму, в которой есть одно текстовое поле. Например это форма обратной связи (где нам почему-то без разницы от кого был отзыв и мы просто хотим посмотреть, что нам напишут товарищи пользователи). Т.е. это форма на одно поле - textarea.

Начнём с того, что запилим хук, который позволяет использовать нам любые текстовые поля (input type text и textarea).

В принципе каждый хук, который в будущем будет отвечать за свой тип поля ввода (например за checkbox или radio button), может расширять анатомию DefaultField по своим потребностям.

Все поля, модели работы с которыми мы будем описывать будут основываться на дефолтном поле:

type DefaultField = {
  id: string;
  value: string;
  error: null | string;
  hasError: () => Promise<boolean>;
};

Для текстовых полей useTextFormField мы расширяем DefaultField следующим образом:

type TextField = DefaultField & {
  handleChange: (
    event: ChangeEvent<HTMLTextAreaElement | HTMLInputElement>
  ) => void;
  handleBlur: () => void;
};

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

import { useCallback, useState } from 'react';
import type { ChangeEvent } from 'react';

import type { Validator, ValidationResult } from '../../validators';
import validateValue from '../../validators';
import type { DefaultField } from './types';

type TextField = DefaultField & {
  handleChange: (
    event: ChangeEvent<HTMLTextAreaElement | HTMLInputElement>
  ) => void;
  handleBlur: () => void;
};

function useTextFormField(
  id: string,
  validators: Validator<string>[],
  init = ''
): TextField {
  const [value, setValue] = useState(init);
  const [error, setError] = useState<ValidationResult>(null);

  const handleChange = useCallback(
    async (event: ChangeEvent<HTMLTextAreaElement | HTMLInputElement>) => {
      const val = event.target.value;

      setValue(val);
      setError(await validateValue(val, validators));
    },
    [validators]
  );

  const handleBlur = useCallback(async () => {
    setError(await validateValue(value, validators));
  }, [value, validators]);

  const hasError = useCallback(async () => {
    const err = await validateValue(value, validators);
    setError(err);

    return !!err;
  }, [value, validators]);

  return {
    id,
    value,
    error,
    hasError,
    handleChange,
    handleBlur,
  };
}

export { TextField };
export default useTextFormField;

Хук формы useForm

Хук для текстового поля у нас уже есть, теперь опишем логику отправки формы.

useForm это рутовый хук, который будет принимать набор полей формы, валидировать значения, выполнять запрос к апи (сабмит формы) и предоставлять информацию об успешности/не успешности сабмита.

Ну и разумеется будем в место вызова хука возвращать краткое описание его текущего состояния через флаг isSending:

import { useState } from 'react';
import type { FormEventHandler } from 'react';
import type { DefaultField } from './types';

function useForm<Field extends DefaultField, Response>(props: {
  fields: Field[];
  apiCall: () => Promise<Response>;
  onSuccess?: (response: Response) => void;
  onFailure?: (error: string) => void;
}): {
  isSending: boolean;
  sendingError: string;
  hasFieldErrors: boolean;
  handleFormSubmit: FormEventHandler<HTMLFormElement>;
} {
  const { fields, apiCall, onSuccess, onFailure } = props;

  const [isSending, setIsSending] = useState(false);
  const [sendingError, setSendingError] = useState('');

  const handleFormSubmit: FormEventHandler<HTMLFormElement> = async (event) => {
    event.preventDefault();

    const errors = await Promise.all(fields.map((field) => field.hasError()));
    const isFormValid = errors.every((error) => !error);

    if (isFormValid) {
      setIsSending(true);
      setSendingError('');

      try {
        const response = await apiCall();
        onSuccess?.(response);
      } catch (err) {
        const msg =
          err instanceof Error
            ? err.message
            : 'Что-то пошло не так, попробуйте ещё раз';

        setSendingError(msg);
        onFailure?.(msg);
      } finally {
        setIsSending(false);
      }
    }
  };

  const hasFieldErrors = fields.some((field) => !!field.error);

  return {
    isSending,
    sendingError,
    hasFieldErrors,
    handleFormSubmit,
  };
}

export default useForm;

Примерчики хуков можно посмотреть здесь src/form-validation-hooks. По ссылке примеры хуков для работы с кастомным селектом, радио группой, инпутом для файлов и для текстовых полей.

PS:
Подробнее, про причину столь забавной типизации ошибки в catch блоке можно почитать здесь: get-a-catch-block-error-message-with-typescript

Собираем всё вместе

Нам осталось соеденить эти 2 фичи вместе:

  • Валидаторы (validate и наш кастомный required валидатор)

  • Кастомные хуки: useForm (для формы) и useTextFormField (для текстового поля)

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

  1. собираем массив валидаторов (в нашем случае это один required валидатор)

  2. инициализируем хуки

  3. прикручиваем модели формы и поля к разметке так, как нам нужно

import React from 'react';

import Preloader from 'your-ui-kit-library/Preloader';

import { required } from 'some-path/validators';
import { useForm, useTextFormField } from 'some-path/hooks';
import type { TextField } from 'some-path/hooks/types';

import api from 'some-path/api';
import type { Response as ApiResponse } from 'some-path/api/handlers/post-feedback';

import Styles from './index.css';

const validators = [required('our custom error message')];

function FormPage() {
  const feedback = useTextFormField('feedback', validators);

  const form = useForm<TextField, ApiResponse>({
    fields: [feedback],
    apiCall: () =>  /* your api call  */
    onSuccess: () => {
      /* api success call handler */
    },
    onFailure: () => {
      /* api failure call handler */
    },
  });

  return (
    <div className={Styles.layout}>
      <header className={Styles.header}>
        <h1 className={Styles.title}>Обратная связь</h1>
      </header>
      <main className={Styles.main}>
        <form className={Styles.form} onSubmit={form.handleFormSubmit}>
          <fieldset className={Styles.fieldset}>
            <p className={Styles.label}>Помогите нам стать лучше</p>
            <div className={Styles.textarea}>
              <textarea
                id={feedback.id}
                value={feedback.value}
                onChange={feedback.handleChange}
                onBlur={feedback.handleBlur}
                name="feedback"
                data-error={!!feedback.error}
              />
            </div>
          </fieldset>
          <div className={Styles.submitWrapper}>
            <button
              type='submit'
              className={Styles.submitButton}
              disabled={form.isSending || form.hasFieldErrors}
            >
              Получить
            </button>
            {form.isSending && (
              <div className={Styles.loader}>
                <Preloader />
              </div>
            )}
            {form.sendingError && (
              <p className={Styles.error}>{form.sendingError}</p>
            )}
          </div>
        </form>
      </main>
    </div>
  );
}

Немного пояснений:

  • Используем textarea и делаем её управляемым компонентом

  • стилизацию textarea в случае ошибки делаем через [data-error] аттрибут уже в css

  • отключаем кнопку на время отправки или в случае найденных ошибок

  • крутим крутилку (любой Preloader, который больше нравится) пока запрос активный

  • показываем сообщение об ошибке, если оно есть, через form.sendingError

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

Итого

Мы имеем лёгку, гибкую систему ��остоящую из двух частей:

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

  • Вторая даёт нам возможность использовать апи хуков для реализации прекрасной реюзабельности хуков между формами.

Надеюсь вам было любопытно прочитать про этот подход)

Спасибо за чтение и удачи в реализации вашей валидации (кажется каждый frontend должен запилить за свою карьеру хотя бы раз свою собственную кастомную валидацию)))

PS:
В опытной эксплуатации данный подход был обкатан на двух проектах средней длительности (до 1.5 лет) и в процессе работы не встречалось ни недостатка гибкости в системе, ни критических проблем). Валидаторы пилились, некоторые мигрировали между фронтом/бэком разных проектов одной экосистемы, хуки писались, формы валидировались, а тимлиды/продуктологи радовались быстро написанным формам ))
Но это история только про мой боевой опыт. Если же у вас есть время и желание поделится вкратце своей историей - велкам в комментарии)

PPS: ссылки из статьи