Glossy 3D restaurant order ticket on a chrome kitchen rail with red and cyan neon lighting and the words Designing Good Custom React Hooks.

Designing Good Custom React Hooks

Part 2 of 10 in React Lunch & Learn

When I first started writing React, my components were swamps. Data fetching, loading flags, error handling, and the actual JSX—all crammed into one file, and every new feature made the swamp deeper. What pulled me out wasn’t a library. It was a habit: stop writing logic inside components and start designing custom hooks.

TL;DR: design the hook’s call site before its body, translate vendor data into your own types at the boundary, cancel async work in the cleanup function, and remember that hooks share logic, not state. Do those four things and your components shrink down to sentences. The rest of this post is the how, with a real app to build along the way.

This is part of the React Lunch & Learn series. If you haven’t read the render cycle post yet, skim it first—we lean on useEffect and dependency arrays here.

A custom hook is just a function whose name starts with use and that can call other hooks. That’s it. React’s docs say it’s a convention, not a feature.

As the React team puts it, when you extract logic into a hook, “the code of your components expresses your intent, not the implementation.” That sentence is the whole philosophy: your component should read like a sentence about what it wants, and the how lives in the hook.

We’re going to build a small movie-browsing app against The Movie Database (TMDB) API and extract its real logic into well-designed hooks. By the end you’ll have a repeatable method for designing hooks that are pleasant to reuse and don’t rot.

Design the API before you write the hook

Before I touch the inside of a hook, I jot down its signature first. Sometimes that’s a comment, sometimes it’s a pretend call site that isn’t real yet.

For our movie list, I start by asking: what is this hook supposed to do, exactly? Fetch the popular movies. So I picture myself calling it, like this:

const movies = useMovies(import.meta.env.VITE_TMDB_TOKEN);

movies isn’t real yet. useMovies isn’t real yet either. That’s the point. I’m sketching out the API I wish existed. Would I actually want to use this? Yep. I hand it a token, it hands me back movies. One job, clear call site. Now I can go make it real.

It’s a lot like ordering food. You don’t march into the kitchen and start listing off ingredients. You just say, “burger, medium,” and let the kitchen figure out the rest. The call site is your order. The hook’s body is the kitchen. If you write the menu first, the cooking has a clear goal.

This is hands-down the most useful habit I’ve picked up. If you design the outside first—name, parameters, what comes back—the inside almost fills itself in, because you already know what “done” means. Before I started doing this, I used to forget things the call site needed all the time. I’d jump back and forth, patching the hook, then the call site, then the hook again. It got old fast. You may still forget things, but the back and forth are significantly reduced.

If you work menu-first, you lay out everything the call site needs right away, then fill it all in at once when you build the hook. One trip to the kitchen, not five. That’s how I like to work.

Our first hook: useMovies

By convention, hooks live in a hooks/ folder and their filename matches the hook name. A hook that returns data (not JSX) is a .ts file:

hooks/useMovies.ts
import { useEffect, useState } from "react";
import type { Movie } from "../types";
/**
* Fetches the currently-popular movies from The Movie Database.
*
* @param authToken - The TMDB read-access token to authenticate with.
* @returns The list of movies adapted to our `Movie` domain type.
*/
export function useMovies(authToken: string): Movie[] {
const [movies, setMovies] = useState<Movie[]>([]);
// ...effect goes here
return movies;
}

Here are a couple of small habits that save you a headache later. First, document the hook. I’ve lost track of how many times I’ve wandered into a hook I didn’t write, only to get lost in a jungle of options and variables. It’s like opening a toolbox where every tool is unlabeled and half of them are mystery gadgets. A single line of JSDoc is enough to save the next person (probably future you) from having to play code archaeologist.

Second, shape your types to fit your needs, not the vendor’s. See how movies is typed as Movie[]—that’s our Movie, trimmed down to just the fields we actually use: id, title, posterPath, and a few others. Not the sprawling, everything-and-the-kitchen-sink response from TMDB. We’ll set up that translation in a second, but here’s why it matters: it gives us a buffer zone. If the API changes, we only have to update the translation layer, not chase changes through the whole codebase. That’s the difference between a quick patch and a time lost to refactoring.

