Article summary
When creating a component in React, I often want it to be independent, able to maintain its own state. And at the same time, I’d like its parent to be notified when the component’s state changes, and I want to enable the parent to pass down new state at any time.
Implementing this behavior in a React function component just requires the right application of hooks. A simple component for adding fruit to a bag might accept props like this:
type Props = {
fruits: Fruit[];
onChange: (fruits: Fruit[]) => void;
};
State goes into the component through the fruits
prop, and changes are communicated back up to the parent through the onChange
callback. Importantly, we only want the onChange
callback to be fired when the component itself is updating the state (usually in direct response to user input) — not when the parent has just sent in new state. Doing this should break most of those nasty infinite update loops.
Inside the component, state can be managed with useState
or useReducer
. useReducer
may be preferred when there’s a lot of state or when updating it gets complicated. It also makes this discussion more interesting, so I’ll use it in the following examples (even though the state is pretty simple).
Say our initial FruitBag
component looks like this:
export type Fruit = "Apple" | "Orange" | "Pear";
const allFruits: Fruit[] = ["Apple", "Orange", "Pear"];
type Props = {
fruits: Fruit[];
onChange: (fruits: Fruit[]) => void;
};
type State = {
fruits: Fruit[];
};
export const FruitBag: React.FunctionComponent<Props> = ({
fruits,
onChange
}) => {
const [state, dispatch] = useReducer(reducer, { fruits });
return (
<div>
<div>Fruits in bag: {fruits.join(", ")}</div>
<ul>
{allFruits.map(fruit => (
<li key={fruit}>
<button onClick={() => dispatch({ type: "addFruit", fruit })}>
Add
</button>
<button onClick={() => dispatch({ type: "removeFruit", fruit })}>
Remove
</button>
{fruit}
</li>
))}
</ul>
</div>
);
};
type Action =
| { type: "addFruit"; fruit: Fruit }
| { type: "removeFruit"; fruit: Fruit };
const reducer: Reducer<State, Action> = (prevState, action) => {
switch (action.type) {
case "addFruit":
return {
...prevState,
fruits: [...prevState.fruits, action.fruit]
};
case "removeFruit":
return {
...prevState,
fruits: _.remove(prevState.fruits, f => f !== action.fruit)
};
}
};
Currently, FruitBag
will accept the list of fruits given to it when the component is mounted, and it will update its own list when fruit is added or removed. But we still want it to notify the parent of changes using onChange
and accept an updated list of fruits at any time.
Notify Parent Using onChange
Let’s start with notifying about changes. We could put the call to onChange
right after dispatch
, like so:
<button onClick={() => {
dispatch({ type: "addFruit", fruit });
props.onChange(state.fruits);
}}>
But this won’t work the way we want it to. It will send the previous list of fruits, before the update. This is because dispatch
is asynchronous — not in the same sense as async
/await
and Promises, but simply due to the nature of React’s component render cycle. The state
returned from useReducer
is the actual state value; it’s not a getter or a reference, so it doesn’t have the updated value until the next render cycle.
To get around this, you might be tempted to include a call to onChange
directly within your reducer. But since we have no idea what the callback might do, we have to treat it like a side effect. And reducers must only produce an updated state without causing any side effects.
In a function component, the right place for side effects is in a useEffect
hook. So let’s add one of those to notify whenever state
changes:
useEffect(() => {
onChange(state.fruits);
}, [state]);
This seems simple enough: useEffect
will run this function after rendering, but only if state
has changed since the last render.
Of course, if you’re using a linting tool like ESLint, you’ll get a warning like this:
React Hook useEffect has a missing dependency: ‘onChange’. Either include it or remove the dependency array. If ‘onChange’ changes too often, find the parent component that defines it and wrap that definition in useCallback.
useEffect
prefers that the dependency array include all of the stateful things referenced by the function. In this case, that includes state
as well as onChange
, which is passed in through props. Since we are only interested in calling onChange
when state
changes, this code will work correctly as is.
And adding onChange
to the dependency array may actually introduce subtle bugs if the parent sets up onChange
incorrectly. For instance, say the parent component did this:
return (<FruitBag fruits={fruits} onChange={newFruits => console.log(newFruits)} />);
This doesn’t seem too unreasonable. But since onChange
is actually getting a new function every time this parent component renders, it will be fired redundantly (and even erroneously, since the intention is only to call onChange
when FruitBag
updates its state).
One option would be to suppress the lint warning since we know what we’re doing. But since lint warnings are there for a good reason, the other option would be to do what it says and add onChange
to the list of dependencies:
useEffect(() => {
onChange(state.fruits);
}, [state, onChange]);
In order to get the expected behavior, this will also require anything using FruitBag
to memoize onChange
like so:
const handleChange = useCallback(newFruits => {
console.log(newFruits);
}, []);
return (<FruitBag fruits={fruits} onChange={handleChange} />);
Perhaps this isn’t a bad habit to get into. Similarly to useEffect
, useCallback
will only do its thing if its dependencies have changed. In this case, the dependency array is empty []
, so it will only run once during this component’s lifecycle. handleChange
will then be a stable function, meaning it won’t change from one render to the next.
Accept Updated State Through Props
Even if a component maintains its own internal state, it’s often useful to be able to update this state from a parent. This first line of the FruitBag
component is a good start:
const [state, dispatch] = useReducer(reducer, { fruits });
This will initialize the reducer with state initially passed into the component, but it won’t update if the parent sends new fruits
at a later time. Since this sounds like mutating state when something changes, another useEffect
is in order. We’ll also add a reset
action to the reducer to simply pass in the new state:
useEffect(() => {
dispatch({ type: "reset", fruits });
}, [fruits]);
Here’s the complete FruitBag
component with these changes:
export type Fruit = "Apple" | "Orange" | "Pear";
const allFruits: Fruit[] = ["Apple", "Orange", "Pear"];
type Props = {
fruits: Fruit[];
onChange: (fruits: Fruit[]) => void;
};
type State = {
fruits: Fruit[];
notify: boolean;
};
export const FruitBag: React.FunctionComponent<Props> = ({
fruits,
onChange
}) => {
const [state, dispatch] = useReducer(reducer, fruits, init);
useEffect(() => {
dispatch({ type: "reset", fruits });
}, [fruits]);
useEffect(() => {
if (state.notify) {
onChange(state.fruits);
}
}, [state, onChange]);
return (
<div>
<div>Fruits in bag: {fruits.join(", ")}</div>
<ul>
{allFruits.map(fruit => (
<li key={fruit}>
<button onClick={() => dispatch({ type: "addFruit", fruit })}>
Add
</button>
<button onClick={() => dispatch({ type: "removeFruit", fruit })}>
Remove
</button>
{fruit}
</li>
))}
</ul>
</div>
);
};
type Action =
| { type: "addFruit"; fruit: Fruit }
| { type: "removeFruit"; fruit: Fruit }
| { type: "reset"; fruits: Fruit[] };
function init(initialFruits: Fruit[]): State {
return { fruits: initialFruits, notify: false };
}
const reducer: Reducer<State, Action> = (prevState, action) => {
switch (action.type) {
case "reset":
return init(action.fruits);
case "addFruit":
return {
...prevState,
fruits: [...prevState.fruits, action.fruit],
notify: true
};
case "removeFruit":
return {
...prevState,
fruits: _.remove(prevState.fruits, f => f !== action.fruit),
notify: true
};
}
};
useReducer
has been modified to accept an init function, which it uses internally to set the initial state. We’re also using it to process the reset
action.
Notice that, unlike with onChange
, we don’t have to add dispatch
to the dependency array of useEffect
. That’s because useReducer
guarantees that dispatch
is a stable function (likewise for the set function of a useState
).
Another important change is the addition of a notify
flag to state
. We can use this to differentiate between state updates coming from within the FruitBag
(notify: true
) and updates coming in through props (notify: false
). This ensures we only send onChange
for internal changes.
The End
The state in this example was pretty simple. And this particular component would probably be better off not maintaining any internal state at all (just rendering based on props, sending updates through onChange
, and waiting for the parent to send new props).
However, this pattern can improve a component’s reusability, and it can be very useful as state gets more complex.
Hi Brian. I like the useReducer approach.
What do you think about having that state inside a context (react context), exposing the fruits and the dispatch method?
(avoiding props on FruitBag and those useEffects)
Hi André, that would certainly work if you wanted to remove state management from this component. Although I would still pass the state and dispatch callback through props unless a context is really necessary (I prefer to reserve those for global-style resources that need to be shared among many components).