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


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


К слову, в React для удобной работы с формами уже набрали популярность 3 отличных библиотеки. Это Formik, Redux Form и React Hook Form. На сайте последнего представлены плюсы перед конкурентами.


Для начала


Для начала нам нужно создать React приложение. Сделаем это через Create React App. Если информации по ссылке будет недостаточно, то github.


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


npx create-react-app react-custom-forms-article  --template typescript

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


Приступая к реализации хука


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


// App.tsx
const formInputs = {
  firstName: {},
  lastName: '',
}

В компоненте инициализируем форму.


// App.tsx
const App = () => {
  const { fields, handleSubmit } = useForm(formInputs);

  const { firstName } = fields;
  return <></>;
}

handleSubmit – метод, принимающий callback-функцию и возвращающий значение всех полей.
fields – структура вида "ключ-значение", в ней будут храниться все описанные ранее поля формы с дополнительной информацией, например: свойство с текстом ошибки, значение поля, флаг валидности поля и те свойства, что будут переданы в объекте при создании формы.


Компонент вернет примерно такую верстку.


// App.tsx
// Метод выполнения формы при срабатывание onSubmit
const onSubmit = ({ values }: { values: IValues }) => {
  console.log(values, 'submit');
}

return (
  <div className="App">
    <form onSubmit={handleSubmit(onSubmit)}>
      <input type="text" value={firstName.value} onChange={firstName.setState}/>
    </form>
  </div>
);

useForm


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


