DEV Community

Cover image for React - three props are enough in most cases
simprl
simprl

Posted on

React - three props are enough in most cases

Let's talk about the data flow of a React application consisting of a set of forms.

Presume: the reader is familiar with react, react-hooks, functional components, memorization, knows JavaScript well and is not afraid of spread operators (denoted by three dots)
Unfortunately, examples are without Typescript and Redux comes across.

I lead you to a logical conclusion that only three props are enough for the components that display or part of the form.

To make the way of my thoughts clearer from the very beginning, it is advisable to read my previous article about the composition of components.

Imagine a complex shape that consists of several parts, which in their turn fold other parts.

For example, a form for editing user data:

const UserForm = () =>
  <FormBlock>
    <UserInfo/>
    <Experience/>
    <Education/>
  </FormBlock>
Enter fullscreen mode Exit fullscreen mode
  • We edit the fields firstName, lastName in the UserInfo component.
  • We also edit the positionName, positionDescription fields in the Experience component.
  • And edit the fields name, description in the Education component.

Let's try to implement the UserInfo component.

Sometimes I come across an implementation like this:

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>
Enter fullscreen mode Exit fullscreen mode

And a call like this from UserForm:

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

I recommend to escape this whereas at the input the UserForm has all the props from the UserInfo, Experience and Education components. This is not worth to code.

Typically, instead of writing all the props, someone uses the spread operator:

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

They suppose that each component chooses the right props for itself.

I also advice not to do that either. You are exposing your code to implicit errors. You never know what can get into UserForm, which is not desirable in Education.

For example, we used the className or style props six months ago to style the UserForm, then they removed it in the UserForm, and added such a props to Education.

And then someone forgets to clean up the code and somewhere there are calls to UserForm with className. Now, unexpectedly for everyone, className passes into Education.

Always explicitly pass props so that we can see from the code which props go to which components.

In such cases we can do like this:

Let's take a look at the usual input fields that have migrated to the react from HTML. The developers of the react have kept the same interface familiar to everyone, contrary in Angular, invent their own designs.

Take, for example, the input tag. He has familiar props: value, onChange and name.

In fact, these are all three props sufficient for transmitting a data flow.

UserInfo looks as:

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>
}
Enter fullscreen mode Exit fullscreen mode

Here I use the standard three props in the UserInfo component. And what is important, I repeat the interface for calling the onChange event. It also returns change information like standard input does using target, name, value.

On the one hand, target adds additional level of nesting, but it has historically been the case for the standard onChange event. There is nothing you can do about it. But we get a very important advantage - the same behavior of all input fields and parts of the form.

That is, we can now rewrite the UserForm.

If we store data as such an object:

{ firstName, lastName, positionName, positionDescription, name, description }
Enter fullscreen mode Exit fullscreen mode

Then we write in this way:

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

If we store data as such an object:

{
  userInfo: { firstName, lastName },
  position: { positionName, positionDescription },
  education: { name, description }
}
Enter fullscreen mode Exit fullscreen mode

Then we write in this way:

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

As we can see, the number of props at the UserForm input has decreased from 2 * N to only 3.
This is only part of the benefit.

To make your code more compact and readable

Since we have the same interface everywhere, now we can write auxiliary functions that work with all such components.

For example, imagine a getInnerProps function that maps nested data to nested components. Then the component code becomes much more concise:

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>
}
Enter fullscreen mode Exit fullscreen mode

Note that the same innerProps.forInput () function generates name, value, and onChange props for both the standard Input field and the UserInfo component. Because of the one data flow interface.

Let's complicate the example

Accept the user needs to enter multiple education. One of the solutions (in my opinion wrong):

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>
Enter fullscreen mode Exit fullscreen mode

The onChangeEducation handler changes the education store in the right place by its id. There is a slight contradiction. A collection of educations takes at the input, and one education is returned for the change event.

You can move some of the code from Redux to a component. Then everything becomes more logical. The educations collection takes to the UserForm input, and the educations collection also returns to the change event:

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>
Enter fullscreen mode Exit fullscreen mode

Notice how we pass the handler to onChangeName and onChangeDescription. I deliberately ignored this in order to minimize the examples. But this is important now.

In reality, the Education component most likely is memoized (React.memo ()). Then memoization has not make sense due to the fact that every time we pass a new reference to the function. In order not to create a new link every time, we use the useCallback or useConstant hook (a separate npm module).

If in other examples this solve the problem, then here is a loop, and hooks we cannot use inside conditions and loops.

But using name and expecting the standard onChange behavior from Education, you can already use the useConstant hook:

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>
Enter fullscreen mode Exit fullscreen mode

Now let's do it using the getInnerProps function:

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>
}
Enter fullscreen mode Exit fullscreen mode

It seems like a concise and understandable code turned out.

A few words about the state

Let's connect the stateless UserInfo component to the state and close the data flow. Let's take Redux as an example.

This is how we sometimes implement reducer:

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 } }
   ........
  }
}
Enter fullscreen mode Exit fullscreen mode

Though, changing each field is taken out in a separate action. In this approach, I see two dubious advantages and one big disadvantage.

The first advantage is that you can write a test for this reducer. Doubtful - because this test is unlikely to help much.

The second advantage is that you can separately connect almost every input to a separate field in the store and only this related input field is updated. It is not yet a fact that this gives an increase in productivity. Iterated over 10 memorized parts of the form, as a result of which only one part is redrawn - this has practically no effect on performance.

The disadvantage is that you have to write a lot of code: for each field, change the state, then add an action, pass the value, call a separate action for each event.

Obviously, in the documentation on Redux they say that you need to write reducers, which do not have only set, but which have more actions. Like, the more actions in the reducer, the more tests you can write. More tests mean fewer bugs.

To my mind, there are fewer errors where there is less code, and a lot of actions need to be written only where necessary.

I come to conclusion, that for the forms in the editor, wherever possible, I use only one action - some kind of SET.

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

And directly on the UI (i.e. in the react) I determine which fields in which part of the data change.

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

Therefore, we cannot describe the logics of specific fields in the redux. For example, a phone number input field can be a complex react component, and not just change the value in the state.

Cases of using this approach

Keep in mind. This is not a one-size-fits-all approach. Everything we describe above applies mainly to applications that are going to make use from other forms and the data flow is directed from the store to the container form, from it to the constituent parts of the form, and from them one more level.

If you have an application with a complex interface in which different components interact with each other, the article is useless for you. In this case, it is logical to connect each component to the store.

If you have a mixed application, then it is important to find the border - which parts of the form to connect to redux, and in which to forward data from the container to the child components. Usually, this border begins where the logic of interaction between different parts of the form appears.

Summary

I recommend to use the same props for the data flow, the props that have been in HTML for a long time:

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

Try to adhere to the same structure in onChange as in react's onChange.

Try to return onChange in target.value the same entity as input to value.

Then, by using the standard approach and common helper functions for this approach, the code becomes more concise and understandable.

Top comments (0)