Привет!
У нас в БКС есть админка и множество форм, но в React-сообществе нет общепринятого метода — как их проектировать для переиспользования. В официальном гайде Facebook’a нет подробной информации о том, как работать с формами в реальных условиях, где нужна валидация и переиспользование. Кто-то использует redux-form, formik, final-form или вообще пишет свое решение.
В этой статье мы покажем один из вариантов работы с формами на React. Наш стек будет вот таким: React + formik + Typescript. Мы покажем:
- Что компонент должен делать.
- Конфиг, поля и валидация на уровне пропсов.
- Как сделать форму переиспользуемой.
- Оптимизацию перерендера.
- Чем наш способ неудобен.
При новой бизнес-задаче мы узнали, что нам нужно будет сделать 15-20 похожих форм, и гипотетически их может стать еще больше. У нас была одна форма-динозавр на конфиге, которая работала с данными из `store`, отправляла actions на сохранение и выполнение запросов через `sagas`. Она была замечательной, выполняла бизнес-велью. Но уже была нерасширяемой и непереиспользуемой, только при плохом коде и добавлении костылей.
Задача поставлена: переписать форму для того, чтобы ее можно было переиспользовать неограниченное количество раз. Хорошо, вспоминаем функциональное программирование, в нем есть чистые функции, которые не используют внешние данные, в нашем случае `redux`, только то, что им присылают в аргументах (пропсах).
И вот что получилось.
Идея нашего компонента заключается в том, что ты создаешь обертку (контейнер) и пишешь в ней логику работы с внешним миром (получение данных из стора Redux и отправка экшенов). Для этого компонент-контейнер должен иметь возможность получать какую-то информацию через колбеки. Весь список пропсов формы:
interface IFormProps {
// сообщает форме когда ей показывать лоадер и дизейблить кнопки
IsSubmitting?: boolean;
// текс для кнопки отправки
submitText?: string;
//текст для кнопки отмены
resetText?: string;
// стоит ли валидировать при изменении поля (пропс для формика)
validateOnChange?: boolean;
// стоит ли валидировать при blur’e поля (пропс для формика)
validateOnBlur?: boolean;
// конфиг, на основе которого будут рендериться поля.
config: IFieldsFormMetaModel[];
// значения полей.
fields: FormFields;
// схема для валидации
validationSchema: Yup.MidexSchema;
// колбек при сабмите формы
onSubmit?: () => void;
// колбек при клике на reset кнопку
onReset?: (e: React.MouseEvent<HTMLElement>) => void;
// изменение конкретного поля
onChangeField?: (
e: React.SyntaticEvent<HTMLInputElement, name: string; value: string
) => void;
// присылает все поля на изменение + валидны ли они
onChangeFields?: (values: FormFields, prop: { isValid }) => void;
}
Использование Formik
Мы используем компонент <Formik />.
render() {
const {
fields, validationSchema, validateOnBlur = true, validateOnChange = true,
} = this.props;
return (
<Formik
initialValues={fields}
render={this.renderForm}
onSubmit={this.handleSubmitForm}
validationSchema={validationSchema}
validateOnBlur={validateOnBlur}
validateOnChange={validateOnChange}
validate={this.validateFormLevel}
/>
);
}
В prop'e формика `validate` мы вызываем метод `this.validateFormLevel`, в котором компоненту-контейнеру даем возможность получить все измененные поля и проверить, валидны ли они.
private validateFormLevel = (values: FormFields) => {
const { onChangeFields, validationSchema } = this.props;
if (onChangeFields) {
validationSchema
.validate(values)
.then(() => {
onChangeFields(values, { isValid: true });
})
.catch(() => {
onChangeFields(values, { isValid: false });
});
}
}
Здесь приходится вызывать еще раз валидацию для того, чтобы дать понять контейнеру, валидны ли поля. При сабмите формы мы просто вызываем prop `onSubmit`:
private handleSubmitForm = (): void => {
const { onSubmit } = this.props;
if (onSubmit) {
onSubmit();
}
}
С пропсами 1-5 все должно быть понятно. Перейдем к ‘config’, ‘fields’ и ‘validationSchema’.
Пропс ‘config’
interface IFieldsFormMetaModel {
/** Имя секции */
sectionName?: string;
sectionDescription?: string;
fieldsForm?: Array<{
/** Название поля формы */
name?: string; // по значению этого поля будет будет находить ключ из prop ‘fields’
/** Является ли поле checked */
checked?: boolean;
/** enum, возможные варианты для отображения поля */
type?: ElementTypes;
/** Текст для лейбла */
label?: string;
/** Текст под полем */
helperText?: string;
/** Признак обязательности заполнения элемента формы */
required?: boolean;
/** Признак доступности поля для изменения */
disabled?: boolean;
/** Минимальное кол-во элементов в поле */
minLength?: number;
/** Объект с начальным значением куда входит само значение и его описание */
initialValue?: IInitialValue;
/** Массив значений для выпадающих списков */
selectItems?: ISelectItems[]; // значения для select, dropdown и подобных
}>;
}
На основе этого интерфейса создаем массив объектов и рендерим по такой схеме “раздел” -> “поля раздела”. Так мы можем показывать несколько полей для раздела или в каждом по одному, если нужен заголовок и примечание. Как устроен рендер, покажем немного позже.
Короткий пример конфига:
export const config: IFieldsFormMetaModel[] = [
{
sectionName: 'Общая информация',
fieldsForm: [{
name: 'subject',
label: 'Тема',
type: ElementTypes.Text,
}],
},
{
sectionName: 'Напоминание',
sectionDescription: 'Напоминание для сотрудника',
fieldsForm: [{
name: 'reminder',
disabled: true,
label: 'Сотруднику',
type: ElementTypes.CheckBox,
checked: true,
}],
},
];
На основе бизнес-данных задаются значения для ключей `name`. Эти же значения используются в ключах prop `fields` для передачи первоначальных или измененных значений для формика.
Для примера выше `fields` может выглядеть так:
const fields: SomeBusinessApiFields = {
subject: 'Встреча с клиентом',
reminder: 'yes',
}
Для валидации нам нужно передавать Yup Schema. Форме мы отдаем схему с пропсами контейнера, описывая там взаимодействия с внешними данными, например, запросами.
Форма никак не может повлиять на схему, пример:
export const CreateClientSchema: (
props: CreateClientProps,
) => Yup.MixedSchema =
(props: CreateClientProps) => Yup.object(
{
subject: Yup.string(),
description: Yup.string(),
date: dateSchema,
address: addressSchema(props),
},
);
Рендер и оптимизация полей
Для рендера мы сделали мапу, для быстрого поиска по ключу. Выглядит лаконично и поиск быстрее, чем по `switch`.
fieldsMap: Record<
ElementTypes,
(
state: FormikFieldState,
handlers: FormikHandlersState,
field: IFieldsFormInfo,
) => JSX.Element
> = {
[ElementTypes.Text]: (
state: FormikFieldState,
handlers: FormikHandlersState,
field: IFieldsFormInfo
) => {
const { values, errors, touched } = state;
return (
<FormTextField
key={field.name}
element={field}
handleChange={this.handleChangeField(handlers.setFieldValue, field.name)}
handleBlur={handlers.handleBlur}
value={values[field.name]}
error={touched[field.name] && errors[field.name] || ''}
/>
);
},
[ElementTypes.TextSearch]: (...) => {...},
[ElementTypes.TextArea]: (...) => {...},
[ElementTypes.Date]: (...) => {...},
[ElementTypes.CheckBox]: (...) => {...},
[ElementTypes.RadioButton]: (...) => {...},
[ElementTypes.Select]: (...) => {...},
};
Каждый компонент-поле является stateful. Он находится в отдельном файле и обернут в `React.memo`. Все значения передаются через props, минуя `children`, чтобы избежать лишнего перерендера.
Заключение
Наша форма неидеальна, для каждого кейса нам приходится создавать контейнер обертку для работы с данными. Сохранять их в `store`, конвертировать и делать запросы. Присутствует повторение кода, от которого хочется избавиться. Мы пробуем найти новое решение, при котором форма в зависимости от пропсов будет брать нужный ключ из стора с полями, экшены, схемы и конфиг. В одном из следующих постов мы расскажем, что из этого получилось.
Автор: Фонин Игорь