AbortController is how fetch learned to clean up after itself
Fetching data in a React component looks harmless at first.
You render a component, call an API in an effect, store the result in state, and move on with your day. It feels too small to deserve much ceremony.
Then the user navigates away before the request finishes.
Or they type quickly and your component starts fetching search results for r, re, rea, and react at almost the same time.
Or a slow response arrives after a newer one and quietly replaces the data with something stale.
The fetch worked. The component moved on. Those two facts do not always happen in the same order.
That is where AbortController earns its keep. It gives browser APIs like fetch() a cancellation signal, and React's useEffect gives us exactly one place to clean it up.
The fit is small enough that it is easy to miss.
The request that outlives the component
Start with the version most of us write first:
jsximport { useEffect, useState } from 'react'function UserProfile({ userId }) {const [user, setUser] = useState(null)useEffect(() => {fetch(`/api/users/${userId}`).then((response) => response.json()).then((data) => setUser(data))}, [userId])if (!user) {return <p>Loading...</p>}return <h1>{user.name}</h1>}
There is nothing exotic here. The component receives a userId, fetches the matching user, and renders the name.
The fast path is fine. The awkward bit starts when the component stops caring before the network does.
Maybe the user clicks back. Maybe userId changes. Maybe the request hangs for a few seconds on a train connection and resolves after a completely different screen has rendered.
React effects already have a cleanup mechanism for exactly this kind of thing. If an effect returns a function, React runs that function before the effect runs again and when the component unmounts.
Most of the time we use that for event listeners or timers. A network request deserves the same treatment.
Give fetch a signal
AbortController has two moving parts:
- the controller, which can cancel the work
- the signal, which gets passed to the thing doing the work
With fetch, that signal goes in the options object:
jsxuseEffect(() => {const controller = new AbortController()fetch(`/api/users/${userId}`, {signal: controller.signal,}).then((response) => response.json()).then((data) => setUser(data))return () => {controller.abort()}}, [userId])
Now every render that starts a request also knows how to stop it.
When userId changes, React runs the cleanup for the previous effect. When the component unmounts, same thing.
There is one rough edge: aborting a fetch rejects the promise. If we do not handle that rejection, the console gets noisy.
So we catch the abort and ignore that specific error:
jsxuseEffect(() => {const controller = new AbortController()fetch(`/api/users/${userId}`, {signal: controller.signal,}).then((response) => response.json()).then((data) => setUser(data)).catch((error) => {if (error.name === 'AbortError') {return}throw error})return () => {controller.abort()}}, [userId])
The useful shape is the same every time: create the controller inside the effect, pass its signal to fetch, abort it in the cleanup.
The hook is the point
If you only need this once, the effect above is fine. Ship it.
The second copy is where the noise starts to show:
- loading state
- response state
- error state
- abort handling
- dependency changes
- cleanup
That is a lot of plumbing to leave scattered through components.
React hooks are good at this kind of extraction. Every three lines of code do not deserve a custom hook, but repeated lifecycle code has a way of growing little differences in every file.
Let's move the fetch lifecycle into useAbortableFetch:
jsximport { useEffect, useState } from 'react'function useAbortableFetch(url) {const [data, setData] = useState(null)const [error, setError] = useState(null)const [loading, setLoading] = useState(false)useEffect(() => {const controller = new AbortController()setLoading(true)setError(null)fetch(url, { signal: controller.signal }).then((response) => {if (!response.ok) {throw new Error(`Request failed with ${response.status}`)}return response.json()}).then((json) => {setData(json)}).catch((error) => {if (error.name === 'AbortError') {return}setError(error)}).finally(() => {if (!controller.signal.aborted) {setLoading(false)}})return () => {controller.abort()}}, [url])return { data, error, loading }}
Then the component gets boring again:
jsxfunction UserProfile({ userId }) {const { data: user, error, loading } = useAbortableFetch(`/api/users/${userId}`)if (loading) {return <p>Loading...</p>}if (error) {return <p>Could not load the user.</p>}if (!user) {return null}return <h1>{user.name}</h1>}
That trade works well.
The hook owns the awkward lifecycle. The component describes the screen.
It also gives the cleanup behavior a name. useAbortableFetch tells the next developer that cancellation is part of the contract. They do not have to inspect a chain of promises to discover whether the request cleans up after itself.
What happens when the URL changes quickly?
The hook gets more useful when the dependency changes often.
Think about search:
jsxfunction UserSearch({ query }) {const { data: users, loading } = useAbortableFetch(`/api/users?query=${encodeURIComponent(query)}`)if (loading) {return <p>Searching...</p>}return (<ul>{users?.map((user) => (<li key={user.id}>{user.name}</li>))}</ul>)}
Every time query changes, the old request gets aborted before the new request starts.
That does not make the server do less work in every possible situation. The request may already have reached it.
It does stop the browser from continuing to care about a response the UI no longer needs.
This pairs nicely with debouncing. I wrote separately about debouncing a React input without adding a dependency, and the two ideas solve different halves of the same problem:
- debounce before starting work you probably do not need
- abort work that has already become irrelevant
They are not replacements for each other. They solve different timing problems.
A small note about stale state
AbortController cancels a request, but it does not magically solve every stale state problem.
If your effect does more work after the fetch, or if some other async function does not support abort signals, you still need to think about what happens when old work finishes late. Sometimes the right answer is a local ignore flag in the effect cleanup. The React docs show that pattern in their useEffect examples for data fetching.
For browser fetches, though, aborting is usually nicer than pretending the result did not arrive. We are telling the underlying operation that we are done with it instead of merely guarding the setState.
The contract is easier to reason about.
Why this belongs in a hook
This is the part I care about more than the API itself.
The browser gives us AbortController. React gives us effects and cleanup. The useful application code is the small hook that makes those two ideas meet in one place.
That is the same reason I like tiny hooks such as usePrevious. A ref and an effect are not complicated by themselves, but the hook turns the pattern into a reusable sentence: "give me the previous value."
This one says: "fetch this, and stop caring when the component does."
That beats copy-pasting another effect and hoping every cleanup branch survives the next refactor.
When I reach for it
I do not wrap every request in a custom hook. Sometimes a framework, router, or data library already owns the lifecycle, cache, retries, and invalidation. In that case, use the tool that is already solving the bigger problem.
But when a component does its own browser fetch, especially in smaller React codebases, AbortController is a good default.
Use it when:
- the component can unmount before the request finishes
- the request depends on props or state that change often
- a search, filter, or tab switch can make old results irrelevant
- you want loading and error state to be handled consistently
The hook is small enough to understand and boring enough to trust. That is exactly where I want this kind of code to live.