Max RozenMax Rozen

Guidelines for developing Custom Hooks in React

Max Rozen
@RozenMD

It's pretty common to ask whether there's a rule of thumb for deciding whether or not something should be a custom hook. While it's down to developer preference how you write your custom hooks, there are still some things that aren't immediately obvious.

Luckily for us, hooks are (for lack of a better expression) glorified functions, meaning we can extract them and test them as we like.

At the risk of repeating the docs, here's the gist of custom hooks:

  • Name your custom hooks starting with use (useDataFetcher, for example)
  • Rules of hooks still apply
  • You can call other hooks in your custom hooks
  • Hooks don't share state, meaning you can use the same hook that uses useState in two different places, and they'll be isolated from each other
  • As they're still functions, you can pass the results from one hook as an argument to the next hook
Table of Contents

Why custom hooks?

Since hooks can be treated as functions in a lot of ways, what we know about functions still applies.

  • They're reusable
  • We can break our larger hooks into smaller hooks
  • We can compose our smaller hooks together, like lego bricks
  • They're testable

Sure, you could always re-use the same sequence of hooks in each component (useState, useEffect, update state...), but why repeat yourself when you could extract the logic out into a well-tested custom hook?

What do custom hooks look like?

This is a relatively contrived example, but focus on the concepts for a moment.

Say you have a React component that fetches data and displays it:

import React, { useEffect, useState } from 'react';
export default function MyComponent() {
const [data, setData] = useState(null);
useEffect(() => {
const fetchData = async () => {
const response = await fetch('https://swapi.dev/api/people/1/');
const newData = await response.json();
setData(newData);
};
fetchData();
});
return (
<div>
<h1>Hello!</h1>
{data ? <div>{data.name}</div> : null}
</div>
);
}

You decide you want to use the same useEffect logic in another component.

So you extract it into a custom hook:

//useFetchData.js
import { useEffect, useState } from 'react';
export const useFetchData = () => {
const [data, setData] = useState(null);
useEffect(() => {
const fetchData = async () => {
const response = await fetch('https://swapi.dev/api/people/1/');
const newData = await response.json();
setData(newData);
};
fetchData();
});
return data;
};

Our original component becomes much simpler:

import React from 'react';
import { useFetchData } from './useFetchData';
export default function MyComponent() {
const data = useFetchData();
return (
<div>
<h1>Hello!</h1>
{data ? <div>{data.name}</div> : null}
</div>
);
}

In the real world, we probably wouldn't extract a data fetcher like this, and we'd probably use an existing library such as react-query instead of useFetchData.

Document your custom hooks!

As with most things in a large organisation, the more custom hooks your team writes, the easier it is to lose track of them.

I recommend documenting each hook you write. If not for your team, for future-you. Whether it's a README in each hook's folder or a doc in a wiki - ensure people know your hook exists, what it does, and what your intentions were.

Leaving a note like "This was a massive hack where we only covered case X due to this buggy API we use" is significantly more valuable than writing nothing.

Adding JSDOC comments above your hooks is also useful (here's a cheat sheet):

/**
* This is a JSDOC comment.
* VS Code's tooltip will show extra information because of me.
*/
const useMyCustomHook = (props) => {
// ...
};

Don't assume your code is self-documenting/easy enough to understand. Chances are you're just thinking your code is simple because it's still fresh in your mind.

Testing custom hooks

Thanks to React Testing Library's react-hooks-testing-library, it's never been easier to test hooks in isolation.

Here's an example from their docs. Given a hook extracted as useCounter:

//useCounter.js
import { useState, useCallback } from 'react';
function useCounter() {
const [count, setCount] = useState(0);
const increment = useCallback(() => setCount((x) => x + 1), []);
return { count, increment };
}
export default useCounter;

Your tests would look like this:

//useCounter.test.js
import { renderHook, act } from '@testing-library/react-hooks';
import useCounter from './useCounter';
test('should increment counter', () => {
const { result } = renderHook(() => useCounter());
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});

On Folder Structure

As many things in React go, it's up to developer preference how your code is structured. I wrote some words on the topic already, but here's how I would structure my folders when writing custom hooks:

src/
|-- components/
| |-- Avatar/
| | |-- Avatar.js
| | |-- Avatar.test.js
| |-- Button/
| | |-- Button.js
| | |-- Button.test.js
|-- hooks/
| |-- useCounter/
| | |-- useCounter.js
| | |-- useCounter.test.js

Hey! Tired of infinite re-renders when using useEffect?

I've started teaching a free mini-course on useEffect via email. I'd love to hear what you think!

You'll learn how to fetch data with useEffect, how to use the dependency array, even how to prevent infinite re-renders with useCallback.

    Join 1,508 React developers that have signed up so far!