В моей работе(и не только моей) очень часто возникает необходимость писать логику для формы. Каждый раз это больно. Кажется, даже создатели React солидарны с этим, поэтому скоро у нас появится useFormStatus, но, на моей взгляд, этот хук лишь немного облегчит жизнь в простых кейсах, но никак не поможет в более сложных.

Под сложными кейсами я имею ввиду, например:

  • Значение поля не примитив, а объект или массив (или Map/Set)

  • Нужна возможность задать стейт вне инпута/очистить какое-то поле или ресетнуть всю форму, т.е. более продвинутый API, а не просто возможность вытащить данные из инпута

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

Поэтому чаще всего приходится использовать библиотеку для работой с формой или писать свой велосипед (это как раз мой случай).

Я пробовал много библиотек, больше всего мне понравились react-hook-form и rc-field-form (используется в antd). react-hook-form показалась мне слишком сложной в использовании, постоянно приходилось импортировать кучу всего и лезть в доки в любой непонятной ситуации, но зато она очень гибкая. А rc-field-form слишком много весит и плохо типизирована, но показалась мне очень понятной и удобной в использовании. Я решил попробовать объединить гибкость и удобство.

В моей библиотеке мне хотелось видеть следующее:

  1. Должно быть достаточно одного импорта, вся логика должна лежать в одном compound component.

  2. Типизировано все, что может быть типизировано. Форма должна помогать и подсказывать названия полей, типы в коллбеках и т.д.

  3. Очень легко достать и подписаться на значение любого поля формы при помощи useWatch хука

  4. Можно управлять стейтом любого поля при помощи useField хука

  5. Валидация должна быть такой же удобной, как в rc-field-form

  6. Никакого CSS, минимум HTML, FormItem должен быть просто HOC над пользовательским компонентом

  7. Удобный API для работы с полем, являющимся массивом (свой useFieldArray из react-hook-form)

API, который я хотел видеть:

// форму можно создать в отдельном файле и использовать где угодно
// вся логика лежит в созданном инстансе
export const MyForm = createForm({
  name: '',
  age: 0,
})

const MyComponent = () => {
  const name = MyForm.useWatch('name'); // у name тип string
  const [age, setAge] = MyForm.useField('age'); // у age тип number, setAge имеет такую же апишку, как если бы использовался useState
  const nameValidationErrors = MyForm.useFieldError('name'); // подписка на ошибки валидации, все типизировано

  const {
    setFieldValue,
    setFieldsValue,
    getState,
    resetFields,
    submit,
    validateField,
    validateFields
  } = MyForm.formApi; // внутри formApi все необходимые методы для работы с формой

  return (
    <MyForm onFinish={(state) => {
      // state типизирован
      alert(JSON.stringify(state, undefined, 2));
    }}>
      <MyForm.Item
        name="name" // Для name работает autocomplete
        label="Name"
        onChange={value => {
          console.log(value) // value имет тип string
        }}
        rules={[
          {
            required: true,
            message: 'Name is required',
            validateTrigger: ['onFinish']
          },
          {
           validator: async (name) => {
             await validateName(name) // есть promise based поддержка кастомной валидации
           },
           message: 'Name is invalid'
          }
        ]}
      >
        {({ value, onChange }) => // value и onChange типизированы, тут value - string
          <input value={value} onChange={e => onChange(e.target.value)} />
        }
      </MyForm.Item>
      <MyForm.Item name="age">
        {({ value, onChange }) => // а тут value - number
          <input type="number" value={value} onChange={(e) => onChange(+e.target.value)} />
        }
      </MyForm.Item>
      <button type="submit">
        Submit button
      </button>
    </MyForm>
  )
}

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

Управление состоянием формы

Если не обращаться за помощью к стейт менеджерам вроде redux, то не так уж много способов управлять состоянием централизовано (при необходимости обеспечить любую вложенность):

1) Использовать контекст и хранить стейт формы и методы управления стейтом внутри контекста(в виде Map/объект). Главный минус - ререндер всех элементов формы при изменении стейта. Так же невозможно использовать API формы вне контекста, что накладывает некоторые ограничения. А заставлять оборачивать все приложение в контекст я не хотел.

