- Published on
Case Study: The Dangers of Mutating State in React
"Don't directly mutate state" is a common and fundamental recommendation in the React community. But for many beginning developers, it's not always obvious why they should avoid it or even when they're doing it. And sometimes, the bug it causes may be so sneaky that it stays hidden until long after you've deployed the code. In this post, I'll show you an example of a mutation-related bug we found in a real, live production app and explain how we fixed it.
Background
Like many apps, this one has a lot of forms. To make it easier for developers
to add and update these forms, they created a set of reusable low-level
components designed to interact with a form management library like formik
.
One feature that frequently appeared in these forms was a dynamic array of
sub-forms. Imagine a list of contacts, where each contact has a name, email
address, and phone number. You can update a specific field for an existing
contact, delete a contact, or add a new contact.
Because this pattern was so prevalent, they created a dedicated ArrayField
component to handle the common tasks of adding, removing, and updating items. To
make it reusable, you define the component it uses to render the sub-form. Take
a look at the demo and a simplified version of the implementation below.
import * as React from 'react'
export type ArraySubFieldProps<T> = {
label: string
value: T
index: number
onUpdate: (index: number, value: T) => void
onRemove: (index: number) => void
onReset: (index: number) => void
}
export type ArrayFieldProps<T> = {
label: string
itemLabel?: string
component: React.ElementType<ArraySubFieldProps<T>>
defaultValue: T
value: T[]
onChange: React.Dispatch<React.SetStateAction<T[]>>
}
export function ArrayField<T>({
label,
itemLabel = 'Item',
component: Component,
defaultValue,
value,
onChange,
}: ArrayFieldProps<T>) {
// These callbacks are only used within this component,
// so no need to memoize them.
const appendItem = () => {
onChange([...value, defaultValue])
}
// These callbacks get passed to children, so we should
// memoize them in case that affects the child component.
const removeIndex = React.useCallback(
(index: number) => {
onChange((value) => {
const nextValue = [...value]
nextValue.splice(index, 1)
return nextValue
})
},
[onChange]
)
const resetIndex = React.useCallback(
(index: number) => {
onChange((value) => {
const nextValue = [...value]
nextValue[index] = defaultValue
return nextValue
})
},
[onChange, defaultValue]
)
const updateItem = React.useCallback(
(index: number, nextItem: T) => {
onChange((value) => {
const nextValue = [...value]
nextValue[index] = nextItem
return nextValue
})
},
[onChange]
)
return (
<div>
<h2>{label}</h2>
<ul>
{value.map((item, i) => (
<li key={i}>
<Component
label={`${itemLabel} #${i + 1}`}
value={item}
index={i}
onUpdate={updateItem}
onRemove={removeIndex}
onReset={resetIndex}
/>
</li>
))}
</ul>
<button onClick={appendItem}>Add {itemLabel}</button>
</div>
)
}
Users
Links
Now, whenever you have a new type of sub-form to render, you simply create a component for that form and handle the props it receives.
import * as React from 'react'
import type { User } from './UserForm'
import { UserForm } from './UserForm'
import type { Link } from './LinkForm'
import { LinkForm } from './LinkForm'
const defaultUser: User = {
firstName: '',
lastName: '',
email: '',
}
const defaultLink: Link = {
url: '',
description: '',
}
export function DemoForm() {
const [users, setUsers] = React.useState<User[]>([])
const [links, setLinks] = React.useState<Link[]>([])
return (
<div>
<ArrayField
label="Users"
itemLabel="User"
component={UserForm}
defaultValue={defaultUser}
value={users}
onChange={setUsers}
/>
<ArrayField
label="Links"
itemLabel="Link"
component={LinkForm}
defaultValue={defaultLink}
value={links}
onChange={setLinks}
/>
</div>
)
}
Notice that there are a few "unspoken" rules in place with this pattern. One of
these rules is that the defaultValue
prop also needs to be memoized for the
memoized callbacks to remain stable. In this case, we achieve that by defining
the objects outside the component body, but you could also do it with the
React.useMemo
hook.
Recreating the Bug
You might not have run into the bug while trying out the demo form. But it's there, waiting, and it'll wait weeks, months, or even years before showing itself (probably during a sales demo when the stakes are extra high).
If you didn't run into it above, try again here. But this time, try adding multiple users and changing some fields. Also, see what happens if you try to add another user after changing a user you've already added.
Users
Pretty surprising, right?
Before we dig into what happened, take a look at the UserForm
implementation
and see if you can spot the bug.
import * as React from 'react'
import type { ArraySubFieldProps } from './ArrayField'
export type User = {
firstName: string
lastName: string
email: string
}
export function UserForm({
label,
value,
index,
onUpdate,
onRemove,
onReset,
}: ArraySubFieldProps<User>) {
const changeValue = (event: React.FormEvent) => {
const target = event.target as HTMLInputElement
const nextValue = value
nextValue[target.name as keyof User] = target.value
onUpdate(index, nextValue)
}
return (
<fieldset>
<legend>{label}</legend>
<label htmlFor={`firstName-${index}`}>First Name</label>
<input
id={`firstName-${index}`}
name="firstName"
type="text"
value={value.firstName}
onChange={changeValue}
/>
<label htmlFor={`lastName-${index}`}>Last Name</label>
<input
id={`lastName-${index}`}
name="lastName"
type="text"
value={value.lastName}
onChange={changeValue}
/>
<label htmlFor={`email-${index}`}>Email</label>
<input
id={`email-${index}`}
name="email"
type="email"
value={value.email}
onChange={changeValue}
/>
<button onClick={() => onRemove(index)}>Remove</button>
<button onClick={() => onReset(index)}>Reset</button>
</fieldset>
)
}
Tracking Down the Bug
The trick used in the onChange
callback has become fairly popular in the past
few years, for good reason. If you're unfamiliar, it relies on setting the
name
prop for each input to match the name of its property in the value
object. You can then grab the name
from the event's target
in the callback
and dynamically update the value on the original object. It's helpful because
not only can it reduce a potentially large number of similar callbacks
(changeFirstName
, changeLastName
, changeEmail
, etc.) down to one flexible
callback, but it also aligns pretty well with how form elements work in vanilla
JavaScript apps.
In this case, the issue is how we update a property on the original object. It's a mistake I see pretty often: either the author forgot to make a copy of the original, or (if they're not yet familiar with how object references work in JavaScript) they may have thought they already had made a copy. Specifically, the issue is on this line:
const nextValue = value
Because value
is an object (not a primitive like number
or string
), both
value
and nextValue
now point to the same object in memory. So, when we
update the nextValue
object on the next line,
nextValue[target.name] = target.value
The original value
object also changes.
In many cases, this kind of mutation can occur without serious side effects. In particular, if the component that holds the state is the only component that depends on the state, then mutating it will usually have no consequences. The problem there—and the reason that it's easier just to say "don't mutate state"—is that there's no way to enforce that rule. Some other developer (or Future You, after you've forgotten all about this) may refactor that component and break the unspoken rule.
Speaking of unspoken rules, remember the one from earlier about needing to
memoize the defaultValue
prop? It turns out that's the second piece to this
mystery. Because each element in the array points to the same object, mutating
any of them mutates all of them. Even creating a new copy of the array inside
the ArrayField
callbacks can't get around this because those copies are
shallow. In other words, the copied array is a new instance, but each index of
the new array points to the same object as the corresponding index of the
original array.
The Fix
Let's review what we've found so far. Two simultaneous factors cause this bug: changing a field within the sub-form mutates the existing value for that form, and each element in the array of values points to the same original object. To fix it, we need to address at least one—but ideally both—of them.
First, we'll fix the mutation. We can use the spread
operator to create a copy of the current value object.
It's important to note that, like copying the array, this only makes a shallow
copy. So if the value
object has nested objects as properties, we'll need to
remember to handle that specifically.
const changeValue = (event: React.FormEvent) => {
const target = event.target as HTMLInputElement
const nextValue = { ...value }
nextValue[target.name as keyof User] = target.value
onUpdate(index, nextValue)
}
We can simplify this a bit by combining the spread operator with the property update, using a computed property name:
```tsx {4,5}
const changeValue = (event: React.FormEvent) => {
const target = event.target as HTMLInputElement
onUpdate(index, {
...value,
[target.name as keyof User]: target.value,
})
}
This change alone will fix the bug, but it leaves us with another unspoken rule
that every developer will need to know about whenever they update or change any
of these form components. To ensure we fix this bug everywhere, we should also
have each element in the value
array point to a unique object. We can do that
by changing the "append" and "reset" callbacks in ArrayField
to create a copy
of the default object.
const appendItem = () => {
onChange([...value, { ...defaultValue }])
}
// ...snip: other callbacks stay the same
const resetIndex = React.useCallback(
(index: number) => {
onChange((value) => {
const nextValue = [...value]
nextValue[index] = { ...defaultValue }
return nextValue
})
},
[onChange, defaultValue]
)
You could even take this a step further by changing the defaultValue
prop to a
function, like getDefaultValue
. That way, the function itself can be memoized,
but the object returned could be a new instance each time. A change like this
isn't necessary for every use case, but the advantage it has over the simple
copy from above is that it also works for objects with nested objects as
properties. For example, imagine we update UserForm
with an address
property, an object with all the usual address fields.
// ArrayField.tsx
import * as React from 'react'
type ArraySubFieldProps<T> = {
label: string
value: T
index: number
onUpdate: (index: number, value: T) => void
onRemove: (index: number) => void
onReset: (index: number) => void
}
type ArrayFieldProps<T> = {
label: string
itemLabel?: string
component: React.ElementType<ArraySubFieldProps<T>>
getDefaultValue: () => T
value: T[]
onChange: React.Dispatch<React.SetStateAction<T[]>>
}
function ArrayField<T>({
label,
itemLabel = 'Item',
component: Component,
getDefaultValue,
value,
onChange,
}: ArrayFieldProps<T>) {
const appendItem = () => {
onChange([...value, getDefaultValue()])
}
// ...snip: the other callbacks stay the same
const resetIndex = React.useCallback(
(index: number) => {
onChange((value) => {
const nextValue = [...value]
nextValue[index] = getDefaultValue()
return nextValue
})
},
[onChange, defaultValue]
)
// ...snip: the returned JSX stays the same, too
}
// DemoForm.tsx
import * as React from 'react'
import type { User } from './UserForm'
import { UserForm } from './UserForm'
export function DemoForm() {
const [users, setUsers] = React.useState<User[]>([])
return (
<div>
<ArrayField
label="Users"
itemLabel="User"
component={UserForm}
getDefaultValue={React.useCallback(
() => ({
firstName: '',
lastName: '',
email: '',
address: {
street: '',
city: '',
state: '',
zip: '',
},
}),
[]
)}
value={users}
onChange={setUsers}
/>
</div>
)
}
If you found this post helpful and want to take a look at all of the code, I've
created a sandbox with both the broken and fixed versions.
And if you find yourself building something similar to the ArrayField
component, I highly recommend checking out react-hook-form
for managing the form state. This library includes a
useFieldArray
hook for exactly this use case.