// hooks/useForm.ts
export const useForm = (initialFields: any = {}) => {
  const form = Object.entries(initialFields).reduce((fields, [name, value]: any[]) => {
    const isString = typeof value === 'string';

    const field = {
      [name]: {
        value: (isString && value) || ((!isString && value.value) || ''),
        setState: (value: ChangeEvent<HTMLInputElement>) => handleInput(value, name),
        ...(!isString && value),
      }
    }

    return { ...fields, ...field };
  }, {});

В этом примере кода для удобства итераций используется метод объектов entries, который возвращает массив вида [[propName, propValue], [propName, propValue]], и метод для работы с массивами reduce, который помогает собрать объект заново. В целом все выглядит неплохо, но не хватает методов для обновления значения полей. Добавим состояний с использованием React Hook.


// hooks/useForm.ts
...
const [fields, setState] = useState<any>(form);

const handleInput = (element: ChangeEvent<HTMLInputElement>, name: string) => {
  const input = fields[name];
  const value = element.target.value;
  const field = { ...input,  value };

  setState(prevState => ({ ...prevState, [name]: field });
}

Здесь заводится состояние для полей формы, и в качестве начального значения используется готовая структура формы. Функция handleInput будет необходима для редактирования данных. Как видно из кода, стейт будет обновляться полностью. Это специфика хука useState и текущей реализации. Если бы для работы с состояниями использовалась библиотека RxJs вместо хука useState, то была бы возможность обновлять состояние частично, не провоцируя повторный рендер компонента. В setState в данном примере состояние обновляется также через функцию обратного вызова. При использовании записи вида setState({ ...fields, [name]: field }) изменение другого поля провоцировало бы возврат остальных полей к исходным значениям.


Следующий пример кода проиллюстрирует применение формы.


// hooks/useForm.ts
...
const handleSubmit = (onSubmit: Function) => (e: FormEvent) => {
    if (e && e.preventDefault) {
      e.preventDefault();
    }

    const values = Object.entries(fields).reduce(((prev: any, [name, { value }]: any) => ({ ...prev, [name]: value })), {});

    onSubmit({ values });
  }

При помощи [каррирования] (https://learn.javascript.ru/currying-partials) принимается переданная из компонента функция и далее при сабмите вызывается с аргументами из хука. Каррирование в примере выше используется для того, чтобы иметь возможность вызвать метод в верстке, не выполняя его при рендере компонента.


Таким образом у нас получился минимальный хук для обычных форм.


Весь код хука
import { ChangeEvent, FormEvent,, useState } from 'react';

export const useForm = (initialFields = {}) => {
  const form = Object.entries(initialFields).reduce((fields, [name, value]: any[]) => {
    const isString = typeof value === 'string';

    const field = {
      [name]: {
        value: (isString && value) || ((!isString && value.value) || ''),
        setState: (value: ChangeEvent<HTMLInputElement>) => handleInput(value, name),
        ...(!isString && value),
      },
    };

    return { ...fields, ...field };
  }, {});

  const [fields, setState] = useState<any>(form);

  const handleInput = useCallback(
    (element: ChangeEvent<HTMLInputElement>, name: string) => {
      const input = fields[name];
      const value = element.target.value;
      const field = { ...input, value };

      setState(prevState => ({ ...prevState, [name]: field });
    }, [fields, setState]);

  const handleSubmit = (onSubmit: Function) => (e: FormEvent) => {
    if (e && e.preventDefault) {
      e.preventDefault();
    }

    const values = Object.entries(fields).reduce(((prev: any, [name, {value}]: any) => ({ ...prev, [name]: value })), {});

    onSubmit({ values });
  }

  return {
    handleSubmit,
    fields,
  }
}

Типизация


Я не хочу останавливаться на этой теме, так как данный код можно спокойно использовать и без TypeScript, поэтому в коде могут внезапно появляться какие-то типы, чтобы все корректно собрать, в конце статьи будет предоставлена ссылка на github.


Валидация


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


Итак, немного перепишем схему. Здесь указан массив validators для обоих полей и добавлен флаг обязательности поля.


// App.tsx
...
const formInputs = {
  firstName: {
    required: true,
    validators: [
      (s: string) => !s.length && 'Поле обязательно для заполнения',
      (s: string) => s.length < 2 && 'Минимальная длина строки 2',
      (s: string) => s.length <= 2 && 'А теперь 3',
      (s: string) => parseInt(s) < 2 && 'Должна быть цифра больше 1',
    ]
  },
  datetime: {
    validators: [
      (s: string) => new Date(s).getUTCFullYear() > new Date().getUTCFullYear() && 'Год рождения не может быть больше текущего',
    ],
  },
}

Помимо обновленной схемы добавим в хук переменную isValid, которая будет запрещать/разрешать отправку формы по кнопке. Рядом с полями также будет выводиться ошибка. К слову, ошибку будем выводить только для «тронутых» полей.


// App.tsx
...
const { fields, isValid, handleSubmit } = useForm(formInputs);
const { firstName, datetime }: Form = fields;

return (
    <div className="App">
      <form onSubmit={handleSubmit(onSubmit)}>
        <input type="text" value={firstName.value} onChange={firstName.setState}/>
        <span>{firstName.touched && firstName.error}</span>
        <input type="date" value={datetime.value} onChange={datetime.setState}/>
        <span>{datetime.touched && datetime.error}</span>
        <button disabled={!isValid}>Send form</button>
      </form>
    </div>
);

Разберем этот код. В показанной функции ожидается 2 аргумента, field – поле инпута, второй – опциональный, в нем будут храниться дополнительные свойства для поля. Далее при помощи деструктуризации объекта заводятся переменные value, required и массив валидаций. Чтобы не менять свойства аргумента, заводятся новые переменные error и valid. Я объявил их через let, так как меняю их в процессе. В коде до обхода массива валидаторов стоит проверка на required, в теле условия проверяется значение поля, и там же прокидывается ошибка.


Мы подошли к условию, где проверяем переменную validators. Она должна быть массивом. Далее по коду создаем массив результатов выполнения функций валидации при помощи метода массива map. validateFn – здесь хранится функция, в которую передается значение поля из свойства value. Результат выполнения функции валидации должен быть строкой, так как выводить мы будем именно текст ошибки. Если результат не строка, то возвращаться будет что-то другое. Конкретно здесь – пустая строка, но там может быть и другое значение, например false. В любом случае далее происходит фильтрация массива результатов для удаления пустых значений. Таким образом, поле ошибки могло бы быть и массивом ошибок. Но здесь я решил выводить лишь одну ошибку, поэтому далее стоит условие проверки массива result, где присваивается ошибка и меняется состояние valid. В конце выполнения функция fieldValidation возвращает новый объект поля, где записаны все переданные ранее значения + новые, модифицированные.


Далее этот метод будет использоваться в функции handleInput и handleSubmit. В обоих случаях будет прогоняться валидация.


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


// hooks/useForm.ts

const [isValid, setFormValid] = useState<boolean>(true);
const getFormValidationState = (fields) => Object.entries(fields).reduce((isValid: any, [_, value]: any) => Boolean(isValid * value.isValid), true);

const handleInput = (element: ChangeEvent<HTMLInputElement>, name: string) => {
    const input = fields[name];
    const value = element.target.value;

    const field = {
      ...input,
      value,
      touched: true,
      isValid: true,
    };

    const validatedField = fieldValidation(field);

    setState((prevState) => {
      const items = {...prevState, [name]: validatedField};

      setFormValid(getFormValidationState(items));
      return items;
    });
  }

  const handleSubmit = (onSubmit: Function) => (e: FormEvent) => {
    if (e && e.preventDefault) {
      e.preventDefault();
    }

    const fieldsArray = Object.entries(fields);
    // Забираем только значения
    const values = fieldsArray.reduce(((prev: any, [name, {value}]: any) => ({ ...prev, [name]: value })), {});
    // Валидируем каждый инпут
    const validatedInputs = fieldsArray.reduce((prev: any, [name, value]: any) => ({ ...prev, [name]: fieldValidation(value, { touched: true }) }), {});
    // Изменяем значение стейта isValid
    setFormValid(getFormValidationState(validatedInputs));
    setState(validatedInputs);

    onSubmit({ values });
  }

Теперь у нас полностью готов кастомный хук для простых форм с возможностью валидировать значения ввод пользователя. После некоторых манипуляций на выходе получим такой код:


useForm.ts
import { ChangeEvent, FormEvent, useState } from 'react';

type IValidatorFN = (s: string) => {};

export interface IField {
  value?: string;
  type?: string;
  label?: string;
  error?: string;
  isValid?: boolean;
  required?: boolean;
  touched?: boolean;
  setState?: (event: ChangeEvent<HTMLInputElement>) => {};
  validators?: IValidatorFN[];
}

export type ICustomField<T = {}> = IField & T;

export type ICustomObject<T = {}> = {
  [key: string]: ICustomField & T;
}

export type IValues = {
  [key: string]: string | number;
}

export type IForm = {
  fields: ICustomObject;
  isValid: boolean;
  handleSubmit: (onSubmit: Function) => (e: FormEvent) => void;
}

type IOptions = {
  [key: string]: any;
}

export const useForm = (initialFields: ICustomObject): IForm => {
  const form = Object.entries(initialFields).reduce((fields, [name, value]: any[]) => {
    const isString = typeof value === 'string';

    const field = {
      [name]: {
        type: 'text',
        value: (isString && value) || ((!isString && value.value) || ''),
        error: (!isString && value.error) || null,
        validators: (!isString && value.validators) || null,
        isValid: (!isString && value.isValid) || true,
        required: (!isString && value.required) || false,
        touched: false,
        setState: (value: ChangeEvent<HTMLInputElement>) => handleInput(value, name),
        ...(!isString && value),
      },
    };

    return {...fields, ...field};
  }, {});

  const [fields, setState] = useState<ICustomObject>(form);
  const [isValid, setFormValid] = useState<boolean>(true);

  const getFormValidationState = (fields: ICustomObject): boolean =>
    Object.entries(fields).reduce((isValid: boolean, [_, value]: any) => Boolean(Number(isValid) * Number(value.isValid)), true);

  const fieldValidation = (field: ICustomField, options: IOptions = {}) => {
    const { value, required, validators } = field;

    let isValid = true, error;

    if (required) {
      isValid = !!value;
      error = isValid ? '' : 'field is required';
    }

    if (validators && Array.isArray(validators)) {
      const results = validators.map(validateFn => {
        if (typeof validateFn === 'string') return validateFn;

        const validationResult = validateFn(value || '');

        return typeof validationResult === 'string' ? validationResult : '';
      }).filter(message => message !== '');

      if (results.length) {
        isValid = false;
        error = results[0];
      }
    }

    return { ...field, isValid, error, ...options };
  };

  const handleInput = (element: ChangeEvent<HTMLInputElement>, name: string) => {
    const input = fields[name];
    const value = element.target.value;

    const field = {
      ...input,
      value,
      touched: true,
      isValid: true,
    };

    const validatedField = fieldValidation(field);

    setState((prevState: ICustomObject) => {
      const items = {...prevState, [name]: validatedField};

      setFormValid(getFormValidationState(items));
      return items;
    });
  }

  const handleSubmit = (onSubmit: Function) => (e: FormEvent) => {
    if (e && e.preventDefault) {
      e.preventDefault();
    }

    const fieldsArray = Object.entries(fields);
    const values = fieldsArray.reduce(((prev: ICustomObject, [name, { value }]: any) => ({ ...prev, [name]: value })), {});
    const validatedInputs = fieldsArray.reduce((prev: ICustomObject, [name, value]: any) => ({ ...prev, [name]: fieldValidation(value, { touched: true }) }), {});

    setFormValid(getFormValidationState(validatedInputs));
    setState(validatedInputs);

    onSubmit({ values });
  }

  return {
    fields,
    isValid,
    handleSubmit,
  }
}

App.tsx
import React from 'react';
import { useForm, IValues } from './hooks/useForm';

const formInputs = {
  firstName: {
    required: true,
    validators: [
      (s: string) => !s.length && 'Поле обязательно для заполнения',
      (s: string) => s.length < 2 && 'Минимальная длина строки 2',
      (s: string) => s.length <= 2 && 'А теперь 3',
      (s: string) => parseInt(s) < 2 && 'Должна быть цифра, больше 1',
    ],
    label: 'First Name',
  },
  datetime: {
    type: 'date',
    label: 'Birth Date',
    validators: [
      (s: string) => new Date(s).getUTCFullYear() > new Date().getUTCFullYear() && 'Год рождения не может быть больше текущего',
    ],
  },
  lastName: {
    label: 'Last Name',
  },
}

const App = () => {
  const { fields, isValid, handleSubmit } = useForm(formInputs);
  const { firstName, datetime, lastName } = fields;

  const onSubmit = ({ values }: { values: IValues }) => {
    console.log(values, 'submit');
  }

  const formFields = [firstName, lastName, datetime];

  return (
    <div className="App">
      <form onSubmit={handleSubmit(onSubmit)}>
        {formFields.map((field, index) => (
          <div key={index}>
            <input
              type={field.type}
              placeholder={field.label}
              value={field.value}
              onChange={field.setState}
            />
            <span>{field.touched && field.error}</span>
          </div>
        ))}
        <div>
          <button disabled={!isValid}>Send form</button>
        </div>
      </form>
    </div>
  );
}

export default App;

Итог


В данной статье мне хотелось привести пример создания собственного хука для работы с формами. Необязательно для работы с ними ограничиваться локальными состояниями или реакт-хуками. Как я упоминал выше, есть возможности и способы оптимизации текущего примера. Основная проблема в этом коде в том, что он вызывает рендер всего компонента при взаимодействии с полями. От этого можно избавиться, если написать реализацию без контроля состояния полей. Однако не всегда неуправляемый вариант подходит для решения той или иной задачи. На помощь может прийти библиотека rxjs или другие, использующие ее возможности, например, такие как focal или rixio. Но это тема для другой статьи.
Спасибо, что дочитали до конца. Я надеюсь, что после ознакомления с этими примерами у вас улучшилось понимание темы кастомных хуков, в частности, работа с формами.
Проект на github