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