Composing with React Hooks
12 Feb 2022
Custom hooks are a wonderful tool to have when you're building interactive applications with React. Over the last few months and years, I've developed a few patterns and made a few observations I wanted to share. You see, I spend an unreasonable amount of effort trying to organize and de-clutter my code (far more than I do adding features, at least when I'm off-the-clock). If you're interested in doing the same, I have a few tricks you might find useful.
A Counter App
For the sake of demonstration, we'll be looking at the minimal example of a basic counter app. The abstractions I'll be making may feel a bit contrived but I'm convinced that you'll find opportunities to use these principles in your own (more realistic) projects.
I'm going to present the source code for this Counter app in the way that it might happen organically. Something like this:
- In a single component file, write out the basic functionality we need.
- Re-write the existing logic to make it easier to expand on.
- Add a new feature.
- Repeat steps 2-4.
Here's what the first iteration of our basic counter app looks like:
// Counter.jsx
import { useState } from "react";
export default function Counter() {
const [count, setCount] = useState(0);
const increment = () => setCount(count + 1);
return (
<>
<div>You clicked {count} times</div>
<button onClick={increment}>Click me!</button>
</>
);
}
Pretty harmless so far. Let's add a feature before we start creating extra abstractions. So far, our app's users are able to count their button clicks but are unable to remove past clicks. Let's add a decrement
function:
// Counter.jsx
import { useState } from 'react';
export default function Counter() {
const [count, setCount] = useState(0);
const increment = () => setCount(count + 1);
+ const decrement = () => setCount(count - 1);
return (
<>
- <div>You clicked {count} times</div>
+ {count}
- <button onClick={increment}>Click me!</button>
+ <button onClick={increment}>+</button>
+ <button onClick={decrement}>-</button>
</>
);
}
Still pretty manageable, but the component's responsibilities are slowly starting to grow. While things are still small, let's take the opportunity to create some new abstractions. This is where the example becomes a little contrived but I trust that you'll find more than enough opportunities to apply it. Let's make a custom hook. For now, we'll name our hook useCounter
and have it take care of all of the counter's logic. When I write abstractions of any kind, I like to start by pretending that they already exist. Doing so lets us create the API[^1] which suits our use case best. Let's see what this looks like:
// Counter.jsx
-import { useState } from 'react';
+import useCounter from './useCounter';
export default function Counter() {
+ const { count, increment, decrement } = useCounter();
- const [count, setCount] = useState(0);
- const increment = () => setCount(count + 1);
- const decrement = () => setCount(count - 1);
return (
<>
{count}
<button onClick={increment}>+</button>
<button onClick={decrement}>-</button>
</>
);
}
Now we can create the useCounter
hook by simply pasting in the lines we removed from the counter app:
// useCounter.js
import { useState } from "react";
export default function useCounter() {
const [count, setCount] = useState(0);
const increment = () => setCount(count + 1);
const decrement = () => setCount(count - 1);
return { count, increment, decrement };
}
Cool, I think we're making great progress. I want to make one final refactoring for now before I start talk about the ideas and benefits behind these patterns. Let's make a CounterControls
component to take care of the increment/decrement buttons. Again, let's start in the main Counter
component and pretend that CounterControls
already exists:
// Counter.jsx
import useCounter from './useCounter';
+import CounterControls from './CounterControls';
export default function Counter() {
const { count, increment, decrement } = useCounter();
return (
<>
{count}
+ <CounterControls onIncrement={increment} onDecrement={decrement} />
- <button onClick={increment}>+</button>
- <button onClick={decrement}>-</button>
</>
);
}
And finally, based on this new API[^1], we can write the CounterControls
component:
// CounterControls.jsx
export default function CounterControls({ onIncrement, onDecrement }) {
return (
<>
<button onClick={onIncrement}>+</button>
<button onClick={onDecrement}>-</button>
</>
);
}
Our entire codebase now looks like this:
// Counter.jsx
import useCounter from './useCounter';
import CounterControls from './CounterControls';
export default function Counter() {
const { count, increment, decrement } = useCounter();
return (
<>
{count}
<CounterControls onIncrement={increment} onDecrement={decrement} />
</>
);
}
// CounterControls.jsx
export default function CounterControls({ onIncrement, onDecrement }) {
return (
<>
<button onClick={onIncrement}>+</button>
<button onClick={onDecrement}>-</button>
</>
);
}
// useCounter.js
import { useState } from 'react';
export default function useCounter() {
const [count, setCount] = useState(0);
const increment = () => setCount(count + 1);
const decrement = () => setCount(count - 1);
return { count, increment, decrement };
}
What We Learned So Far
There are lots of small things which go into authoring and maintaining good, clean code. First of all there's formatting, for which I suggest you always use an automated tool such as Prettier (I configure mine to run on save). Then there's naming.
There are only two hard things in Computer Science: cache invalidation and naming things.
—Phil Karlton
A good rule of thumb I like to follow for naming is to mimic natural language as closely as possible. This means using verbs for functions, nouns for generic variables and yes/no questions (using the prefixes is
/has
/should
) for booleans. When naming components in React, I recommend a hierarchical approach. That is, components which are tightly-coupled children of their parent component should inherit their parent's name, e.g. Counter
→ CounterControls
. I've included a few resources below on naming if you want to learn more.
Instead of defining the increment/decrement functions inside the CounterControls
component, I define them in the Counter
parent component (with the help of the useCounter
hook) and pass them via props as callback functions (a form of dependency injection). This small difference in approaches makes a world of a difference when it comes to maintaining and extending code. Don't underestimate it.
By using dependency injection for the increment
/decrement
functions, we've also managed to turn CounterControls
into a presentational component. This is related to the classic MVC and makes our components easier to work with. Isolating our components like this can also improve performance by preventing unnecessary re-renders.
Spreading Props
If you've used eslint-plugin-react
as part of your development setup (if you don't use eslint
at all, do yourself a favor and start now!), you've probably come across a rule named jsx-props-no-spreading. This is an interesting rule because it forces us to be explicit with the data we pass to our JSX components (and HTML tags) while a lot of popular libraries (e.g. react-beautiful-dnd) encourage us to break it.
Because of the rule above, I've completely avoided spreading props in my code for the past years but have recently started experimenting with it. Coming back to our counter app, spreading props can be very convenient when paired with custom hooks. Let's try it:
// Counter.jsx
import useCounter from './useCounter';
import CounterControls from './CounterControls';
export default function Counter() {
- const { count, increment, decrement } = useCounter();
+ const { count, counterControlsProps } = useCounter();
return (
<>
{count}
- <CounterControls onIncrement={increment} onDecrement={decrement} />
+ <CounterControls {...counterControlsProps} />
</>
);
}
// useCounter.js
import { useState } from 'react';
export default function useCounter() {
const [count, setCount] = useState(0);
const increment = () => setCount(count + 1);
const decrement = () => setCount(count - 1);
- return { count, increment, decrement };
+ return {
+ count,
+ counterControlsProps: {
+ onIncrement: increment,
+ onDecrement: decrement,
+ },
+ };
}
This is great news for the main Counter
component since it no longer has to concern itself with the increment
and decrement
functions or where to pass them. Instead, it just sees an object named counterControlsProps
and passes it to a component named CounterControls
. No implementation details, no mental overhead. The trade-off we make for this is that now the useCounter
is quite tightly coupled to the CounterControls
component. If this becomes a problem in the future, we could move the increment
/decrement
functions out of the useCounter
hook into a dedicated useCounterControls
hook, making its coupling to the CounterCotrols
component explicit. The result might look something like this:
// Counter.jsx (future version)
// import useState, useCounterControls
export default function Counter() {
const [count, setCount] = useState(0);
const counterControlsProps = useCounterControls(setCount);
return (
<>
{count}
<CounterControls {...counterControlsProps} />
</>
);
}
Coming back to the jsx-props-no-spreading
rule, note that it ships with an option to ignore explicit spreading (e.g. ...{ prop1, prop2 }
). We can strengthen our code by providing a layer of explicitness if we convert our codebase to TypeScript. In our case, we only need to manually declare types in one place, the rest will be inferred by the TypeScript compiler:
-// CounterControls.jsx
+// CounterControls.tsx
-export default function CounterControls({ onIncrement, onDecrement }) {
+export default function CounterControls({
+ onIncrement,
+ onDecrement,
+}: {
+ onIncrement: () => void;
+ onDecrement: () => void;
+}) {
return (
<>
<button onClick={onIncrement}>+</button>
<button onClick={onDecrement}>-</button>
</>
);
}
Now you, too, can spread your props and take flight!
References and Further Reading/Watching
(YouTube) Clean Code - Uncle Bob / Lesson 1
Presentational and Container Components by Dan Abramov
[^1]: The term API (Application Programming Interface) is commonly used to refer to HTTP endpoints. In this article, I use it to refer to the signature of a function (such as a React hook or component), defined mainly by its name and parameters.