Effects, dependency arrays, and what actually triggers them

That // ...effect goes here comment is where we could put an effect to actually fetch data. To fetch on mount, we reach for useEffect—but it’s worth noting how it fires first, because this trips people up pretty often since hooks were introduced in React 16.8.0 in 2019.

By default, an Effect runs after every render.

useEffect(() => {
// do something
})

If you leave out the second argument to useEffect, it runs every single time your component renders. The React docs call this ‘probably undesirable,’ which is their polite way of saying ‘don’t do this.’ Whenever you think you need it, the docs nudge you toward something else: list your dependencies, do the calculation during render, set up a ref once, or use an Effect Event. There isn’t a normal situation where react.dev recommends this pattern. The only example they give is if you want to count how often your component renders and send that number to an external API. Even then, that’s a pretty rare thing to need.

That constant looping is the opposite of what you want if you’re just trying to fetch data once. To avoid that, you pass a dependency array. According to the React docs, an empty dependency array ([]) means the Effect only runs when the component first appears on the screen—think of it as the component’s grand entrance. If you put something in the array, the Effect will run again whenever that value changes. changes.

Here’s the footgun for most developers: the dependency array only reacts to reactive values—props, state, and values derived from them. A plain module-level const will never trigger a re-run, because React has no way to know it changed. If your Effect “won’t fire,” the first thing to check is whether everything it depends on is actually reactive. (This applies to useCallback and useMemo too—same dependency-array rules.)

Cleaning up async work with AbortController

Now for the fetch itself. This is where most people run into the classic async bug in React: trying to set state on a component that’s already been removed from the screen.

Here’s how it happens. The user opens your movie list, and the fetch kicks off. Before the data comes back, they click away to another page. The response finally arrives, and your .then() tries to call setMovies(…), but the component has already left the building. It’s like a delivery driver ringing the doorbell at a house where nobody lives anymore. This causes a memory leak, and React used to shout about it: you tried to update state on a component that’s no longer around.

The fix is to call ahead and cancel the order when the component unmounts. The safest tool for this is the browser-native AbortController. You create one, hand its signal to fetch, and abort it in the Effect’s cleanup function:

useEffect(() => {
const controller = new AbortController();
async function fetchMovies() {
const url = new URL("https://api.themoviedb.org/3/discover/movie");
url.searchParams.set("include_adult", "false");
url.searchParams.set("sort_by", "popularity.desc");
url.searchParams.set("page", "1");
// Use the browser's language so posters/titles come back localized.
url.searchParams.set("language", navigator.language);
const response = await fetch(url, {
signal: controller.signal,
headers: {
accept: "application/json",
Authorization: `Bearer ${authToken}`,
},
});
const data: TmdbMoviePage = await response.json();
setMovies(data.results.map(adaptMovie));
}
fetchMovies().catch((error) => {
// An aborted request rejects with an AbortError—that's expected, ignore it.
if (error.name !== "AbortError") throw error;
});
return function cleanUpUseMovies() {
controller.abort();
};
}, [authToken]);

First, I name the cleanup function cleanUpUseMovies instead of leaving it anonymous. When a bug surfaces and you’re staring at a stack trace, “anonymous” tells you nothing; an explicit name tells you exactly which hook the cleanup came from. Name things for future you—who, in my experience, remembers nothing—so debugging is easier.

Second, notice navigator.language in the query string. TMDB localizes its responses, so if a French-speaking user has their browser set to French, their posters and titles come back in French for free. That’s the kind of thing you get nearly for nothing when you wire it in at the data layer.

A note on Strict Mode. In development, React intentionally mounts, unmounts, and remounts every component once—so you’ll see your fetch fire twice and the request abort in between. That’s not a bug; it’s React verifying that your cleanup actually works. In production it runs once. (See the render cycle post for the deep dive.)

Adapting the API at the boundary

The fetch is done, but the keen-eyed among you probably noticed this in the Effect: data.results.map(adaptMovie). Let’s talk about that adaptMovie function, because it’s the translation layer we talked about earlier.

