How should we manage server state in React? Many people first think of sending requests in useEffect and managing state with useState.
Let's look at the problems with this approach, how to fix them, and then better alternatives.
Fetch in useEffect and manage state with useState
We usually write something like this:
function App() {
const [users, setUsers] = useState([]);
useEffect(() => {
fetch("https://jsonplaceholder.typicode.com/users")
.then((res) => res.json())
.then((data) => setUsers(data));
}, []);
return (
<ul>
{users.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
As a demo this is fine, but it has obvious issues:
- It does not track request state, so the user has no idea data is loading.
- It does not handle errors, so failures are silent.
Let's fix that:
function App() {
const [users, setUsers] = useState();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
setIsLoading(true);
fetch("https://jsonplaceholder.typicode.com/users")
.then((res) => {
if (!res.ok) {
throw new Error("Failed to fetch users");
}
return res.json();
})
.then((data) => {
setUsers(data);
setIsLoading(false);
setError(null);
})
.catch((error) => {
setError(error);
setIsLoading(false);
});
}, []);
if (isLoading) {
return <div>Loading...</div>;
}
if (error) {
return <div>Error: {error.message}</div>;
}
return (
<ul>
{users.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
At first glance, adding multiple useState hooks makes the code more complex but still manageable.
But your boss asks for search, and you might write something like this:
function App() {
const [users, setUsers] = useState([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
const [search, setSearch] = useState("");
useEffect(() => {
setIsLoading(true);
fetch(`https://jsonplaceholder.typicode.com/users?q=${search}`)
.then((res) => {
if (!res.ok) {
throw new Error("Failed to fetch users");
}
return res.json();
})
.then((data) => {
setUsers(data);
setIsLoading(false);
setError(null);
})
.catch((error) => {
setError(error);
setIsLoading(false);
});
}, [search]);
if (error) {
return <div>Error: {error.message}</div>;
}
return (
<div>
<input
type="text"
value={search}
onChange={(e)=> setSearch(e.target.value)}
/>
{isLoading ? (
<div>Loading...</div>
) : (
<ul>
{users.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
)}
</div>
);
}
This looks fine, but it hides a problem. Every keystroke fires a request. If the user types "a" then "b", you send two requests for "a" and "ab". Server response times vary. If the response for "a" is slower than "ab", your UI will show results for "ab" first and then overwrite with results for "a". The issue is:
- The final result does not match the UI intent. We want the "ab" results.
How do we fix it?
You might think of debouncing requests, delaying the fetch a bit. But debounce doesn't solve the core issue: response times vary. Even with debounce, a slight pause can still trigger multiple requests and cause races.
What we actually need is: when a new request starts, cancel or ignore the previous one. That avoids races. You can find a similar approach in the React docs.
function App() {
const [users, setUsers] = useState();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
const [search, setSearch] = useState("");
useEffect(() => {
let ignore = false;
setIsLoading(true);
fetch(`https://jsonplaceholder.typicode.com/users?q=${search}`)
.then((res) => {
if (!res.ok) {
throw new Error("Failed to fetch users");
}
return res.json();
})
.then((data) => {
if (ignore) return;
setUsers(data);
setIsLoading(false);
setError(null);
})
.catch((error) => {
if (ignore) return;
setError(error);
setIsLoading(false);
});
return () => {
ignore = true;
};
}, [search]);
if (error) {
return <div>Error: {error.message}</div>;
}
return (
<div>
<input
type="text"
value={search}
onChange={(e)=> setSearch(e.target.value)}
/>
{isLoading ? (
<div>Loading...</div>
) : (
<ul>
{users.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
)}
</div>
);
}
Because React runs cleanup before the next effect, we can set ignore and skip setState when the request is outdated.
At this point the request logic is getting complex. The same pattern is needed for every request, so you might extract a shared hook.
export function useQuery({ url }) {
const [data, setData] = useState();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
let ignore = false;
setIsLoading(true);
fetch(url)
.then((res) => {
if (!res.ok) {
throw new Error("Failed to fetch users");
}
return res.json();
})
.then((data) => {
if (ignore) return;
setData(data);
setIsLoading(false);
setError(null);
})
.catch((error) => {
if (ignore) return;
setError(error);
setIsLoading(false);
});
return () => {
ignore = true;
};
}, [url]);
return {
data,
isLoading,
error,
};
}
import { useQuery } from "./useQuery";
function App() {
const [search, setSearch] = useState("");
const {
data: users,
isLoading,
error,
} = useQuery({
url: `https://jsonplaceholder.typicode.com/users?q=${search}`,
});
if (isLoading) {
return <div>Loading...</div>;
}
if (error) {
return <div>Error: {error.message}</div>;
}
return (
<div>
<input
type="text"
value={search}
onChange={(e)=> setSearch(e.target.value)}
/>
{isLoading ? (
<div>Loading...</div>
) : (
<ul>
{users.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
)}
</div>
);
}
Now the custom hook looks nice, but if you use it widely you will run into issues:
- Request deduplication: our
useQuerydoesn't dedupe requests. Using it in multiple components with the same URL triggers redundant requests. - Caching: our
useQuerydoesn't cache. High-performance apps often need caching because not every screen requires real-time data. As the saying goes, caching and naming are hard. - Refetching: should we refetch data when the user returns to the page after some time (coffee break, screen lock, etc.)?
- Coupling to
fetch: ouruseQuerybakes infetch. Ideally we should accept aqueryFnandqueryKey, return a promise fromqueryFn, and comparequeryKeys efficiently to know when to refetch.
Use a third-party library
After all that, the point is clear: managing server request state is complex. If you care about great UX, use a third-party library. The most popular in this space is react-query, which provides:
- Declarative API: declare a query and changing its parameters automatically reruns it.
- Automatic caching
- Automatic request deduplication
- Backend-agnostic
- Automatic retries
- Prefetching
- Request cancellation
- More
For more details, see the react-query docs.
Let's rewrite the example with react-query:
import {
QueryClient,
QueryClientProvider,
useQuery,
} from "@tanstack/react-query";
const queryClient = new QueryClient();
export default function App() {
return (
<QueryClientProvider client={queryClient}>
<Example />
</QueryClientProvider>
);
}
function Example() {
const [search, setSearch] = useState("");
const { data, isPending, error } = useQuery({
queryKey: ["users", search],
queryFn: () =>
fetch(`https://jsonplaceholder.typicode.com/users?q=${search}`).then(
(res) => {
if (!res.ok) {
throw new Error("Failed to fetch users");
}
return res.json();
}
),
});
if (error) {
return <div>Error: {error.message}</div>;
}
return (
<div>
<input
type="text"
value={search}
onChange={(e)=> setSearch(e.target.value)}
/>
{isPending ? (
<div>Loading...</div>
) : (
<ul>
{users.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
)}
</div>
);
}
All done. The API looks much cleaner. Hopefully you get a chance to use it in your projects.