2) Довольно любопытный способ, который я попробовал - хранить стейт каждого поля внутри Form.Item и использовать useImperativeHandle для доступа к управлению стейтом извне. Мне не понравилось, что в этом случае пришлось бы всю логику для валидации так же хранить внутри Form.Item, а еще жонглировать ref-ами.

3) Вынести всю логику работы с формой в отдельный класс и хранить в useRef инстанс этого класса, а для обновления стейта использовать логику с подпиской + useSyncExternalStore для рендера актуального значения поля. Именно на этом варианте я остановился в итоге, он показался мне самым гибким.

Логика для управления и подписки на стейт поля формы выглядит примерно так:

export class FormApi<State extends Record<string, unknown>, Field extends GetFields<State> = GetFields<State>> {
  private state: State
  private subscribers: Map<Field, FieldOnChangeCb<State[Field]>[]>;

  constructor(state: State) {
    this.state = state;
    this.subscribers = new Map();
  }

  getState() {
    return this.state
  }

  onFieldChange<F extends Field>(field: F, cb: FieldOnChangeCb<State[F]>) {
    const currentSubscribers = this.subscribers.get(field) || [];
    this.subscribers.set(field, currentSubscribers.concat(cb as FieldOnChangeCb<State[Field]>));

    return () => { // unsubscribe
      this.subscribers.set(field, this.subscribers.get(field)?.filter(i => i !== cb) || []);
    }
  }

  private triggerFieldUpdate<F extends Field, V extends State[F]>(field: F, value: V) {
    this.subscribers.get(field)?.forEach(cb => cb(value));
  }

  setFieldsValue(update: Partial<State>) {
    this.state = {
      ...this.state,
      ...update,
    }
    for (const field in update) {
      this.triggerFieldUpdate(field as Field, update[field] as State[Field])
    }
  }

  setFieldValue<F extends Field>(field: F, value: FieldUpdate<State[F]>) {
    if (typeof value === 'function') {
      this.state[field] = (value as FieldsUpdateCb<State[typeof field]>)(this.state[field]);
    } else {
      this.state[field as Field] = value
    }
    this.triggerFieldUpdate(field, this.state[field])
  }

  getFieldValue<F extends Field>(field: F) {
    return this.getState()[field];
  }
}

export const useWatch = <
  Form extends FormApi<any>,
  Types extends FormApiGenericTypes<Form>,
  State extends Types['state'],
  Field extends Types['field']
>(form: Form, field: Field) => {
  const value = useSyncExternalStore<State[Field]>(
    cb => form.onFieldChange(field, cb),
    () => form.getFieldValue(field),
    () => form.getFieldValue(field),
  )

  return value;
}

FormItem

Одной из главных проблем для меня стал API для FormItem. Мне хотелось такую же простоту, как в rc-field-form, т.е. нечто такое:

<FormItem name="username">
  <input placeholder="Username" />
</FormItem>

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

В идеале я хотел, чтобы пользователь прокидывал в FormItem функции normalize и getValueFromEvent , чтобы приводить value к ожидаемому типу. Для этого я хотел проверить наличие у children полей value и onChange, достать из них типы и использовать их в normalize и getValueFromEvent .

К моему сожалению оказалось, что JSX убивает типы и не позволяет достать пропсы из children. Самое забавное в этой ситуации - React.createElement это позволяет:


type ReturnElementProps<El> = El extends React.ReactElement<infer P> ? P : never

type FormItemProps<
  Val,
  El extends React.ReactElement,
  Props extends ReturnElementProps<El>,
  ElVal = Props['value']
> = {
  value: Val,
  getValueFromEvent: (...args: Parameters<Props['onChange']>) => Val,
  normalize: (value: Val) => ElVal,
  children: El
}

const FormItem = <Val, El extends React.ReactElement, Props extends ReturnElementProps<El>>(props: FormItemProps<Val, El, Props>) => {
  return <>{props.children}</>
}

// Такой вариант работает
const Test = <FormItem
  value={0}
  getValueFromEvent={(event) => Number(event.target.value)}
  normalize={(value) => String(value)}
