Давайте поговорим про поток данных React приложения состоящего из набора форм.

Предполагается, что читатель знаком с react, react-хуками, функциональными компонентами, мемоизацией хорошо знает javascript и не пугается spread операторов (три точки которые).
Update 1: Извините, но примеры написаны без использования Typescript и встречается Redux.

Я постараюсь подвести вас к логичному для меня выводу, что зачастую компонентам, которые отображают форму или часть формы, достаточно иметь всего три пропса.


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

Представьте себе сложную форму которая состоит из нескольких частей, а те в свою очередь состоят из других частей.

Например форма редактирования данных о пользователе:

const UserForm = () =>
  <FormBlock>
    <UserInfo/>
    <Experience/>
    <Education/>
  </FormBlock>

В компоненте UserInfo редактируются поля firstName, lastName.

В компоненте Experience редактируются поля positionName, positionDescription.

В компоненте Education редактируются поля name, description.

Попробуем реализовать компонент UserInfo


Иногда я встречаю такую реализацию:

const UserInfo = ({
  firstName,
  onChangeFirstName,
  lastName,
  onChangeLastName,
}) =>
  <FormBlock>
    <Label>First Name</Label>
    <Input
       value={firstName}
      onChange={({ target: { value } }) => onChangeFirstName(value)}
    />
    <Label>Last Name</Label>
    <Input
      value={lastName}
      onChange={({ target: { value } }) => onChangeLastName(value)}
    />
  </FormBlock>

И такой вызов из UserForm:

const UserForm = ({
  firstName,
  onChangeFirstName,
  lastName,
  onChangeLastName,
}) =>
  <FormBlock>
    <UserInfo
      firstName={firstName}
      onChangeFirstName={onChangeFirstName}
      lastName={lastName}
      onChangeLastName={onChangeLastName}
    />
  </FormBlock>

Но не делайте так. Если пойдет в таком духе, то на входе у UserForm будут все пропсы из компонентов UserInfo, Experience и Education. Мне даже лень этот код вам писать.

Обычно всем тоже лень и в итоге просто вместо прописывания всех пропсов используют spread оператор:

const UserForm = (props) =>
  <FormBlock>
    <UserInfo {...props} />
    <Experience {...props} />
    <Education {...props} />
  </FormBlock>

И дальше надеются, что каждый компонент сам выберет себе нужные пропсы.

Пожалуйста, так тоже не делайте. Вы подвергаете свой код неявным ошибкам. Мало ли что может залететь в UserForm что не желательно чтобы было в Education. Ну например пропс className или style которые пол года назад использовались чтобы стилизовать UserForm, потом в UserForm это убрали, а в Education такой пропс добавили. И вот кто-то забыл почистить код и где-то остались вызовы UserForm с className. Теперь неожиданно для всех className прокинется в Education.

Всегда явно прокидуйте пропсы чтобы это видно было по коду какие пропсы в какие компоненты попадают.

Что мы можем с этим всем сделать?


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

Возьмем, например, тег input. У него есть знакомые всем пропсы: value, onChange и, внимание, name .

По сути — это все три пропсы достаточные для передачи потока данных.

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

const UserInfo = ({
  name,
  value,
  onChange,
}) => {
  const onChangeHandler = ({ target }) => onChange({target: { name, value: { ...value, [target.name]: target.value } }})
  return <FormBlock>
    <Label>First Name</Label>
    <Input
       name={'firstName'}
       value={value['firstName']}
       onChange={onChangeHandler }
    />
    <Label>Last Name</Label>
    <Input
       name={'lastName'}
       value={value['lastName']}
       onChange={onChangeHandler }
    />
  </FormBlock>
}

Тут я в компоненте UserInfo использую стандартные три пропса. И что немаловажно повторил интерфейс вызова события onChange. Он также возвращает информацию о изменениях как это делает стандартный input используя target, name, value. С одной стороны target добавляет еще уровень вложенности, но так уж исторически сложилось у стандартного события onChange, с этим уже ничего не поделаешь. Зато мы получаем очень важное преимущество — одинаковое поведение всех полей ввода и частей формы.

То есть мы можем теперь переписать UserForm.

Если у нас данные хранятся как такой объект:

