Skip to Content
React in a Week
Day 4 · State & escape hatches

Reusing logic with custom hooks

Extract stateful logic into your own use-functions, and build a useLocalStorage hook that makes the Reading List persist.

React comes with built-in hooks like useState, useEffect and useRef. Sometimes you wish there were one for a more specific job: "is the user online?", "store this in localStorage", "search books as I type". You can make those yourself.

A custom hook is a JavaScript function whose name starts with use and which calls other hooks. It lets you pull a chunk of stateful logic out of a component, give it a clear name, and reuse it anywhere.

Spotting repeated logic

Imagine two components that both need to know whether the browser is online. Each one would need the same state and the same Effect subscribing to the browser's online and offline events. Instead of copying it, move it into a function:

Live code· useOnlineStatus

Both components read like plain English now: "use the online status". The details of how it works are tucked away in one place. If you later find a better way to detect the network, you change one file.

The rules of hooks

Custom hooks follow the same rules as built-in ones:

  1. Names start with use followed by a capital letter, like useOnlineStatus or useLocalStorage. That is how React and the linter know the function may call hooks.
  2. Call hooks only at the top level of a component or another hook. Never inside conditions, loops or nested functions. React relies on hooks being called in the same order on every render.
  3. Call hooks only from React functions: components or other custom hooks, not regular functions.

Hooks share logic, not state

This is the most important idea in the lesson. Each call to a custom hook is completely independent. When StatusBar and SaveButton both call useOnlineStatus(), each gets its own isOnline state. They agree only because they watch the same browser events.

Let's prove it with the hook you'll use in your project. useLocalStorage works like useState, but it also saves the value in the browser's localStorage and reads it back when the page loads.

Live code· useLocalStorage

NameField and GoalField use the same hook but store completely separate values under separate keys. Notice the parts you already know:

  • useState(() => ...) with a function is a lazy initializer. React only calls it on the first render, so you don't read localStorage on every keystroke.
  • localStorage is an external system, so writing to it is a valid use of an Effect, with key and value as dependencies.
  • Returning [value, setValue] means the hook is a drop-in replacement for useState, including updater functions like setValue((v) => v + 1).
Deep diveAvoid lifecycle-style hooks like useMount

When people discover custom hooks, they sometimes write wrappers like useMount(fn) that run fn once. These hide the dependency array from the linter and encourage thinking in "mount and unmount" instead of "what am I syncing with?". Good custom hooks are named after what they do for the component: useOnlineStatus, useLocalStorage, useBookSearch. If you can't think of a specific name, you may not need a custom hook yet.

Quick check

Two components both call useLocalStorage('theme', 'light'). One of them calls its setter with 'dark'. What happens to the other component right away?

Try it

Extract a useBookSearch hook

The SearchSuggestions component from the fetching lesson mixes the search logic with the UI. Extract the logic into useBookSearch(query), which returns { suggestions, status }. The component should become short and readable.

Live code· Challenge: useBookSearch
Show solution

Create useBookSearch.js (or keep it at the top of App.js):

useBookSearch.js
import { useEffect, useState } from 'react';
import { searchBooks } from './api.js';

export function useBookSearch(query) {
  const [suggestions, setSuggestions] = useState([]);
  const [status, setStatus] = useState('idle');

  useEffect(() => {
    if (query.trim().length < 2) {
      setSuggestions([]);
      setStatus('idle');
      return;
    }
    let ignore = false;
    setStatus('loading');
    searchBooks(query)
      .then((books) => {
        if (!ignore) {
          setSuggestions(books);
          setStatus('done');
        }
      })
      .catch(() => {
        if (!ignore) setStatus('error');
      });
    return () => {
      ignore = true;
    };
  }, [query]);

  return { suggestions, status };
}

The component now only deals with the input and the display:

App.js
import { useState } from 'react';
import { useBookSearch } from './useBookSearch.js';

export default function SearchSuggestions() {
  const [query, setQuery] = useState('');
  const { suggestions, status } = useBookSearch(query);

  return (
    <>
      <input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Find a book" />
      {status === 'loading' && <p>Searching...</p>}
      {status === 'error' && <p role="alert">Could not load suggestions.</p>}
      <ul>
        {suggestions.map((b) => (
          <li key={b.id}>
            {b.title} by {b.author}
          </li>
        ))}
      </ul>
    </>
  );
}

query stays in the component because the input needs it. The hook receives it as an argument and re-runs its Effect whenever it changes, exactly as before. On Day 5, useBookSearch will be replaced by TanStack Query's useQuery, which has the same shape: pass in what you want, get back data and a status.

Project: persist the Reading List

Right now, refreshing the page throws away every book you added. Let's fix that with useLocalStorage.

There is one wrinkle: your books are managed by booksReducer through useReducer, and useLocalStorage behaves like useState. The neat trick is that a reducer is a plain function. You can apply it with any state setter, by passing an updater function:

App.js
const [books, setBooks] = useLocalStorage('reading-list:books', initialBooks);

function dispatch(action) {
  setBooks((currentBooks) => booksReducer(currentBooks, action));
}

This is very close to what useReducer does internally. Every dispatch({ type: 'added', book }) call in your components keeps working unchanged, and every change is saved to localStorage.

Live code· Reading List: persisted books

The reducer stayed pure and untouched. The hook handles persistence. Each piece does one job, which is exactly what you want as the app grows. On Day 5 you will move this state into a context provider so any page can reach it.

Quick check

Which function is a valid custom hook?

Day 4 progress

0/10

On this page