Fetching is an effect, and it needs three things almost everyone forgets first time: a loading state, an error state, and a way to ignore a response that arrived too late.
The full shape
A request has three outcomes, and the UI has to say something for each. Rendering nothing while loading and crashing on failure is the default if you only handle success.
functionUser({ id }){const[status, setStatus]= React.useState("loading")const[user, setUser]= React.useState(null)const[error, setError]= React.useState(null)
React.useEffect(()=>{const controller =newAbortController()setStatus("loading")fetch(`https://jsonplaceholder.typicode.com/users/${id}`,{signal: controller.signal,}).then((response)=>{// fetch only rejects on network failure — a 404 still "succeeds".if(!response.ok)thrownewError(`HTTP ${response.status}`)return response.json()}).then((data)=>{setUser(data)setStatus("done")}).catch((err)=>{if(err.name ==="AbortError")return// we cancelled it on purposesetError(err.message)setStatus("error")})return()=> controller.abort()},[id])if(status ==="loading")return<p>Loading…</p>if(status ==="error")return<pstyle={{color:"#b91c1c"}}>Failed: {error}</p>return(<div><strong>{user.name}</strong><pstyle={{margin:0,color:"#475569"}}>{user.email}</p></div>)}functionDemo(){const[id, setId]= React.useState(1)return(<div><buttononClick={()=>setId((n)=>(n %3)+1)}>Next user (id {id})</button><divstyle={{marginTop:10}}><Userid={id}/></div></div>)}const root = ReactDOM.createRoot(document.getElementById("root"))
root.render(<Demo/>)
fetch does not reject on 404
This surprises everyone once. fetch rejects only when the request could not be made at all - no network, DNS failure, CORS. A 404 or a 500 is a successful request that returned an error status.
So response.ok has to be checked by hand. Without it, a 500 page gets parsed as JSON and the failure surfaces later as a confusing render error rather than as "the server said no".
Cancelling with AbortController
The cleanup calls controller.abort(), which cancels the request outright. That is better than the ignore flag from the cleanup chapter - the browser stops waiting for a response nobody wants.
An aborted request rejects with an AbortError, which is why the catch checks for it first. Without that check, every cancelled request would show the user an error that never actually happened.
Async functions and effects
An effect may not be async. React expects its return value to be a cleanup function, and an async function returns a promise - so React would try to call a promise as cleanup.
Not allowed
jsx
// Wrong: the effect returns a promise, not a cleanup function.useEffect(async()=>{const data =awaitload()setData(data)},[])
Either use .then as above, or declare an async function inside the effect and call it. Both are fine; the constraint is only on the effect itself.
When to stop hand-rolling this
Everything above is one request in one component. Real apps also want caching, retries, deduplicated requests, refetching when a window regains focus, and shared data between components - and each of those is harder than it sounds.
Always handle loading, error and success.
Check response.ok; fetch will not do it for you.
Abort in the cleanup, and ignore AbortError.
The effect itself cannot be async.
Once you need caching or sharing, use a data library rather than growing this.