{ firstName, lastName, positionName, positionDescription, name, description }

То пишем так:

const UserForm = ({
  name,
  value,
  onChange,
}) =>
  <FormBlock>
    <UserInfo
       value={value}
       onChange={({ target }) => onChange({target: { name, value: target.value }})}
    />
   .......
  </FormBlock>

Если у нас данные хранятся как такой объект:

{
  userInfo: { firstName, lastName },
  position: { positionName, positionDescription },
  education: { name, description }
}

То пишем так:

const UserForm = ({
  name,
  value,
  onChange,
}) =>
  <FormBlock>
    <UserInfo
       name={'userInfo'}
       value={value['userInfo']}
       onChange={({ target }) => onChange({target: { name, value: { ...value, [target.name]: target.value } }})}
    />
   .......
  </FormBlock>

Как видим, количество пропсов на входе UserForm уменьшилось с 2*N до всего трех.
Но это только часть выгоды.

Как сделать код компактней и читабельней?


Так как у нас теперь везде одинаковый интерфейс, то теперь можно написать вспомогательные функции, которые будут работать со всеми такими компонентами.

Например, представим функцию getInnerProps, которая мапит вложенные данные на вложенные компоненты. Тогда код компонентов становится намного лаконичней:


const UserInfo = ({ name, value, onChange }) => {
   const innerProps = getInnerProps({name, value, onChange})
   return <FormBlock>
    <Label>First Name</Label>
    <Input {...innerProps.forInput('firstName')} />
    <Label>Last Name</Label>
    <Input {...innerProps.forInput('lastName')} />
  </FormBlock>
}
const UserForm = ({
  name,
  value,
  onChange,
}) => {
  const innerProps = getInnerProps({name, value, onChange})
  return <FormBlock>
    <UserInfo {...innerProps.forInput('userInfo')} />
    <Experience {...innerProps.forInput('position')} />
    <Education {...innerProps.forInput('education')} />
  </FormBlock>
}

Обратите внимание, что одна и та же функция innerProps.forInput() формирует пропсы name, value и onChange и для стандартного поля ввода Input и для компонента UserInfo. Все благодаря одному интерфейсу потока данных.

Усложним пример


Допустим, пользователю нужно ввести несколько образований (education). Один из вариантов решения (на мой взгляд неправильного):

