logo

Ziffur

Composing with React Hooks

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:

  1. In a single component file, write out the basic functionality we need.
  2. Re-write the existing logic to make it easier to expand on.
  3. Add a new feature.
  4. 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. CounterCounterControls. 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

Vue Style Guide

Airbnb React/JSX Style Guide

(YouTube) Clean Code - Uncle Bob / Lesson 1

kettanaito/naming-cheatsheet

alan2207/bulletproof-react

BEM — Block Element Modifier

Model–view–controller (MVC)

Presentational and Container Components by Dan Abramov

TypeScript

[^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.

Tags

Computer Science
Programming
React
JavaScript