TMDB returns a big object per movie—adult, backdrop_path, genre_ids, and a couple dozen more. We don’t want that shape spreading through our whole codebase. Think of the adapter as a customs checkpoint: everything entering the country gets inspected and repackaged once, at the border, and the interior never has to know what foreign packaging looks like. We translate TMDB’s shape into our Movie type, once, right here:

function adaptMovie(raw: TmdbMovie): Movie {
return {
id: raw.id,
title: raw.title,
posterPath: raw.poster_path,
// A throwaway "want to watch" score so the UI has something to show.
wantToWatch: Math.floor(Math.random() * 3) + 1,
} satisfies Movie;
}

I use satisfies Movie rather than as Movie deliberately. satisfies checks that the object conforms to Movie without widening or coercing its type—if I drop a required field, TypeScript complains. as would silence any mistakes and force the type through. Prefer the one that catches your mistakes, after all, that’s why we use TypeScript, right?

Why bother adapting at all? Because the if TMDB API renames a field—and external APIs always eventually do—I want exactly one file to change. If I’d let their raw shape leak into fifteen components, that rename becomes a fifteen-file scavenger hunt. Centralize the translation at the border, and a breaking vendor change becomes a one-line diff and a merge request.

Why you can’t call hooks conditionally

Before we jump into building a second hook, let me pause for a second. This next bit is a classic stumbling block for anyone new to React hooks.

You can’t call hooks conditionally. No sneaking useState inside an if, and definitely no hooks after an early return. The official Rules of Hooks say you always call them at the top level. Why? It’s not a style thing, it’s mechanical. React keeps track of each hook by the order you call them, not by name. Imagine React as a valet at a busy restaurant. It doesn’t care who you are, just which ticket you hand over. The first useState gets ticket 0, the second gets ticket 1, and so on. If one render checks in three cars and the next checks in five because an if statement changed, ticket 2 now points to someone else’s car. This is how bugs sneak in.

function useMovies(authToken: string) {
// ✅ top level, every render, same order
const [movies, setMovies] = useState<Movie[]>([]);
if (movies.length === 0) {
const [error] = useState(null); // 🔴 breaks the call-order contract
}
// ...
}

It feels strange that React keys state on call order rather than on a name. But once you know it does, every “you violated the Rules of Hooks” error starts to make sense.

One more thing about that setMovies(...) call: React doesn’t necessarily re-render the moment you fire it. As of React 18, it batches multiple state updates into a single re-render—even updates that happen inside promises, timeouts, and native event handlers. Before 18, updates outside React’s own event handlers weren’t batched, so you’d get a render per setState. The newer behavior is strictly better for performance, and it’s free—you don’t have to do anything to get it.

A second hook: useImageUrl (and “should this even be a hook?”)

Detour over—back to building. Our adapted movies carry a posterPath like /abc123.jpg—a path fragment, not a full URL. We need to build the real image URL from a base URL plus the path. It’s reasonable to ask: does this need to be a hook at all? It’s pure string assembly. A plain utility function would do.

And that’s the right instinct. If it’s pure, start with a utility:

export function buildImageUrl(
secureBaseUrl: URL,
imagePath: string | null,
width = 500,
): string {
let imagePart = "";
// Strip TMDB's leading slash so it doesn't clobber our path segments.
if (imagePath?.startsWith("/")) {
imagePart = imagePath.slice(1);
}
const url = new URL(secureBaseUrl);
// Preserve the existing base path, then append size + image segments.
url.pathname = `${url.pathname}/w${width}/${imagePart}`;
return url.toString();
}

I reach for a hook version here for one forward-looking reason: I know that base URL is going to come from somewhere stateful soon. TMDB exposes a configuration endpoint with the real image base URL, and I’m going to cache it in global state rather than hard-code it. The day that base URL becomes a useSelector(...) or a context read, the utility can no longer supply it—but a hook can:

hooks/useImageUrl.ts
export function useImageUrl() {
// Later: read the secure base URL from global state instead of a constant.
// No trailing slash—buildImageUrl adds its own separators.
const secureBaseUrl = new URL("https://image.tmdb.org/t/p");
return (imagePath: string | null, width = 500) =>
buildImageUrl(secureBaseUrl, imagePath, width);
}

