React has a wonderful hook called useState
that we use to set and listen to things that might change over time.
There's a few points I'd like to argue here.
1. Using useState
like this is bad, and should throw a lint error:
function Component({ someProp }) {
// ESLint error: initial state depends on a mutable value
const [state, setState] = useState(someProp);
...
}
2. Derived state is sometimes a valid approach to solving a problem, but the escape hatches React suggests can be somewhat invasive and error-prone.
3. The "right" primitive for this would be a useState
hook that requires a dependency array. The title. We'll get to that eventually.
Let's get started.
Edit (31/05/25): I'm using controlled inputs throughout this article, but you'll find that a similar issue actually comes up with using defaultValue
for uncontrolled inputs!
So, about that lint error I suggested. Maybe you've already identified the issue with the code above. If not, hopefully this demo illustrates it better:
When you change the "task" being edited, the text in the input doesn't change to whatever the title for the new task is for.
But the text for the previous task you were editing before isn't relevant to the task you're editing now, right? Wouldn't you expect it to "reset" with the rest of the UI?
Let's see if anything familiar comes up in the code for that edit panel:
function EditPanel({ item, saveName }) {
// This isn't reset when `item.name` changes!
const [name, setName] = useState(item.name);
return (
<div>
<input value={name} onChange={(e) => setName(e.target.value)} />
<button onClick={() => saveName(name)}>Save</button>
</div>
);
}
What we've done here is create un-reactive code by accident.
Ideally, components should be dealing with the reality that a prop can change over time. But instead, we're taking a snapshot of only the initial value of a prop, and ignoring any changes.
It's a fairly common position that deriving any mutable state from other state should be avoided where possible. In other words, you shouldn't be able to put a prop or some other piece of state inside a useState
call. And I'd agree to an extent - so much so that I think that piece of code should warrant the death penalty, or at least a warning from ESLint. But there's no such warning, and I haven't been strapped to the electric chair just yet.
I've seen this problem come up way too often in production, and it's in no way exclusive to React - it's the reality of any moving part that is tied to another moving part, but also needs to move on its own for a while. You'll find the same problem in SolidJS, except...
function SolidComponent(props) {
// ESLint error: The reactive variable 'props.someProp' should be used within JSX, a tracked scope (like
// createEffect), or inside an event handler function, or else changes will be ignored.
const [state, setState] = createSignal(props.someProp);
}
...Solid's recommended ESLint config does fire a warning for this!
So, I don't really see a reason why React's ESLint config shouldn't prohibit this.
React offers a few solutions to this problem, both in the old "You Probably Don't Need Derived State" article (pre-hooks!), and in the way more recent article on useState.
First, if the derived state you're creating is read-only, i.e. is only written to when the source state changes - you don't need useState
at all. You can just compute it directly like so:
function Component({ a }) {
const [b, setB] = useState(0);
const sum = a + b;
...
}
Or, if you really don't want to re-compute it each render, and aren't using the new React Compiler, there's useMemo
:
function Component({ a }) {
const [b, setB] = useState(0);
const sum = useMemo(() => a + b, [a, b]);
...
}
It's the cases that can't be represented this way - cases that need mutable derived state - that get really messy. This tends to come up a lot when dealing with user input and optimistic updates (if you can't use useOptimistic
or Tanstack Query for whatever reason).
Let's go back to the todo list example from before. The textbook way of fixing this is through the key
prop, which exists on every React component. So, let's add that to where this component is used:
<EditPanel item={items[selectedItemId]} key={selectedItemId} />
And it works - the input field changes when we edit a new task!
But we have to be careful with this - what if there's still some state that we want to preserve?
Call it contrived, but maybe we want to add drawings to our tasks. With different brush colours.
function EditPanel(...) {
const [brushColor, setBrushColor] = useState('black');
...
}
Try changing the brush colour before selecting another task.
Why should the colour we're drawing with reset each time we change what task we're editing? That state might be part of the same component, but it shouldn't be joined to the specific task we're editing.
If key
changes between renders, the entire component tree below that point is "remounted". That means that the underlying DOM nodes will be re-created, and that any state declared below that point will be trashed and reset.
This is why we're now seeing that field reset as expected, and why we're seeing the chosen brush colour reset as not expected. We can't just reset state at the level of a single useState
hook, or even a single component instance.
You might have also noticed that we're now passing on the responsibility of resetting this state to the consumer of this component. What if we end up using it elsewhere, but forget to provide a key
prop? Also, what if you have more than one dependency that should cause a component to reset? You're going to need to merge those into a single key somehow.
So, sometimes forcing a remount with key
is absolutely the right option, but there's cases where it's too much of an ask.
For those cases, the React docs provides a pattern that's a bit more isolated:
export default function CountLabel({ count }) {
const [prevCount, setPrevCount] = useState(count);
const [trend, setTrend] = useState(null);
if (prevCount !== count) {
setPrevCount(count);
setTrend(count > prevCount ? 'increasing' : 'decreasing');
}
return (
<>
<h1>{count}</h1>
{trend && <p>The count is {trend}</p>}
</>
);
}
The nicest thing I can say about this is that it works - this would force only the state we specify to reset. And at the very least, React does prevent Effects from running and children from re-rendering if all a render here does is trigger another render.
But despite this pattern only being applied to one value, it already looks quite janky. We now have to manage previous state, and there's a lot of room for things to go wrong. Maybe our condition gets us stuck in an infinite loop, or maybe we accidentally omit a dependency that we should be listening to.
Now, what I've seen more in practice is code that looks like this, which definitely seems more elegant:
function EditPanel({ item, ... }) {
const [name, setName] = useState(item.name);
useEffect(() => {
setName(item.name);
}, [item.name]);
...
But this introduces an unnecessary re-render. What's even worse is the fact that the first re-render has a mix of stale and non-stale data, leading to potentially dangerous behaviour with other useEffects:
function EditPanel({ item, ... }) {
...
useEffect(() => {
someDatabase.sync({ id: item.id, name });
}, [item.id, name]);
// Runs twice when item.id changes:
// 1. someDatabase.sync({ id: 'Second item ID', name: 'First item name' }) (incorrect!)
// 2. someDatabase.sync({ id: 'Second item ID', name: 'Second item name' })
...
In my opinion, these workarounds are far more complex than just having proper tools for mutable derived state. And these days, at least Svelte and SolidJS seem to agree.
Let's look at the createWritable
"hook" being proposed for Solid. Its implementation is actually fairly simple, and reveals a lot about its expected behaviour.
Some context:
create
.createSignal
is basically just useState
.createMemo
is basically just useMemo
, but automatically derives its dependencies instead of requiring a dependency array.function createWritable(
// Function for computing "initial" state
fn,
) {
// When dependencies used by `fn` change, the memoized state is
// discarded and recomputed
const computed = createMemo(() => createSignal(fn()))
// This looks scary, but it's basically just returning the getters
// and setters from the state declared above
return [() => computed()[0](), (v) => computed()[1](v))]
}
What this returns is state that is memoized by whatever dependencies were used to create that state. Just like Svelte's $derived
, that state can change over the lifetime of its dependencies - but once one of those dependencies changes, that state is trashed and re-computed.
I'm only using Solid's implementation as an example here because implementing a hook like this in React is actually quite annoying. For starters, Rules of Hooks prevents you from nesting a useState
inside a useMemo
.
But React implementations of this concept do exist, such as use-state-with-deps. Let's give it a shot.
import { useStateWithDeps as useState } from 'use-state-with-deps';
function EditPanel({ item, ... }) {
const [name, setName] = useState(item.name, [item]);
...
}
No messing with key
, weird setter calls in the render method, or useEffect
- it just kinda works.
What I'm proposing is that this becomes how useState
works by default.
Remember that ESLint error I proposed further up? Maybe it should look more like this:
function Component({ someProp }) {
// ESLint error: someProp not specified in dependency array
const [state, setState] = useState(someProp);
...
}
...and be fixed like so:
function Component({ someProp }) {
// No error!
const [state, setState] = useState(someProp, [someProp]);
...
}
There's a lot of precedent to draw from with how dependency arrays for useMemo
and useEffect
work. We'd probably want to inherit the same ESLint warnings and autofixes, and have the React Compiler auto-generate dependencies in the same way.
Of course, we wouldn't want to fully follow useEffect
's precedent and have the absence of a dependency array re-create state on each render. A missing dependency array here should behave the same as an empty one, which also means code like this is still perfectly valid:
function Component({ someProp }) {
// No error - we're not deriving state from anything mutable.
const [state, setState] = useState(0);
...
}
I'm not trying to entirely rule out having useState
not reset when something it depends on changes. You'd still have the option of lying to the dependency array like with other hooks:
// eslint-disable-next-line react-hooks/exhaustive-deps
useState(someProp, []);
...which in my opinion, is a much simpler escape hatch than what it takes to get the behaviour that's more "correct" today.