• Blog
  • Archives
  • Code
  • Talks
  • About

React useReducer hook for form handling

2019-03-22

(2 minute read)

I've started porting over a recent React project to use the new Hooks API. Porting a few state values is relatively simple with the useState hook, but when it comes to larger state objects (e.g. for forms) the useReducer hook comes in handy.

If you've built with Redux before then useReducer is very straightforward to understand, as it uses the concept of a Dispatcher that handles Actions, just like in Redux.

Let's say we have a form that we've so far built using a class-based component (for brevity sake I'm not including field labels, styling, etc):

import React from 'react'

class MyForm extends React.Component {
  state = {
    name: '',
    address: '',
    age: '',
    favouriteSubject: '',
    likesChess: false,
  }

  render () {
    return (
      <form>
        {Object.keys(this.state).forEach(field => (
          <input key={field} type="text" value={this.state[field]} onChange={this.onChange(field)} />
        ))}
      </form>
    )
  }

  onChange = field => e => {
    this.setState({
      [field]: e.currentTarget.value
    })
  }
}

(Yes, I could probably cache the onChange handlers so that they don't get recreated upon each re-render, but hooks will help us solve that below anyway!)

Here is how we would write it using useReducer:

import React, { useReducer, useMemo, useCallback } from 'react'
import Immutable from 'immutable'

// the reducer (similar to what we'd have in redux)
const formReducer = (state, { field, value }) => state.set(field, value)

// the initial state (separated out here in case we wish to easily reset the state)
const initialState = {
  name: '',
  address: '',
  age: '',
  favouriteSubject: '',
  likesChess: false,
}

// the field names (useful below)
const fieldNames = Object.keys(initialState)

const MyForm = () => {
  // get current state and the dispatcher, wrap the state as an immutable map
  const [ state, dispatch ] = useReducer(formReducer, new Immutable.Map(initialState))

  // build the onChange handlers
  const handlers = fieldNames.reduce((m, field) => {
    // the onChange handler for this field is only re-created if the dispatch method changes
    m[field] = useCallback(e => dispatch({ field, value: e.currentTarget.value }), [ field, dispatch ])
    return m
  }, {})

  // convert the immutable back to an object for easy access
  const stateAsObj = useMemo(() => state.toObject(), [state])

  return (
    <form>
      {fieldNames.forEach(field => (
        <input key={field} type="text" value={stateAsObj[field]} onChange={handlers[field]} />
      ))}
    </form>
  )
}

Let's break this down.

We only want to re-render when our state changes, so we'll represent it as an Immutable.Map instance instead of a plain Javascript object - this is similar to how we would do this if we were using Redux. This also allows us to simplify our actual reducer method to simply return the result of updating the state:

const formReducer = (state, { field, value }) => state.set(field, value)

Note: In standard Redux patterns, each dispatched Action usually has a type key. But since our action objects are internal to this component we can just structure them however we want, and so use field as the main key.

The useCallback hook is used to build a memoized onChange callback handler for each of the text input components. The memoization is triggered/reset by the dependences we pass in the as the second parameter to the hook - the field name and the dispatch method:

m[field] = useCallback(e => dispatch({ field, value: e.currentTarget.value }), [ field, dispatch ])

Finally, for ease of access we convert the state to a plain old Javascript object prior to rendering. We use the useMemo hook for this so that we only ever re-do this conversion if the state has changed (another reason why using an Immutable.Map is a good idea!):

const stateAsObj = useMemo(() => state.toObject(), [state])

And that's all there is to it.