>
  {React.createElement('input')}
</FormItem>

// А такой - уже нет :(
const Test2 = <FormItem
  value={0} 
  getValueFromEvent={(event) => Number(event.target.value)} // event тут - unknown
  normalize={(value) => String(value)}
>
 <input />
</FormItem>

Хотя казалось бы - JSX должен быть просто синтаксическим сахаром над createElement и они должны быть полностью взаимозаменяемыми. Возможно это некий баг, который когда-нибудь исправят. Но пока что я не нашел ничего лучше, чем использовать render function в качестве children:

<FormItem name="myField">
  {({ value, onChange }) => <input value={String(value)} onChange={e => onChange(Number(e.target.value))} />}
</FormItem>

Типизация

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

Например, я хотел, чтобы метод onFieldChange или хук useWatch автоматически подставляли названия всех полей формы.

Например, если мы имеем тип

type Test = {
  age: number
  name: string
}

и хотим получить 'age' | 'name' - недостаточно использовать keyof, дополнительно нужен Extract, чтобы точно быть уверенным, что мы достаем string.

type GetFields<T extends Record<string, unknown>> = Extract<keyof T, string>

type TestFields = GetFields<Test> // 'age' | 'name'

Даже с учетом того, что State у меня завязан на тип Record<string, unknown>, keyof State все равно ругался на то, что ключом может быть, например, symbol. С Extract же таких ошибок удалось избежать.

// Упрощенный пример для методов setFieldValue и getFieldValue
export class FormApi<State extends Record<string, unknown>, Field extends GetFields<State> = GetFields<State>> {
  private state: State

  constructor(state: State) {
    this.state = state;
  }

  setFieldValue<F extends Field>(field: F, value: State[F]) {
    this.state[field] = value;
  }

  getFieldValue<F extends Field>(field: F) {
    return this.state[field];
  }
}

// Проверка
const myForm = new FormApi({ a: '', b: 0 });

const a = myForm.getFieldValue('a') // string
const b = myForm.getFieldValue('b') // number
const c = myForm.getFieldValue('c') // Argument of type '"c"' is not assignable to parameter of type '"a" | "b"'

Интереснее дела обстояли с тем, чтобы извлечь названия полей из инстанса формы. На помощь пришел infer, благодаря чему получилось извлечь тип стейта и тип полей из FormApi.

export type FormApiGenericTypes<T> = T extends FormApi<infer S, infer F>
  ? { formApi: T, state: S, field: F } :  never

export const useWatch = <
  Form extends FormApi<any>,
  Types extends FormApiGenericTypes<Form>,
  State extends Types['state'],
  Field extends Types['field']
>(form: Form, field: Field) => {

}

// Пример использования: 
const form = new FormApi({ age: 0, name: '' });
const age = useWatch(form, 'age');

Жаль, что в ts для типов нельзя использовать деструктуризацию... В данном примере было бы кстати. Буду рад, если окажется, что я себя перемудрил и это можно сделать более изящным способом. Напишите пожалуйста в комментариях, если это так.

Когда я начал делать отдельную API (и hook) для работы с массивами, я хотел, чтобы автокомплит предлагал только поля, являющиеся массивами.

Оказалось, что ts позволяет отфильтровать поля по определенному условию, т.е. вполне можно написать pickBy из lodash, но только для типов.

export type GetFields<T extends Record<string, unknown>> = Extract<keyof T, string>

export type PickBy<Obj extends Record<string, unknown>, Predicate> = {
  [Property in keyof Obj as Obj[Property] extends Predicate ? Property : never]: Obj[Property]
}

export type ArrayOnlyFields<
Obj extends Record<string, unknown>,
> = GetFields<PickBy<Obj, unknown[]>>

type Test = {
  num: number
  str: string
  strArr: string[]
  numArr: number[]
}

type TestFields = ArrayOnlyFields<Test> // 'strArr' | 'numArr'

Объединив Extract и PickBy у меня получилось добиться автокомплита в useArrayField и Form.ArrayItem только для полей, которые являются массивами. Думаю, это можно использовать не только для массивов, но и в куче других кейсов.