So the rule of thumb: pure logic → utility function; logic that needs React state or context → hook. When you know a utility is about to grow a stateful dependency, it’s fine to skip ahead and make it a hook. That’s a developer’s preference informed by where the code is headed, not dogma.

Reuse without sharing: useNavigation

Our third hook makes a point the first two only hinted at. For a tiny app you don’t need a router—a useNavigation hook over the History API (pushState, popstate) is enough to update the URL and read the current path:

hooks/useNavigation.ts
import { useEffect, useState } from "react";
export function useNavigation() {
// Each component that calls this hook gets its own copy of this state.
const [search, setSearch] = useState(window.location.search);
useEffect(() => {
// Keep our copy current when the user hits the back/forward buttons.
function handlePopState() {
setSearch(window.location.search);
}
window.addEventListener("popstate", handlePopState);
return function cleanUpUseNavigation() {
window.removeEventListener("popstate", handlePopState);
};
}, []);
const navigate = (movieId: number) => {
const params = new URLSearchParams({ movie_id: String(movieId) });
window.history.pushState({}, "", `?${params}`);
setSearch(window.location.search);
};
// URLSearchParams parses the query string so we don't hand-roll substring math.
const currentMovieId = new URLSearchParams(search).get("movie_id");
return { navigate, currentMovieId };
}

Here’s a gotcha that trips people up: when you use pushState, the URL changes, but React is blissfully unaware. Since no state actually changed, React just sits there, and your movie detail never shows up. To fix this, the hook keeps its own copy of the query string in useState. Whenever you navigate, it updates that copy, and when the user hits the back button, a popstate listener steps in to keep things in sync.

Back in the day, grabbing a query parameter meant hacking off the question mark, splitting on ampersands, splitting again on equals signs, and hoping you didn’t miss some weird edge case. Now the browser provides URLSearchParams, which does all that heavy lifting for you!

But here’s the real trick this hook pulls off. I can call useNavigation() from any component, anywhere in my app, and each one gets its own private stash of search state. Nobody’s fighting over the same box of cookies. This is what the React docs are getting at: custom hooks let you share the instructions for making cookies, but not the cookies themselves. Each call to a hook is its own batch.

This difference trips people up all the time, so let’s make it stick: a custom hook is a recipe, not a finished cake. Give the same recipe to two components, and each one bakes its own cake—no one’s splitting slices. If you pull useState into a hook and call it from two places, you get two separate pieces of state, not one shared one. If you actually want to share the cake, that’s where context or a state management library comes in. That’s the next stop in this series.

The payoff: smart hooks, dumb components

Step back and look at what happened to our components. App.tsx no longer fetches anything, parses anything, or assembles any URLs. It reads almost like prose:

function App() {
const movies = useMovies(import.meta.env.VITE_TMDB_TOKEN);
const { navigate, currentMovieId } = useNavigation();
return currentMovieId ? (
<MovieDetail movieId={Number(currentMovieId)} />
) : (
<MovieList movies={movies} onSelect={navigate} />
);
}

MovieList is dumb: it takes movies and an onSelect callback, renders cards, and shouts up the tree when one is clicked. It holds no business logic. All the gnarly bits—fetching, cancellation, adaptation, URL building, routing—live in hooks, where they’re testable in isolation and reusable across the app.

This is the architecture I keep coming back to: smart hooks, dumb components. Props flow down, callbacks flow up, utilities stay pure, and nothing mutates in place. Components describe what the screen should look like; hooks own how the data gets there. When your components read like intent and your hooks own the implementation, the swamp drains on its own.

Where we go next

You’ll have noticed I kept passing that TMDB token down through component after component, and twice now I’ve muttered that it “could be a candidate for global state.” That’s the seam where this series turns. Next time we pull the token, the image-config base URL, and other app-wide concerns out of prop-drilling and into proper global state management—and we’ll weigh useReducer against Redux Toolkit for the job.

Design the API first. Adapt at the boundary. Clean up your async. Keep your components dumb and your hooks smart. Do that, and custom hooks stop being a syntax trick and start being the backbone of how your app is built.

Related in this series: