Data Fetching and Routing
Load data with loading and error states, then add pages with React Router.
What you will learn
- Model loading/error/success
- Cancel stale requests
- Add client-side routes
Almost every real app loads data from an API. A robust component must handle three situations, not one: loading, error and success. Skipping the first two is why many apps flash blank screens or crash on a network hiccup.
function useFetch(url) {
const [state, setState] = useState({ status: "loading", data: null, error: null });
useEffect(() => {
const controller = new AbortController();
setState({ status: "loading", data: null, error: null });
fetch(url, { signal: controller.signal })
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then((data) => setState({ status: "success", data, error: null }))
.catch((error) => {
if (error.name !== "AbortError")
setState({ status: "error", data: null, error });
});
return () => controller.abort(); // cancel when url changes or unmounting
}, [url]);
return state;
}AbortController cancels the in-flight request when the component unmounts or url changes, preventing an old response from overwriting newer data.
function Users() {
const { status, data, error } = useFetch("https://jsonplaceholder.typicode.com/users");
if (status === "loading") return <p>Loading...</p>;
if (status === "error") return <p role="alert">Failed: {error.message}</p>;
return (
<ul>
{data.map((u) => <li key={u.id}>{u.name}</li>)}
</ul>
);
}Mutations
Sending data (POST, PUT, DELETE) usually happens in an event handler, not an effect.
async function createUser(name) {
const res = await fetch("/api/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
});
if (!res.ok) throw new Error("Could not save");
return res.json();
}Use a data library when it grows
Hand-rolled fetching does not cache, deduplicate requests, retry or refetch in the background. Libraries such as TanStack Query or SWR provide all of that. The pattern stays the same: a hook returns { data, isLoading, error }.
import { useQuery } from "@tanstack/react-query";
function Users() {
const { data, isLoading, error } = useQuery({
queryKey: ["users"],
queryFn: () => fetch("/api/users").then((r) => r.json()),
});
if (isLoading) return <p>Loading...</p>;
if (error) return <p>Error</p>;
return data.map((u) => <p key={u.id}>{u.name}</p>);
}Routing
Single-page apps show different screens without full page loads. React Router maps URLs to components.
npm install react-router-domimport { BrowserRouter, Routes, Route, Link, useParams } from "react-router-dom";
function UserPage() {
const { id } = useParams();
return <h2>User {id}</h2>;
}
function App() {
return (
<BrowserRouter>
<nav>
<Link to="/">Home</Link> | <Link to="/users/42">User 42</Link>
</nav>
<Routes>
<Route path="/" element={<h1>Home</h1>} />
<Route path="/users/:id" element={<UserPage />} />
<Route path="*" element={<p>Not found</p>} />
</Routes>
</BrowserRouter>
);
}Use <Link> instead of <a> for internal navigation so the page does not reload.
Ready for more? Learn a framework built on React, such as Next.js, for server rendering and file-based routing, and TypeScript for safer props.
Try it yourself
Fetch a list of posts from https://jsonplaceholder.typicode.com/posts, show a loading message and an error message, and add a text box that filters posts by title as you type.
Show solution
function Posts() {
const { status, data, error } = useFetch("https://jsonplaceholder.typicode.com/posts");
const [q, setQ] = useState("");
if (status === "loading") return <p>Loading...</p>;
if (status === "error") return <p>{error.message}</p>;
const shown = data.filter((p) => p.title.toLowerCase().includes(q.toLowerCase()));
return (
<>
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Filter" />
<ul>{shown.map((p) => <li key={p.id}>{p.title}</li>)}</ul>
</>
);
}