Compound component

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

Без генериков все просто -

const CompoundForm = Form as typeof Form & {
  Item: typeof FormItem,
  ArrayItem: typeof FormArrayItem,
  formApi: typeof FormApi,
  // и так далее
}

В моем случае потребовалось создать HOC/HOF для всех компонентов/методов, прокинув туда form и продублировав типизацию:

export const createForm = <State extends Record<string, unknown>>(initialState: State) => {
  const form = new FormApi(initialState);

  type Types = FormApiGenericTypes<typeof form>;

  type ArrayFields = ArrayOnlyFields<Types['state']>;

  const FormComponent = (props: FormProps<Types['state'], FormApi<Types['state']>>) =>
    <Form form={form} {...props} />

  const FormItemComponent = <T extends Types['field']>(props: FormItemProps<T, Types['state'][T]>) => <FormItem {...props} />

  const ArrayItemComponent = <T extends ArrayFields>(props: FormArrayItemProps<T, ArrayOnly<Types['state'][T]>>) => <FormArrayItem {...props} />

  const useWatchHook = <T extends Types['field']>(field: T) => useWatch(form, field)

  const useFieldHook = <T extends Types['field']>(field: T) => useField(form, field)

  const useFieldErrorHook = <T extends Types['field']>(field: T) => useFieldError(form, field)

  const useArrayFieldHook = <T extends ArrayFields>(field: T, rules?: ValidationRule<Types['state'][T]>[]) => useArrayField(form, field, rules)

  const CompoundForm = FormComponent as typeof FormComponent & {
    Item: typeof FormItemComponent
    useWatch: typeof useWatchHook,
    useField: typeof useFieldHook,
    ArrayItem: typeof ArrayItemComponent,
    formApi: typeof form,
    useFieldError: typeof useFieldErrorHook,
    useArrayField: typeof useArrayFieldHook,
  }

  CompoundForm.formApi = form;
  CompoundForm.Item = FormItemComponent;
  CompoundForm.ArrayItem = ArrayItemComponent;
  CompoundForm.useWatch = useWatchHook;
  CompoundForm.useField = useFieldHook;
  CompoundForm.useArrayField = useArrayFieldHook;
  CompoundForm.useFieldError = useFieldErrorHook;

  return CompoundForm
}

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

Валидация

Так как мне удалось типизировать буквально все, мне показалось излишним добавлять поддержку schema validator-ов вроде zod или yup. В то же время мне хотелось максимально облегчить пользователям жизнь и добавить встроенные проверки, например required, min/max. От нативной валидации я решил отказаться. Это спорный шаг, но мне нужна была гибкость и возможность кастомизировать ошибки, чего нативная валидация, к сожалению, не дает.

Я хотел, чтобы это работало следующим образом:

const rules = [
  {
    // if field value is undefined
    required: true,
    message: "Field is required",
  },
  {
    // if value < 18
    min: 18,
    type: "number",
    message: "some message",
  },
  {
    // if String(value).length > 100
    max: 100,
    type: "string",
    message: "some error",
  },
  {
    // if myPattern.test(value) === false
    type: "regexp",
    pattern: myPattern,
  },
  {
    // If value is not an email address
    type: "email",
  },
  {
    // if myValidator return Promise.reject
    validator: myValidator,
    message: "some error",
  },
];

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

export const checkMin = async <T>(value: T, rule: ValidationRule<T>) => {
  if (!('min' in rule)) {
    return
  }
  if (typeof rule.min !== 'number') {
    return;
  }
  if (Array.isArray(value) && value.length < rule.min) {
    return Promise.reject(rule.message);
  }
  if (rule.type === 'number' && Number(value) < rule.min) {
    return Promise.reject(rule.message);
  }
  if (rule.type === 'string' && String(value).length < rule.min) {
    return Promise.reject(rule.message);
  }
};

export const checkMax = async <T>(value: T, rule: ValidationRule<T>) => {
  if (!('max' in rule)) {
    return
  }
  if (typeof rule.max !== 'number') {
    return;
  }
  if (Array.isArray(value) && value.length > rule.max) {
    return Promise.reject(rule.message);
  }
  if (rule.type === 'number' && Number(value) > rule.max) {
    return Promise.reject(rule.message);
  }
  if (rule.type === 'string' && String(value).length > rule.max) {
    return Promise.reject(rule.message);
  }
};

