Load data from a server when a component appears. Learn promises and await, loading and error states, a Retry button, the race fix, and why Strict Mode asks twice.
Most apps show data that lives on a server: users, messages, prices. A server is another computer that keeps the data. Your app sends it a request, a message that asks for something. Some time later, the server sends back a response, its answer. Getting data like this is called fetching it.
In Part 11 you learned that an Effect runs after React updates the page. In Part 12 you learned to clean up an Effect, and to ignore an old answer with an ignore flag. This part puts them together to load data from a server.
We’ll start with a short look at promises, since every request uses them. Then we’ll build a page that shows a loading message, an error with a Retry button, or the data. Retry means “try again”. We’ll fix what goes wrong when the user moves on before the answer comes. And we’ll see when you should not use an Effect at all.
Try this first
The examples in this part can’t use the real internet. So each one has a small fake server. It is a function that waits a bit, as a real server would, and then answers. You’ll see how real code looks later in this part. It has the same shape.
Read this code. Don’t press Run yet.
import { useEffect, useState } from 'react'
type User = { id: number; name: string }
// A fake server. It waits 800 ms, then answers with a user.
function fetchUser(id: number): Promise<User> {
console.log('Server: someone asked for user', id)
return new Promise(resolve => {
setTimeout(() => resolve({ id, name: 'Ana' }), 800)
})
}
export default function App() {
const [user, setUser] = useState<User | null>(null)
useEffect(() => {
let ignore = false
fetchUser(1).then(result => {
if (!ignore) {
setUser(result)
}
})
return () => {
ignore = true
}
}, [])
if (user === null) {
return <p>Loading...</p>
}
return <p>Hello, {user.name}!</p>
}
ms means milliseconds. There are 1,000 of them in one second.
Make two guesses. What does the page show first? And how many times will the Console say that someone asked the server for user 1?
Now press Run, and watch the Console too. It shows:
Server: someone asked for user 1
Server: someone asked for user 1
The page shows “Loading…” first. A moment later, it shows “Hello, Ana!”. And the server was asked twice. That is Strict Mode, and it’s not a bug in this code. We’ll see why later in this part. First, let’s understand the pieces.
Promises, in plain words
A request takes time. JavaScript doesn’t stop and wait for it, because then the whole page would freeze. Instead, fetchUser(1) gives back a promise right away.
A promise is an object that says: “the value will come later”. It is like a ticket you get at a busy shop counter. You don’t have the food yet. But the ticket says you will get it, and you can go on with other things.
A promise ends in one of two ways:
- It is fulfilled: the value came. In the fake server, calling
resolve(...)fulfils it. Many people say “resolved” for this too. - It is rejected: something went wrong, and you get an error instead.
.then(result => ...) means: “when the value comes, run this function with it”. Run this example and click the button:
type User = { id: number; name: string }
// A fake server. It waits 800 ms, then answers.
function fetchUser(id: number): Promise<User> {
return new Promise(resolve => {
setTimeout(() => resolve({ id, name: 'Ana' }), 800)
})
}
export default function App() {
function handleClick() {
console.log('1. Ask the server for user 1')
fetchUser(1).then(user => {
console.log('3. The answer came:', user.name)
})
console.log('2. The code goes on. It does not wait.')
}
return <button onClick={handleClick}>Ask</button>
}
1. Ask the server for user 1
2. The code goes on. It does not wait.
3. The answer came: Ana
Line 2 prints before line 3, even though line 3 comes first in the code. The function passed to .then runs later, when the answer arrives. These lines are not doubled, because React never calls a click handler twice.
async and await
There is a second way to write the same thing. It reads more like normal code, from top to bottom.
type User = { id: number; name: string }
// A fake server. It knows only user 1. For any other id, it fails.
function fetchUser(id: number): Promise<User> {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (id === 1) {
resolve({ id, name: 'Ana' })
} else {
reject(new Error('No user with id ' + id))
}
}, 800)
})
}
async function showUser(id: number) {
try {
const user = await fetchUser(id)
console.log('Got', user.name)
} catch (error) {
console.log('It failed:', String(error))
}
}
export default function App() {
return (
<div>
<button onClick={() => showUser(1)}>User 1</button>
<button onClick={() => showUser(2)}>User 2</button>
</div>
)
}
Got Ana
It failed: Error: No user with id 2
Run it. Click “User 1”, wait a second, then click “User 2”.
asyncbefore a function lets you useawaitinside it.await fetchUser(id)waits for the promise and gives you its value. Only this function waits. The rest of the page keeps working.- If the promise is rejected, the code jumps to
catch.errorholds what went wrong.String(error)turns it into text. - An
asyncfunction always gives back a promise itself.
try and catch are plain JavaScript. Code inside try runs. If anything in it fails, the code in catch runs instead.
That’s all the promise knowledge this part needs.
Three things a request can be
While a request is out, your page has nothing to show yet. When it comes back, it brings data or an error. So the page must show one of three things:
- Loading: we asked, and we are waiting.
- Error: it failed. Say so, and let the user try again.
- Data: it worked. Show it.
You could keep three pieces of state: isLoading, error and user. But then nothing stops a bug from setting isLoading to true while error also holds an error. The page would not know what to show.
In Part 7 we used one status for a choice like this: type Status = 'idle' | 'loading' | 'done' | 'error'. Here we go one step further. Each status carries only the data that belongs to it:
type User = { id: number; name: string }
type Request =
| { status: 'loading' }
| { status: 'error'; message: string }
| { status: 'done'; user: User }
Read the | as “or”. A type made of choices like this is called a union. A request is loading, or it is an error with a message, or it is done with a user. It can never be two of them at the same time.
TypeScript checks this for you, in an editor like VS Code. (The playground doesn’t check types.) Each choice has a status field with its own fixed text. So after you check request.status === 'done', TypeScript knows which choice you have, and that request.user is there. This is the narrowing you met in Part 7, done on one field.
Here is a full app built on it. It shows one user at a time, with buttons to move between them.
import { useEffect, useState } from 'react'
type User = { id: number; name: string }
type Request =
| { status: 'loading' }
| { status: 'error'; message: string }
| { status: 'done'; user: User }
const users: User[] = [
{ id: 1, name: 'Ana' },
{ id: 2, name: 'Ben' },
{ id: 3, name: 'Cara' },
]
// A fake server. It knows users 1, 2 and 3.
// User 2 is slow: 2000 ms. The others take 500 ms.
function fetchUser(id: number): Promise<User> {
const wait = id === 2 ? 2000 : 500
return new Promise((resolve, reject) => {
setTimeout(() => {
const user = users.find(u => u.id === id)
if (user) {
resolve(user)
} else {
reject(new Error('No user with id ' + id))
}
}, wait)
})
}
export default function App() {
const [userId, setUserId] = useState(1)
const [request, setRequest] = useState<Request>({ status: 'loading' })
useEffect(() => {
let ignore = false
async function load() {
try {
const user = await fetchUser(userId)
if (!ignore) {
setRequest({ status: 'done', user })
}
} catch (error) {
if (!ignore) {
setRequest({ status: 'error', message: String(error) })
}
}
}
load()
return () => {
ignore = true
}
}, [userId])
function goTo(id: number) {
setUserId(id)
setRequest({ status: 'loading' })
}
return (
<div>
<button onClick={() => goTo(userId - 1)}>Previous</button>
<button onClick={() => goTo(userId + 1)}>Next</button>
<h2>User {userId}</h2>
{request.status === 'loading' && <p>Loading...</p>}
{request.status === 'error' && <p>{request.message}</p>}
{request.status === 'done' && <p>Hello, {request.user.name}!</p>}
</div>
)
}
Run it. Press Next a few times. Each user shows “Loading…” first, then a name. User 2 takes longer. The server has no user 4, so on user 4 you get the error: “Error: No user with id 4”.
Let’s walk through the parts of it that are new.
The Effect runs again when the id changes
The Effect reads userId, so userId is in its dependency array: [userId]. As Part 11 showed, React runs the Effect again whenever a value in that array changes. Each time you click Next, userId changes, and the Effect asks the server for the new user.
The async function load lives inside the Effect, and the Effect calls it right away. Why not make the Effect function itself async? That is a common mistake, and we’ll see what goes wrong under Common mistakes.
Who sets “loading”?
The click handler goTo sets two things: the new id, and { status: 'loading' }. Without the second line, the page would show “User 2” with Ana’s name under it, until Ben’s answer came.
You could set “loading” at the top of the Effect instead. We tried it, with Strict Mode off. Then one Next click made App render twice before the answer, not once. The first of those renders had “User 2” with Ana’s name. React’s docs call this rendering “with the stale value”: stale means old. Setting it in the click handler gave one render, with “Loading…” in it.
So set the loading state in the event handler that started the change, when there is one. Sometimes there isn’t. The id may come from a parent as a prop, for example. Then set it at the top of the Effect. React’s own useEffect example does this: it calls setBio(null) first, then asks for the new bio.
Show something while you wait
The Loading... line is important. With no loading state, the page is empty, or shows the old user, until the answer comes. A real request can be slow. The user can’t tell “still loading” from “broken”.
The error line matters too. Without it, a failed request leaves “Loading…” on the page forever.
When the user clicks again before the answer comes
Run the example again. Wait for Ana. Then click Next twice, quickly.
Two requests are now out at the same time. User 2’s request is slow: 2000 ms. User 3’s request is fast: 500 ms. So user 3’s answer comes back first, and user 2’s answer comes back last.
Part 12 called this a race condition. Two requests race, and their answers come back in a different order from the one you asked in.
Without the ignore flag, Ben’s late answer would replace Cara. The page would say “User 3” and “Hello, Ben!”. But the example shows Cara and keeps showing her. The figure shows why.
A request over time. Pick a scenario, then press play, or step through it.
Here is the second scenario in words.
- You click Next.
userIdbecomes 2. The Effect asks for user 2, a slow user. Its ownignoreisfalse. - You click Next again.
userIdbecomes 3. Before the Effect runs again, React runs the old cleanup. It sets user 2’signoretotrue. Then the Effect runs and asks for user 3, with a newignoreoffalse. - User 3’s answer comes first. Its
ignoreisfalse, sosetRequestruns. The page shows “Hello, Cara!”. - User 2’s answer comes last. Its
ignoreistrue, so we skip it. The page still shows Cara.
Each run of the Effect has its own ignore variable. The cleanup changes only the one that belongs to the old request. We checked this with React 19.3: Ben’s answer arrived 1.5 seconds after Cara’s, and setRequest was not called for it.
The figure leaves out one thing. In the playground, Strict Mode also sends an extra request for user 1 when the page first loads. We’ll come to that soon.
An everyday example
You order soup at a restaurant. Then you change your mind and order rice instead. You tell the waiter: “If the soup comes, I don’t want it.” The rice comes first, and you eat it. Later the soup comes, and you send it back.
The note to the waiter is the ignore flag. The cleanup is the moment you change your mind.
The exact version
The soup was still cooked. The ignore flag doesn’t stop the request. The server still does the work, and the answer still arrives. We only refuse to use it.
With the real fetch, you can stop the request too. To abort a request means to stop it before it ends. Pass fetch the signal of an AbortController, and call abort() in the cleanup. Part 12 showed how. React’s docs say the cleanup “should either abort the fetch or ignore its result”. The ignore flag works with any promise, so it’s the one we use here.
If you switch to abort(), keep a check in the catch. An aborted request fails with an error named AbortError, so it lands in your catch, as Part 12 showed. We tried it with a fake server that fails like fetch does. In Strict Mode, the first run’s request was aborted at once. With no check, the page showed AbortError: The request was stopped. until the real answer came. Skip that error in catch: if (controller.signal.aborted) return. With that line, the page showed “Loading…” and then the user.
Errors, and a Retry button
A request can fail: the network drops, or the server is down. When it fails, show an error, and give the user a way to try again.
This fake server starts out broken. The “Fix the server” button plays the part of the people who run the server. Clicking it fixes the server.
import { useEffect, useState } from 'react'
type User = { id: number; name: string }
type Request =
| { status: 'loading' }
| { status: 'error'; message: string }
| { status: 'done'; user: User }
// A fake server that starts out broken.
let serverIsBroken = true
function fetchUser(id: number): Promise<User> {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (serverIsBroken) {
reject(new Error('The server is down'))
} else {
resolve({ id, name: 'Ana' })
}
}, 500)
})
}
export default function App() {
const [request, setRequest] = useState<Request>({ status: 'loading' })
const [attempt, setAttempt] = useState(1)
useEffect(() => {
let ignore = false
async function load() {
try {
const user = await fetchUser(1)
if (!ignore) {
setRequest({ status: 'done', user })
}
} catch (error) {
if (!ignore) {
setRequest({ status: 'error', message: String(error) })
}
}
}
load()
return () => {
ignore = true
}
}, [attempt])
function retry() {
setRequest({ status: 'loading' })
setAttempt(attempt + 1)
}
return (
<div>
<button onClick={() => { serverIsBroken = false }}>Fix the server</button>
{request.status === 'loading' && <p>Loading...</p>}
{request.status === 'error' && (
<p>
{request.message} <button onClick={retry}>Retry</button>
</p>
)}
{request.status === 'done' && <p>Hello, {request.user.name}!</p>}
</div>
)
}
Run it. You see “Loading…”, then “Error: The server is down” and a Retry button. Click Retry: it fails again, because the server is still broken. Now click Fix the server, then Retry. This time you get “Hello, Ana!”.
How does Retry ask again? The Effect doesn’t read attempt. But attempt is in its dependency array, [attempt]. So when retry adds one to attempt, React runs the Effect again, and it sends a new request.
Clicking “Fix the server” changes a normal variable, not state. So the page doesn’t change until you click Retry.
What real fetch code looks like
In a real app, the fake server is replaced by the browser’s fetch function. It sends a request to a web address (a URL) and gives back a promise of the response. Here is a real version of fetchUser:
type User = { id: number; name: string }
async function fetchUser(id: number): Promise<User> {
const res = await fetch(`https://example.com/api/users/${id}`)
if (!res.ok) {
throw new Error('The server answered with status ' + res.status)
}
const user: User = await res.json()
return user
}
This one doesn’t run here, because the playground has no network. But it type-checks, and nothing else in the app changes. The Effect calls it in the same way as the fake one.
There are two steps, and each one needs await:
await fetch(url)waits for the response to start arriving.resis aResponseobject.res.statusis a number like 200 (it worked) or 404 (not found).await res.json()waits for the rest of the response’s body, the data after the status, and reads it as JSON. JSON is a text format for data that looks like JavaScript objects.
TypeScript can’t know what the server will send. res.json() gives back a promise of any, a type that TypeScript doesn’t check. Writing const user: User tells TypeScript to trust you. If the server sends something else, nothing warns you.
The res.ok check is the part people forget. MDN is a set of web guides that many developers use. HTTP status codes are the numbers a server sends with every response, like 200 or 404. MDN says a fetch() promise “does not reject if the server responds with HTTP status codes that indicate errors”. res.ok is true only for a status from 200 to 299.
We tried it on a small test server, with the fetch built into Node.js 24:
| The server’s answer | fetch promise |
res.ok |
res.json() |
|---|---|---|---|
| 200, with a user as JSON | resolved | true |
the user |
| 404, with an HTML page | resolved | false |
throws a SyntaxError |
| 500, with JSON | resolved | false |
the JSON |
| no server at all | rejected, with a TypeError |
These rows come from Node.js, not a browser. A browser can reject in more cases. MDN lists them on its fetch() page, such as a badly written URL or a network error.
Look at the 500 row. With no res.ok check, your app would treat the server’s error message as a user. That is why we throw our own error when res.ok is false. The catch in the Effect then shows it, the same way it shows a network error.
Why Strict Mode asks twice
Back to “Try this first”. The Console said the server was asked for user 1 twice.
In development, Strict Mode runs every Effect, then its cleanup, then the Effect again, when a component first appears. Part 11 showed this. For our fetch, that means:
- The Effect runs and asks for user 1. This request has its own
ignore, set tofalse. - React runs the cleanup right away. That request’s
ignorebecomestrue. - The Effect runs again and asks for user 1 a second time, with a new
ignoreoffalse. - Both answers come back. The first one is ignored. Only the second one sets the state.
We counted with React 19.3. With Strict Mode on, the fake server was asked 2 times, and setUser ran 1 time. With Strict Mode off, it was asked 1 time. Clicking Next in the main example sent one request, not two. The doubling happens only when a component first appears.
React’s docs say this about the two requests: “There is nothing wrong with that.” They also say: “In production, there will only be one request.”
So don’t turn off Strict Mode to hide the second request. The second request is a test. It shows that your cleanup works. Without the ignore flag, we measured setUser running 2 times in Strict Mode. Here both answers were the same, so the page looked fine. In a real app, that may not be true.
Click to send? Fetch in the event handler
Everything so far loads data because a component appeared on the page. That is what Effects are for. But some requests happen because the user did something: clicked “Buy”, or pressed “Send” on a form.
Those requests belong in the event handler, not in an Effect. React’s docs give the rule: “If this logic is caused by a particular interaction, keep it in the event handler.”
Here is a form that sends a message. The async function is the submit handler itself. Part 6 showed onSubmit and e.preventDefault().
import { useState } from 'react'
// A fake server. It takes 800 ms to save a message.
function sendMessage(text: string): Promise<void> {
console.log('Server: got the message', text)
return new Promise(resolve => {
setTimeout(resolve, 800)
})
}
type SendStatus = 'typing' | 'sending' | 'sent' | 'error'
export default function App() {
const [text, setText] = useState('')
const [status, setStatus] = useState<SendStatus>('typing')
async function handleSubmit(e: React.SubmitEvent<HTMLFormElement>) {
e.preventDefault()
setStatus('sending')
try {
await sendMessage(text)
setStatus('sent')
} catch {
setStatus('error')
}
}
if (status === 'sent') {
return <p>Thank you! We got your message.</p>
}
return (
<form onSubmit={handleSubmit}>
<input aria-label="Message" value={text} onChange={e => setText(e.target.value)} />
<button type="submit" disabled={status === 'sending'}>Send</button>
{status === 'sending' && <p>Sending...</p>}
{status === 'error' && <p>Sorry, that did not work. Try again.</p>}
</form>
)
}
Server: got the message Hi
Type “Hi” and click Send. You see “Sending…”, and the button is turned off, so nobody can send twice. Then the thank-you line appears.
The Console shows one line. A click handler runs once per click, so Strict Mode doesn’t double this request.
Some people send the request from an Effect instead. The click sets a submitted state to true, and an Effect watches it. Don’t. The request happens because of the click, so the click handler is where it belongs. By the time an Effect runs, it can’t tell what the user did. Part 11 showed one bug this causes, with a “Buy” button. There, the flag lived in the parent. The shop came back on the page with the flag still true. So the Effect ran again, with no click. A form built that way would send the message again.
The question to ask: why does this code run? Because the component appeared? Use an Effect. Because the user did something? Use the event handler.
React 19 also has form Actions, which handle the “sending” state for you. Part 29 covers them.
What real apps use
Fetching in an Effect works, and it’s the right place to start. But React’s docs list real problems with it:
- Effects don’t run on the server. Some apps build their first HTML on a server. Then that HTML can only show “Loading…”, never the data.
- Waterfalls. A parent loads its data, and only then renders its children. Then the children start loading theirs. React’s docs call these “network waterfalls”. On a slow network, asking for everything at the same time is much faster.
- No saving. Nothing keeps the answer. If the component goes away and comes back, it asks the server again. Keeping answers to use again is called caching.
- Lots of code. Every fetch needs loading, error, the
ignoreflag and a Retry. You saw how much that is.
So the docs suggest two things. If you use a framework, a bigger tool built on top of React, use its own way to load data. If not, use a library that caches answers, such as TanStack Query or SWR.
Two later parts come back to this. Part 16 shows how to move code like this into a custom hook. And React 19 has a new way to wait for data: use() with <Suspense>, which shows something else while the data loads. That is Part 31.
Common mistakes
No loading or error state
import { useEffect, useState } from 'react'
type User = { id: number; name: string }
declare function fetchUser(id: number): Promise<User>
function Profile() {
const [user, setUser] = useState<User | null>(null)
useEffect(() => {
let ignore = false
fetchUser(1).then(result => {
if (!ignore) {
setUser(result)
}
})
return () => {
ignore = true
}
}, [])
return <p>{user?.name}</p>
}
Before the answer comes, this page is empty. If the request fails, it stays empty forever, and nobody knows why. There is also no catch, so a failed request becomes an error nobody handles.
The fix: keep a status with all three cases, as in Request above. Show “Loading…”, the error with a Retry button, or the data.
declare function tells TypeScript that fetchUser exists somewhere else, without writing its code. It keeps the example short.
Making the Effect function async
import { useEffect, useState } from 'react'
type User = { id: number; name: string }
declare function fetchUser(id: number): Promise<User>
function Profile() {
const [user, setUser] = useState<User | null>(null)
useEffect(async () => {
const result = await fetchUser(1)
setUser(result)
}, [])
return <p>{user ? user.name : 'Loading...'}</p>
}
Part 11 showed this one. An async function always gives back a promise. React expects an Effect to give back nothing, or a cleanup function. TypeScript refuses the code. This block has no Run button, but Part 11 has a copy you can run. There, React warns useEffect must not return anything besides a function, which is used for clean-up. Then it stops with destroy is not a function. Strict Mode tried to call the promise as a cleanup.
The fix: put an async function inside the Effect, and call it, as load() does in the main example. Then the Effect still returns its cleanup.
Leaving the id out of the dependency array
In the main example, press Edit and change }, [userId]) to }, []). Run it, wait for Ana, and click Next.
The page says “User 2” and “Loading…” forever. We waited 2.5 seconds in our test, and it never changed. The Effect ran once, for user 1, and never again. The click set “loading”, but nothing asked the server for user 2.
The fix: list every value from the component that the Effect reads, here userId. A linter checks this for you, as Part 11 showed. The Oxlint in a new Vite project (Part 10) warns by default, and so does ESLint with eslint-plugin-react-hooks.
Setting state from an old request
This is the race from earlier, with no ignore flag:
import { useEffect, useState } from 'react'
const names = ['Ana', 'Ben', 'Cara']
// A fake server. User 2 is slow: 2000 ms. The others take 500 ms.
function fetchName(id: number): Promise<string> {
const wait = id === 2 ? 2000 : 500
return new Promise(resolve => {
setTimeout(() => resolve(names[id - 1]), wait)
})
}
export default function App() {
const [userId, setUserId] = useState(1)
const [name, setName] = useState('')
useEffect(() => {
fetchName(userId).then(result => {
setName(result)
})
}, [userId])
return (
<div>
<button onClick={() => setUserId(userId + 1)}>Next</button>
<p>User {userId}: {name}</p>
</div>
)
}
Run it. Wait for Ana, then click Next twice, quickly. You see “User 3: Cara”. Then, about a second and a half later, it changes to “User 3: Ben”. That is the wrong person.
React 19.3 prints no warning for this. We also removed a component in the middle of a request, and let the answer set its state. React printed nothing then either. So you can’t count on a warning. You have to write the cleanup.
The fix: add the ignore flag and the cleanup, as in “Try this first”.
Not checking res.ok
type User = { id: number; name: string }
async function fetchUser(id: number): Promise<User> {
const res = await fetch(`https://example.com/api/users/${id}`)
return await res.json()
}
This type-checks, and it works while the server has no problems. But on a 404 or a 500, fetch still resolves. On an HTML error page, res.json() then throws a strange SyntaxError. On a JSON error, it gives you the server’s error message as if it were a user.
The fix: check res.ok, and throw your own error when it’s false, as in the real fetchUser above.
Practice
Press Edit on any example above and try these.
- In “Try this first”, change
800to3000. What does the page show, and for how long? How many lines does the Console show? - In the main example, the server has no user 0, so Previous on user 1 shows an error. Turn the button off on user 1. You can give a button
disabled={true}ordisabled={false}. - In the “Setting state from an old request” example, add the
ignoreflag and a cleanup. Wait for Ana, click Next twice quickly, and wait three seconds. What does the page show? - In the form example, make the fake server reject an empty message. When
text === '', callreject(new Error('Empty message'))instead ofresolve(). Click Send with an empty box. What do you see?
Answers
- “Loading…” stays for about 3 seconds. Then “Hello, Ana!” appears. The Console still shows 2 lines, because Strict Mode still runs the Effect twice on the first load.
<button disabled={userId === 1} onClick={() => goTo(userId - 1)}>Previous</button>. On user 1 the button is off, and clicking it does nothing. On user 2 it works again.- It shows “User 3: Cara”, and it stays that way. Ben’s late answer is ignored. The full app is below.
- You see “Sending…”, and then “Sorry, that did not work. Try again.” The
awaitthrew, so thecatchinhandleSubmitset the status to'error'. If you then type “Hi” and click Send, the thank-you line appears. The new server is below.
Answer 3, the app with the ignore flag:
import { useEffect, useState } from 'react'
const names = ['Ana', 'Ben', 'Cara']
// A fake server. User 2 is slow: 2000 ms. The others take 500 ms.
function fetchName(id: number): Promise<string> {
const wait = id === 2 ? 2000 : 500
return new Promise(resolve => {
setTimeout(() => resolve(names[id - 1]), wait)
})
}
export default function App() {
const [userId, setUserId] = useState(1)
const [name, setName] = useState('')
useEffect(() => {
let ignore = false
fetchName(userId).then(result => {
if (!ignore) {
setName(result)
}
})
return () => {
ignore = true
}
}, [userId])
return (
<div>
<button onClick={() => setUserId(userId + 1)}>Next</button>
<p>User {userId}: {name}</p>
</div>
)
}
Answer 4, the new fake server:
function sendMessage(text: string): Promise<void> {
console.log('Server: got the message', text)
return new Promise((resolve, reject) => {
setTimeout(() => {
if (text === '') {
reject(new Error('Empty message'))
} else {
resolve()
}
}, 800)
})
}
Interview questions
Try to answer each one out loud before you open the answer.
How do you fetch data when a component appears, in plain React?
In an Effect. Keep the state for loading, error and data. In the Effect, call an async function that waits for the request with await. Set the state when the answer comes. Return a cleanup that sets an ignore flag, or aborts the request. List the values the request depends on, like an id, in the dependency array.
A strong answer adds the problems with this way. Effects don’t run on the server, so HTML built on a server shows only “Loading…”. It causes network waterfalls, there is no caching, and it takes a lot of code. It names what real apps use instead: a framework’s own data loading, or a library like TanStack Query or SWR.
Why can’t the function you pass to useEffect be async?
An async function always returns a promise. React expects an Effect to return nothing, or a cleanup function. TypeScript refuses it. At run time, React prints a warning. When it later tries to run the “cleanup”, it fails with destroy is not a function. In development, Strict Mode runs the cleanup at once, so the app crashes on the first load. Put the async function inside the Effect and call it. A strong answer adds two things. A cleanup returned from inside an async Effect never runs, as Part 12 showed. And the inner function still needs the ignore flag, because await doesn’t stop a late answer.
What is a race condition in data fetching? How do you fix it?
Two requests are out at the same time, for example for user 2 and then user 3. Their answers can come back in any order. If the older one comes last and sets state, the page shows the wrong data. The fix is the cleanup. With a let ignore = false flag, the cleanup sets ignore = true, and the old answer is skipped. With fetch, you can also call abort() on an AbortController in the cleanup. Then the catch must skip the AbortError that the aborted request throws. A strong answer notes that the flag doesn’t stop the request. The server may still do the work. Aborting stops the request itself.
My Effect sends its request twice in development. Is that a bug?
No. Strict Mode runs setup, then cleanup, then setup again, when a component first appears. So the request goes out twice. With a correct cleanup, the first answer is ignored, and only one sets state. In production there is only one request. Don’t turn off Strict Mode to hide it. The double run is a test of your cleanup. If the extra request is a real cost, React’s docs point to a cache that removes repeated requests.
Does fetch reject when the server answers 404 or 500?
No. The fetch promise rejects only when the request can’t be made at all, like a network error. A 404 or 500 is still a response, so it resolves, with res.ok set to false. You must check res.ok (or res.status) and throw your own error. Otherwise res.json() may throw a confusing SyntaxError on an HTML error page. Or you may treat an error message as real data.
When should a request go in an event handler instead of an Effect?
Ask why the code runs. Did it run because the user did something, like clicking “Buy” or sending a form? Then put it in that event handler. If it runs because the component is on the screen, put it in an Effect. React’s docs give the rule: “If this logic is caused by a particular interaction, keep it in the event handler.”
An Effect that watches a “submitted” flag can’t tell what the user did. Say that flag lives in a parent. Then the Effect can run again when the component comes back, and send the form a second time. A strong answer mentions React 19’s form Actions for the sending state.
Why model a request as one status union, and not three separate pieces of state?
Say isLoading, error and data are separate state. Then you can reach mixes that make no sense, like loading with an error. One union, { status: 'loading' } | { status: 'error'; message: string } | { status: 'done'; user: User }, allows only the three real cases. TypeScript then narrows it. After checking status === 'done', it knows user exists. A strong answer calls this a small state machine: a fixed list of states, and only one at a time. Part 26 covers them. It also notes that the render code becomes a simple choice between three cases.
Sources
- Synchronizing with Effects, react.dev: the fetching example with
ignore, that the cleanup “should either abort the fetch or ignore its result”, the two requests in development (“There is nothing wrong with that.” and “In production, there will only be one request.”), and the deep dive on the downsides of fetching in Effects (server rendering, “network waterfalls”, no caching, a lot of code), with frameworks, TanStack Query and useSWR as alternatives. - You Might Not Need an Effect, react.dev: rendering “with the stale value”, requests caused by an interaction belong in the event handler (“If this logic is caused by a particular interaction, keep it in the event handler.”), the “race condition” name, and the fix that ignores stale responses.
- useEffect, react.dev: fetching data with Effects,
setBio(null)at the top of the example Effect, theasyncfunction inside the Effect, and theignorevariable against race conditions. - StrictMode, react.dev: Effects run setup, cleanup, setup in development only.
- Window: fetch() method, MDN: a
fetch()promise rejects only when the request fails (with a list of cases), not on error status codes. Response: ok property, MDN:okis true for a status from 200 to 299. Using the Fetch API, MDN: checkingresponse.okand throwing. - Promise and async function, MDN: a promise’s value comes later, it is fulfilled or rejected, and
awaitwithtry/catch. - The page output, the order of the requests and answers, the request counts, the render counts, the warning and error texts, and the
fetchtable come from running React 19.3.0, TypeScript 7.0.2, Sucrase 3.35.1 and Node.js 24.18.0 for this post.