ReactJS JavaScript Reactivity

While building the Rocks Off editor I was looking for a way to simplify the state management. The editor has hundreds of interactions, and a complex data structure describing everything about the game.

The existing state management patterns were cumbersome and inefficient, and they create long-range dependencies across the code. Each change to a UI component needs a new action in a reducer. I wanted to keep the global state for the game description, but make composable components which work on parts of that state without any global coordination.

What we're building

Let's take a small piece of a game editor: the settings for one level. A level has a name, a time limit, and two positions on the map, where the player starts and where the exit is:

const initialLevel = {
  name: 'Level 1',
  timeLimit: 120,
  start: { x: 2, y: 3 },
  exit: { x: 30, y: 18 },
}
The level's settings

The panel needs a text box for the name, a number box for the time limit, and an X and Y box for each of the two positions. The two positions are the same kind of thing, so we'd like one PositionInput component and to use it twice. Here are the basic inputs, which take a value and a function to set it:

function TextInput({ label, value, setValue }) {
  return (
    <label>
      {label}
      <input value={value} onChange={(e) => setValue(e.target.value)} />
    </label>
  )
}

function NumberInput({ label, value, setValue }) {
  return (
    <label>
      {label}
      <input type="number" value={value}
        onChange={(e) => setValue(Number(e.target.value))} />
    </label>
  )
}
The inputs

With a reducer

The well-known way to manage state like this is a reducer, the pattern Redux is built on and which React has built in as useReducer. Every change is an action, and one function works out the new state for each kind of action:

function levelReducer(level, action) {
  switch (action.type) {
    case 'setName':      return { ...level, name: action.name }
    case 'setTimeLimit': return { ...level, timeLimit: action.timeLimit }
    case 'setStartX':    return { ...level, start: { ...level.start, x: action.x } }
    case 'setStartY':    return { ...level, start: { ...level.start, y: action.y } }
    case 'setExitX':     return { ...level, exit: { ...level.exit, x: action.x } }
    case 'setExitY':     return { ...level, exit: { ...level.exit, y: action.y } }
    default:             return level
  }
}
A reducer with an action for every field

And the components dispatch those actions:

function PositionInput({ label, position, setX, setY }) {
  return (
    <div>
      <NumberInput label={label + ' X'} value={position.x} setValue={setX} />
      <NumberInput label={label + ' Y'} value={position.y} setValue={setY} />
    </div>
  )
}

function LevelSettings() {
  const [level, dispatch] = useReducer(levelReducer, initialLevel)

  return (
    <div>
      <TextInput label="Name" value={level.name}
        setValue={(name) => dispatch({ type: 'setName', name })} />
      <NumberInput label="Time limit" value={level.timeLimit}
        setValue={(timeLimit) => dispatch({ type: 'setTimeLimit', timeLimit })} />
      <PositionInput label="Start" position={level.start}
        setX={(x) => dispatch({ type: 'setStartX', x })}
        setY={(y) => dispatch({ type: 'setStartY', y })} />
      <PositionInput label="Exit" position={level.exit}
        setX={(x) => dispatch({ type: 'setExitX', x })}
        setY={(y) => dispatch({ type: 'setExitY', y })} />
    </div>
  )
}
The panel, dispatching actions

That's six actions for four fields, and it's only one small panel. Every place that uses PositionInput needs two more actions in the reducer. If a position gets another field, say a direction to face, every one of those places and the reducer all change. The reducer has to know about every corner of the interface, and every component depends on it. In an editor with hundreds of interactions, that's hundreds of actions.

You can write one generic action that takes a path to the field, but then every component has to know its path from the top of the state, which is the same long-range dependency in another form.

What if...

What if a component could be given an object, and set any field on it with confidence, knowing that the object it came from will be updated?

Then PositionInput would just take a position and a way to set it, and pull out its own fields:

function PositionInput({ label, position, setPosition }) {
  const [x, setX] = useStateField(position, setPosition, 'x')
  const [y, setY] = useStateField(position, setPosition, 'y')

  return (
    <div>
      <NumberInput label={label + ' X'} value={x} setValue={setX} />
      <NumberInput label={label + ' Y'} value={y} setValue={setY} />
    </div>
  )
}

const ORIGIN = { x: 0, y: 0 }

function LevelSettings({ level, setLevel }) {
  const [name, setName] = useStateField(level, setLevel, 'name', '')
  const [timeLimit, setTimeLimit] = useStateField(level, setLevel, 'timeLimit', 0)
  const [start, setStart] = useStateField(level, setLevel, 'start', ORIGIN)
  const [exit, setExit] = useStateField(level, setLevel, 'exit', ORIGIN)

  return (
    <div>
      <TextInput label="Name" value={name} setValue={setName} />
      <NumberInput label="Time limit" value={timeLimit} setValue={setTimeLimit} />
      <PositionInput label="Start" position={start} setPosition={setStart} />
      <PositionInput label="Exit" position={exit} setPosition={setExit} />
    </div>
  )
}

function LevelEditor() {
  const [level, setLevel] = useState(initialLevel)
  return <LevelSettings level={level} setLevel={setLevel} />
}
The same panel with useStateField

There's no reducer and there are no actions. Each component takes a value and a function to set it, the same pair that useState gives you, and hands pairs for the parts it doesn't deal with itself down to its children. PositionInput has no idea whether its position is the level's start, the level's exit, or something else entirely, and it doesn't need to.

The last argument is a default for when the field isn't there. If it's an object, keep it in a constant like ORIGIN, so it's the same object on every render.

useStateField

Here's the implementation:

function useStateField(object, setObject, fieldName, defaultValue) {
  const value = object && object[fieldName] !== undefined
    ? object[fieldName]
    : defaultValue

  const setValue = useCallback((update) => {
    setObject((previousObject) => {
      const previousValue = previousObject[fieldName]
      const newValue = typeof update === 'function' ? update(previousValue) : update
      return newValue === previousValue
        ? previousObject
        : { ...previousObject, [fieldName]: newValue }
    })
  }, [setObject, fieldName])

  return useMemo(() => [value, setValue], [value, setValue])
}
useStateField

Reading is just the field, or the default. Setting works like a useState setter: you can give it a new value, or a function that takes the old value and returns the new one. Either way it calls setObject with a function that makes a new copy of the object with that one field changed. If the value hasn't actually changed it returns the same object, so React can skip the re-render.

Everything it returns is a stable reference. The value is the same one as last time if the field hasn't changed, useCallback keeps the setter the same function from one render to the next, and useMemo hands back the very same pair if neither of them has changed. So children wrapped in memo don't re-render just because their parent did; only the parts showing something that really changed refresh.

Because the setter accepts a function exactly like the one from useState does, it can itself be handed to another useStateField as its setObject. That's how changing the start position's X reaches the level: setX calls setStart with a function, which calls setLevel with a function, which is the setter from useState. Each step wraps the update in one more function, and React applies the whole chain to the current level.

The gotcha

There's one thing the implementation has to get right. It would be easy to write the setter like this instead:

const setValue = (newValue) => setObject({ ...object, [fieldName]: newValue })
A setter that looks fine but isn't

That works while you change one field at a time. Now let's add a button that puts a position back at the origin, setting two fields in one click:

function resetPosition() {
  setX(0)
  setY(0)
}
Setting two fields in one event

Say the position is { x: 5, y: 7 }. setX(0) builds { x: 0, y: 7 } from the object as it was when the component last rendered. Then setY(0) builds { x: 5, y: 0 }, from that same old object, and replaces it. The position ends up as { x: 5, y: 0 }: the change to X has been lost. The same thing happens whenever one event sets more than one field, whether it's two fields of a position or the name and the time limit of a level.

So each time you set a field, it has to be applied to the very latest value of the object, including any changes made just before it in the same event. That's why the real implementation passes a function to setObject instead of a new object. React queues the functions and runs them in order, each one on the result of the last: { x: 5, y: 7 }, then { x: 0, y: 7 }, then { x: 0, y: 0 }. And as every level of the chain does the same, it holds however deeply the fields are nested.

Array elements

The same idea works for one element of an array. A game has a list of levels, and each level's panel only needs its own level:

function useStateArrayElement(array, setArray, index, defaultValue) {
  const value = array[index] !== undefined ? array[index] : defaultValue

  const setValue = useCallback((update) => {
    setArray((previousArray) => {
      if (index < 0) return previousArray

      const previousValue = previousArray[index]
      const newValue = typeof update === 'function' ? update(previousValue) : update
      if (newValue === previousValue) return previousArray

      const newArray = [...previousArray]
      newArray[index] = newValue
      return newArray
    })
  }, [setArray, index])

  return useMemo(() => [value, setValue], [value, setValue])
}
useStateArrayElement

Like useStateField, it gives back the same pair as before if the element hasn't changed.

Hooks can't be called inside a loop, so each element gets a small component of its own to call the hook in:

const NO_LEVEL = {}

function LevelItem({ levels, setLevels, index }) {
  const [level, setLevel] = useStateArrayElement(levels, setLevels, index, NO_LEVEL)
  return <LevelSettings level={level} setLevel={setLevel} />
}

function Levels({ levels, setLevels }) {
  return levels.map((level, index) => (
    <LevelItem key={index} levels={levels} setLevels={setLevels} index={index} />
  ))
}
A panel for every level in the list

LevelSettings is exactly the component from before. It was written for a level held in useState, and it works unchanged on a level inside an array, which might itself be a field of the game.

Use it anywhere

This gives maximum flexibility and compatibility. A component that edits a particular type of data can be used anywhere without making any global changes, and it doesn't matter whether its value comes from some local state in its parent or all the way down from the main game object.

This is the first of a series of techniques which allowed for rapid development of what turned out to be a very complex user interface, without running into rapidly growing complexity in the state management. The next one, Taming React 2: mutable variables, keeps each value and its setter together in one variable.