export const checkRequired = async <T>(value: T, rule: ValidationRule<T>) => {
  if (typeof value === 'undefined') {
    return Promise.reject(rule.message);
  }
  if (typeof value === 'number' && value === 0) {
    return;
  }
  if (Array.isArray(value) && value.length === 0) {
    return Promise.reject(rule.message);
  }
  if (!value && typeof value !== 'boolean') {
    return Promise.reject(rule.message);
  }
};

export const checkPattern = async <T>(value: T, rule: ValidationRule<T>) => {
  if ('pattern' in rule && typeof value === 'string' && !rule.pattern.test(value)) {
    return Promise.reject(rule.message || 'Invalid format');
  }
  return
};


export const getValidationErrors = async <Value,>(
  value: Value,
  rules: (ValidationRule<Value> & { validateTrigger: ValidateTrigger[] })[],
  trigger?: ValidateTrigger
) => {
  const result = [] as {
    rule: typeof rules[number],
    validator: Validator<Value>
  }[];

  for (const rule of rules) {
    if (trigger && !rule.validateTrigger.includes(trigger)) {
      continue
    }
    if (rule.required) {
      result.push({
        rule,
        validator: checkRequired,
      });
    }
    if ('min' in rule) {
      result.push({
        rule,
        validator: checkMin,
      });
    }
    if ('max' in rule) {
      result.push({
        rule,
        validator: checkMax,
      });
    }
    if (rule.type === 'regexp' && 'pattern' in rule) {
      result.push({
        rule,
        validator: checkPattern,
      });
    }
    if (rule.type === 'email') {
      result.push({
        rule: {
          ...rule,
          type: 'regexp',
          pattern: emailRegex,
        },
        validator: checkPattern,
      })
    }
    if ('validator' in rule) {
      result.push({
        rule,
        validator: rule.validator,
      });
    }
  }

  const settledPromises = await Promise.allSettled(
    result.map(({ validator, rule }) =>
      validator(value, rule)
        .catch(error => {
          const errorText = rule.message || String(error);
          return Promise.reject<ValidationError>({
            errorText,
            value,
            rule,
          })
        })
    ));

  return filterOnlyRejectedPromises<ValidationError<Value>>(settledPromises).map(i => i.reason);
}

Что в итоге

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

Надеюсь, что эта статья окажется кому-то полезной, как и сама библиотека. Я буду рад любой обратной связи(особенно критике и предложениям), хотелось бы довести эту либу до финальной кондиции. Исходный код выложен на github, доки лежат тут, установить можно при помощи npm i react-any-shape-form . Весит всего ~9.1kb/3kb gzip, если верить bundlephobia.

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


  1. Raspy
    11.05.2024 09:47

    Не понятны два сценария:

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

    2. Как добавить в элемент массива формы разнородные элементы? Ну условно у вас есть массив контакт методов и вы хотите в него добавить номер телефона, имейл и соц сеть. Набор полей и валидаций может быть разнородным, но всё это должно лежать в условном contactMethods: []


    1. deadrime Автор
      11.05.2024 09:47

      1 - В рантейме можно показывать что угодно, главное чтобы к моменту финального submit все обязательные поля были заполнены. Можно выборочно провалидировать(validateFields(['name', 'age'])) какие-то поля, если у них validateTrigger - onSubmit, я так пробовал анбординг делать, вроде +- удобно, обычно на каждом экране 2-3 поля, не больше.

      2 - О таком, надо признаться, не подумал. Попробую придумать, как туда валидацию добавить. Но вообще я бы наверное просто сделал email, phoneNumber и т.д. отдельными полями со своими правилами, а если нужно хранить их в виде массива - то положил бы их туда уже перед отправкой на сервер.


    1. deadrime Автор
      11.05.2024 09:47

      upd: поправил метод validateFields , чтобы по-умолчанию он валидировал только те поля, которые не были размонтированы