const UserForm = ({
  educations,
  onChangeEducation,
}) =>
  <FormBlock>
    {Object.entries(educations).map(([id, education]) => <Education
      name={name}
      description={description}
      onChangeName={(name) => onChangeEducation(id, { ...education, name })}
      onChangeDescription={(description) => onChangeEducation(id, { ...education, description })}
    />}
  </FormBlock>

Обработчик onChangeEducation будет менять в нужном месте стора education по его id. Тут есть небольшое противоречие. На вход прилетает коллекция educations, а на событие изменения возвращается один education.

Можно часть кода перенести из Redux в компонент. Тогда станет все логичней. На вход UserForm приходит коллекция educations и на событие изменения уходит тоже коллекция educations:

const UserForm = ({
  educations,
  onChangeEducations,
}) =>
  <FormBlock>
    {Object.entries(educations).map(([id, education]) => <Education
      name={name}
      description={description}
      onChangeName={(name) => onChangeEducations({ ...educations, [id]: { ...education, name } })}
      onChangeDescription={(description) => onChangeEducations({ ...educations, [id]: { ...education, description } })}
    />}
  </FormBlock>

Немного остановимся на том как мы передаем обработчик в onChangeName и onChangeDescription. Я сознательно не обращал на это внимание для минимизации примеров. Но сейчас это важно.

В реальности компонент Education будет скорее всего мемоизированный (React.memo()). Тогда мемоизация не будет иметь смысла из-за того, что каждый раз мы передаем новую ссылку на функцию. Чтобы не создавать каждый раз новую ссылку используют хук useCallback или useConstant (отдельный npm модуль).

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

А вот используя name и ожидая от Education стандартного поведения onChange уже можно применить хук useConstant:

const UserForm = ({
  name,
  value,
  onChange,
}) => {
  const onChangeEducation=useConstant(({ target }) => onChange({
    target: {
      name,
      value: {
        ...value,
        educations: { ...value.educations, [target.name]: target.value ] }
      }
    }
  }))
  return <FormBlock>
  {Object.entries(educations).map(([id, education]) => <Education
      name={id}
      value={education}
       onChange={onChangeEducation}
    />
  )}
  </FormBlock>

А теперь сделаем с помощью функции getInnerProps:


const Education = ({ name, value, onChange }) => {
   const innerProps = getInnerProps({name, value, onChange})
   return <FormBlock>
    <Label>Name</Label>
    <Input {...innerProps.forInput('name')} />
    <Label>Description</Label>
    <Input {...innerProps.forInput('description')} />
  </FormBlock>
}
const Educations = ({ name, value, onChange }) => {
   const innerProps = getInnerProps({name, value, onChange})
   return Object.keys(value).map((id) =>
     <Education {...innerProps.forInput(id)} />
  )
}

const UserForm = ({
  name,
  value,
  onChange,
}) => {
  const innerProps = getInnerProps({name, value, onChange})
  return <FormBlock>
    <UserInfo {...innerProps.forInput('userInfo')} />
    <Experience {...innerProps.forInput('position')} />
    <Educations {...innerProps.forInput('educations')} />
  </FormBlock>
}

Вроде как достаточно красивый и понятный код получился.

Несколько слов про стейт


Подключим stateless компонент UserInfo к стейту и замкнем поток данных. В качестве примера возьмем Redux.

Вот так иногда реализовывают редюсер:


const reducer = (state = initState, action) {
  switch(action.type) {
    case CHANGE_FIRST_NAME:
       return { ...state, userInfo: { ...state.userInfo, firstName: action.payload } }
    case CHANGE_LAST_NAME:
       return { ...state, userInfo: { ...state.userInfo, lastName: action.payload } }
   ........
  }
}

То есть, изменение каждого поля выносят в отдельный action. В этом подходе я вижу два сомнительных плюса и один большой минус.

Первый плюс — это то, что можно написать тест для этого редсера. Сомнителен — потому что вряд ли этот тест сильно поможет.

Второй плюс — то что можно отдельно подконектить чуть ли не каждый инпут к отдельному полю в сторе и будет обновлятся только это связанное поле ввода. Тут еще не факт что это даст увеличение производительности. Проитерироваться по 10 мемоизированным частям формы, в результате чего будет перерисована только одна часть — это практически не скажется на производительности.

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

Да, в документации по редаксу говорят, что надо писать редюсеры не такие у которых только set, а такие в которых побольше экшенов. Типа чем больше экшенов в реюсере, тем больше тестов можно написать. Больше тестов — меньше ошибок.

Но я думаю меньше ошибок там, где меньше кода, а много экшенов нужно писать только там где это надо.

Для себя я решил что для форм в редаксе, везде где это возможно, использую только один action — какой-нибудь SET.


const reducer = (state = initState, action) {
  switch(action.type) {
    case SET_USER_FORM_DATA:
       return { ...state, value: action.payload }
     ........
  }
}

А уже на UI (т.е. в реакте) я определяю какие поля в какой части данных меняются.


const UserFormContainer = () => {
  const dispatch = useDispatch()
  return <UserForm
    value={useSelector(({ userForm }) => userForm?.value)}
    onChange={({target: { value } }) => dispatch(userFormActions.set(value)}
  />
}

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

Когда применять описанный подход


Имейте ввиду. Это не подход на все случаи жизни. Все описанное применимо в основном к приложениям в которых много форм, где одна форма собирается из набора других форм и поток данных направлен из стора в форму контейнер, из нее — в составные части формы, из них еще — на уровень ниже.

Если у вас приложение со сложным интерфесом в котором разные компоненты взаимодействуют друг с другом описанное в статье мало что вам даст. Как раз в этом случае логично каждый компонент подключать к стору.

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

Резюме


Давайте использовать одинаковые пропсы для потока данных, пропсы которые уже давно есть в HTML:

  • name
  • value,
  • onChange({target: { name, value }})

Старайтесь в onChange придерживаться той же структуры, как и в реактовском onChange.

Старайтесь на onChange в target.value возвращать ту же сущность что на вход в value.

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