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
useEffectand 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:
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:
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:
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: