ReactJS JavaScript Reactivity

In Taming React 1, components work on parts of a big state object, each taking a value and a function to set it. That works well, but every piece of state now travels as two things, side by side, everywhere it goes:

const [start, setStart] = useStateField(level, setLevel, 'start', ORIGIN)

<PositionInput label="Start" position={start} setPosition={setStart} />
A value and its setter, always together

Sometimes only one of them is wanted: a component that just shows a value doesn't need the setter, and a button that only sets something isn't interested in the value. But very often they're used together. A component that edits a position takes the way to set it too, and useStateField needs both the object and its setter before it can do anything. So let's keep them together.

One variable for both

React already hands us the pair as one thing: useState returns an array of the value and its setter, and we normally pull it apart straight away. Instead we can keep it whole, in a variable whose name ends in Mutable so it's clear what it holds:

const levelMutable = useState(initialLevel)

//  only where the parts are actually needed
const [level, setLevel] = levelMutable
A mutable variable: a [value, setValue] pair kept in one variable

That's all a mutable is at this stage: the same pair, in one variable, with a naming convention. Anything called somethingMutable is a [value, setValue] pair, and it can be taken apart with the usual destructuring wherever the value or the setter is needed on its own.

A stable reference

There's one thing to be careful of. useState gives you the same value and the same setter on every render, but it puts them in a new array each time. While we were pulling the array apart straight away that didn't matter. Now the array itself is what we pass around, and a new array on every render looks like a changed prop to every child that receives it, so children wrapped in memo would refresh every time for nothing.

So if the value hasn't changed, we need to get the same array as before, not a new one. A small wrapper does it:

function useMutableState(initialValue) {
  const [value, setValue] = useState(initialValue)
  return useMemo(() => [value, setValue], [value, setValue])
}
useState, with a stable pair

useStateField and useStateArrayElement from the first article already return their pairs through useMemo, so they give a stable reference too: if the field or element hasn't changed, you get back exactly the same array as last time.

The hooks

The two hooks now take a mutable instead of a value and a setter, so each loses an argument. Inside, the first thing they do is take it apart, and the rest is as before:

function useStateField(objectMutable, fieldName, defaultValue) {
  const [object, setObject] = objectMutable

  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])
}

function useStateArrayElement(arrayMutable, index, defaultValue) {
  const [array, setArray] = arrayMutable
  ...
}
useStateField and useStateArrayElement, taking mutables (trimmed)

What they return is a mutable as well, so the result of one can be handed straight to the next.

The panel, with mutables

Here's the level panel from the first article. The inputs take a mutable and take it apart, as they're the ones that actually show the value and set it:

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

function NumberInput({ label, valueMutable }) {
  const [value, setValue] = valueMutable
  return (
    <label>
      {label}
      <input type="number" value={value}
        onChange={(e) => setValue(Number(e.target.value))} />
    </label>
  )
}
The inputs, taking one mutable each

Everything above them just passes mutables along:

function PositionInput({ label, positionMutable }) {
  const xMutable = useStateField(positionMutable, 'x', 0)
  const yMutable = useStateField(positionMutable, 'y', 0)

  return (
    <div>
      <NumberInput label={label + ' X'} valueMutable={xMutable} />
      <NumberInput label={label + ' Y'} valueMutable={yMutable} />
    </div>
  )
}

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

function LevelSettings({ levelMutable }) {
  const nameMutable = useStateField(levelMutable, 'name', '')
  const timeLimitMutable = useStateField(levelMutable, 'timeLimit', 0)
  const startMutable = useStateField(levelMutable, 'start', ORIGIN)
  const exitMutable = useStateField(levelMutable, 'exit', ORIGIN)

  return (
    <div>
      <TextInput label="Name" valueMutable={nameMutable} />
      <NumberInput label="Time limit" valueMutable={timeLimitMutable} />
      <PositionInput label="Start" positionMutable={startMutable} />
      <PositionInput label="Exit" positionMutable={exitMutable} />
    </div>
  )
}

function LevelEditor() {
  const levelMutable = useMutableState(initialLevel)
  return <LevelSettings levelMutable={levelMutable} />
}
The level panel, passing mutables

Compare LevelSettings with the first article's. Each field was two names and four arguments:

//  before
const [start, setStart] = useStateField(level, setLevel, 'start', ORIGIN)
<PositionInput label="Start" position={start} setPosition={setStart} />

//  after
const startMutable = useStateField(levelMutable, 'start', ORIGIN)
<PositionInput label="Start" positionMutable={startMutable} />
One field, before and after

The list of levels gets the same treatment:

const NO_LEVEL = {}

function LevelItem({ levelsMutable, index }) {
  const levelMutable = useStateArrayElement(levelsMutable, index, NO_LEVEL)
  return <LevelSettings levelMutable={levelMutable} />
}

function Levels({ levelsMutable }) {
  const [levels] = levelsMutable
  return levels.map((level, index) => (
    <LevelItem key={index} levelsMutable={levelsMutable} index={index} />
  ))
}
A panel for every level, with mutables

Nothing works differently. The gotcha from the first article is still handled, because the setters inside the pairs are exactly the same functions as before, and the stable references mean nothing refreshes unless the value it shows has changed.

What it's for

At this stage this is a convenience more than anything else: fewer arguments to pass to components and to the hooks, and one name for each piece of state instead of two.

But having a single value that stands for "this piece of state" means we can now build other things into it. A mutable can carry more than a value and a setter: whether an asynchronous value has arrived yet, or a custom set function that does something other than store what it's given. Those are the next articles in this series.