The paired hook pattern
Starting with something simple
After years of working with React and TypeScript, I’ve seen a lot of patterns
for component development, but so far, I didn’t saw one that works as well for
function components as the “paired hook pattern.” So to get started, let’s use a
classic: The Counter
component.
First, we write a stateless component:
const Counter = ({ count, onDecrement, onIncrement }) => (
<>
<span>{count}</span>
<button onClick={onIncrement}>+</button>
<button onClick={onDecrement}>-</button>
</>
);
And when we use it, we need to create a state for it:
const App = () => {
const [count, setCount] = useState(0);
return (
<Counter
count={count}
onDecrement={() => setCount(count - 1)}
onIncrement={() => setCount(count + 1)}
/>
);
};
The dynamic looks something like this:
The first problem: Reuse
The problem with the stateless component is that we need to use the useState
hook every time, which might be annoying for components that require more
properties and are all over our app.
So, it is ubiquitous to just put the state directly in the component. By doing
this, we don’t need to have a state every time we use it, so then our Counter
component changes to something like this:
const Counter = ({ initialCount = 0, step = 1 }) => {
const [count, setCount] = useState(initialCount);
return (
<>
<span>{count}</span>
<button onClick={() => setCount(count + step)}>+</button>
<button onClick={() => setCount(count - step)}>-</button>
</>
);
};
And then to use it as many times as we want without having to create a state for each:
const App = () => (
<>
<Counter />
<Counter />
<Counter />
</>
);
The dynamic then looks like this:
The second problem: Data flow
Now, that’s great until we want to know the current state of the counter element from the parent element. So we might be tempted to create a monster like this one:
const Counter = ({ initialCount = 0, step = 1, onCountChange }) => {
const [count, setCount] = useState(initialCount);
useEffect(() => onCountChange?.(count), [count]);
return (
<>
<span>{count}</span>
<button onClick={() => setCount(count + step)}>+</button>
<button onClick={() => setCount(count - step)}>-</button>
</>
);
};
And then use it like this:
const App = () => {
const [count, setCount] = useState(0);
return (
<>
<span>Current count in Counter: {count}</span>
<Counter onCountChange={setCount} />
</>
);
};
It might not be evident at first glance, but we are introducing side effects to every state change to keep the parent in sync with the children, and this has two significant issues:
- First, the state lives in two places simultaneously (the parent element and the children).
- Second, the children are updating the parent’s state, so we are effectively going against the one-way data flow.
The paired hook pattern
One of the best things about hooks is when we create our own. The solution I’ll show next for this issue is quite simple, but I believe it solves most problems with state management I’ve seen around. The first step is similar to what we had at the beginning here; we create a stateless component:
const Counter = ({ count, onDecrement, onIncrement }) => (
<>
<span>{count}</span>
<button onClick={onIncrement}>+</button>
<button onClick={onDecrement}>-</button>
</>
);
But this time, instead of requiring the consumers of our component to figure out
the state themselves, we create a hook that goes together with our component; we
can call it useCounter
. The main requirement for this hook is that it needs to
return an object with properties matching the properties of Counter
:
const useCounter = ({ initialCount = 0, step = 1 } = {}) => {
const [count, setCount] = useState(initialCount);
return useMemo(
() => ({
count,
onDecrement: () => setCount(count - step),
onIncrement: () => setCount(count + step),
}),
[count, step],
);
};
What this enables is that now we can use it almost as a stateful component:
const App = () => {
const counterProps = useCounter();
return <Counter {...counterProps} />;
};
But also, we can use it as a stateless component:
const App = () => <Counter count={42} />;
And we no longer have limitations accessing the state because the state is actually in the parent.
const App = () => {
const { count, ...counterProps } = useCounter();
return (
<>
<span>Current count in Counter: {count}</span>
<Counter {...{ count, ...counterProps }} />
</>
);
};
The dynamic then looks something like this:
With this approach, we are truly making our component reusable by not making it require a context or weird callbacks based on side effects or anything like that. Instead, we have a lovely pure stateless component with a hook that we can pass directly or partially if we want to take control of any property.
The name “paired hook” comes from providing a hook with a stateless component that can be “paired” to it.
A problem (and solution) with the paired pattern
The main issue the paired hook approach has is that now we need a hook for every
component with some state. That is ok when we have a single component but
becomes tricky when we have several components of the same type (for example,
having a list of Counter
components).
We might be tempted to do something like this:
const App = ({ list }) => (
<>
{list.map(initialCount => {
const counterProps = useCounter({ initialCount });
return <Counter {...counterProps} />;
})}
</>
);
But the problem with this approach is that we’re going against the rules of
hooks because we’re calling the useCounter
hook inside a
loop. Now, if we think about it, we can loop over components that have a state
of their own, so one viable solution is to create a “paired” version of our
component, which calls the hook for us:
const PairedCounter = ({ initialCount, step, ...props }) => {
const counterProps = useCounter({ initialCount, step });
return <Counter {...counterProps} {...props} />;
};
// And then...
const App = ({ list }) => (
<>
{list.map(initialCount => (
<PairedCounter initialCount={initialCount} />
))}
</>
);
This approach seems similar to the stateful (the second example in this article)
but is way more flexible and testable. The other method we have is to create a
component context for every item without having to write a component ourselves,
and for that, I made a small function that I published in npm called
react-pair
:
The function is so simple that anyone could write it. The only difference is
that I’m testing it, adding Developer Tools integration, and typing with
TypeScript. I hosted the source on GitHub. The usage is
quite simple, react-pair
provides a pair
function that the developer can use
to create a component that gives access to the hook in a component context
(without breaking the rules of hooks):
import { pair } from "react-pair";
import { useCounter } from "./useCounter";
const PairedCounter = pair(useCounter);
const Component = ({ list }) => (
<ul>
{array.map((initialCount, index) => (
<PairedCounter key={index}>
{usePairedCounter => {
const counterProps = usePairedCounter({ initialCount });
return <Counter {...counterProps} />;
}}
</PairedCounter>
))}
</ul>
);
To be clear, react-pair
is not needed to achieve this. Anyone can manually
create a new stateful component that pairs the hook with the component.
Using the util or not, the resulting dynamic looks something like this:
We get something similar to the stateful approach, but with less coupling and more flexibility; because the state doesn’t live inside the component, it lives “besides” it. So we have the cake and eat it too 🍰
TL;DR
- Write a stateless component designed to work in isolation.
- Write a custom hook to be paired with that component.
- Use the component with the hook for a stateful experience.
- Use the component without the hook for a stateless experience.
- Use the component with just a few properties from the hook for a mixed experience.
- Create or use some util or a wrapper component when looping.
- If we can avoid state altogether, we should do it. But if we have to have state in our component, better do it clean and decoupled.
Closing thoughts
I’ve been using this pattern for a while now, and so far, I haven’t found any blocking issues, so I invite the readers of this article to try it out in one of your projects and tell me how it goes!