Custom hooks and data loading
Custom hooks
The hooks useState, useEffect, useRef, useContext are building blocks. You can assemble your own hooks from
them. A custom hook is an ordinary function that:
- has a name starting with
use(useToggle,useWindowWidth,useFetch); - calls other hooks inside.
Why? To extract logic from a component and use it in several places. Compare:
// Before: the window size logic is right in the component
function Header() {
const [width, setWidth] = useState(window.innerWidth);
useEffect(() => {
const onResize = () => setWidth(window.innerWidth);
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, []);
return width < 600 ? <MobileMenu /> : <DesktopMenu />;
}
// After: the logic is in a hook, the component reads at a glance
function useWindowWidth() {
const [width, setWidth] = useState(window.innerWidth);
useEffect(() => {
const onResize = () => setWidth(window.innerWidth);
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, []);
return width;
}
function Header() {
const width = useWindowWidth();
return width < 600 ? <MobileMenu /> : <DesktopMenu />;
}
function Sidebar() {
const width = useWindowWidth(); // the same logic in one more place
return width < 900 ? null : <aside>…</aside>;
}
Important: hooks share logic, not state. Each call of useWindowWidth() creates its own state and its own
subscription — as if the code were pasted right into the component. For different components to share the same data,
you need context (or a common parent).
The rules of hooks apply to custom ones too: call them only at the top level of a component or another hook, not inside
conditions and loops. The use name isn't just tradition: by it React and the linter know that the function must be
checked against these rules.
Loading data: three states
Loading from a server is the most common effect. It always has three states, and you need to show each one:
function ProductList() {
const [products, setProducts] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
async function load() {
try {
const response = await fetch("https://api.example.com/products");
if (!response.ok) throw new Error(`HTTP ${response.status}`);
setProducts(await response.json());
} catch (e) {
setError(e.message);
} finally {
setLoading(false);
}
}
load();
}, []);
if (loading) return <p>Loading…</p>;
if (error) return <p className="error">Failed to load: {error}</p>;
return (
<ul>
{products.map((p) => <li key={p.id}>{p.title}</li>)}
</ul>
);
}
What matters here:
- the effect function can't be made
async:useEffect(async () => …)returns a promise, while React expects an effect to return either nothing or a cleanup function. That's whyasync function load()is declared inside and called right away; - we check
response.okourselves:fetchdoesn't consider a 404 or 500 response an error (thefetchtopic of the JavaScript course); finallyturns off the loading indicator in any case.
Loading when parameters change
If the address depends on props or state, it's listed in the dependencies — and the effect re-runs on every change:
function UserPage({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
setUser(null); // drop the old one while the new one loads
fetch(`https://api.example.com/users/${userId}`)
.then((r) => r.json())
.then(setUser);
}, [userId]);
if (!user) return <p>Loading…</p>;
return <h1>{user.name}</h1>;
}
The response race
This code hides a bug found in many applications. The user quickly opened Ann's profile and then Timur's. Two requests went out. The responses arrive in any order: if the server answered about Ann later than about Timur, Ann will end up on Timur's page.
The solution is the effect's cleanup function. Before re-running an effect (and when the component is removed) React calls the cleanup of the old effect. In it we mark that the old response is no longer needed:
useEffect(() => {
let ignore = false;
async function load() {
const response = await fetch(`https://api.example.com/users/${userId}`);
const data = await response.json();
if (!ignore) setUser(data); // the old effect is already "switched off" — don't touch state
}
load();
return () => {
ignore = true;
};
}, [userId]);
Each run of the effect has its own ignore variable (a closure!). When userId changes, the cleanup sets
ignore = true for the old run, and its response, whenever it arrives, is skipped.
Even better is to cancel the request itself so you don't waste traffic. fetch has AbortController for that:
useEffect(() => {
const controller = new AbortController();
fetch(url, { signal: controller.signal })
.then((r) => r.json())
.then(setData)
.catch((e) => {
if (e.name !== "AbortError") setError(e.message);
});
return () => controller.abort(); // cancel the request when url changes or the page is left
}, [url]);
The useFetch hook
All of it together fits nicely into a custom hook — and components become really simple:
function useFetch(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let ignore = false;
async function load() {
setLoading(true);
setError(null);
try {
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const json = await response.json();
if (!ignore) setData(json);
} catch (e) {
if (!ignore) setError(e.message);
} finally {
if (!ignore) setLoading(false);
}
}
load();
return () => {
ignore = true;
};
}, [url]);
return { data, loading, error };
}
function Weather({ city }) {
const { data, loading, error } = useFetch(`https://api.example.com/weather?city=${city}`);
if (loading) return <p>Loading…</p>;
if (error) return <p>Error: {error}</p>;
return <p>{city}: {data.temp}°</p>;
}
What big projects use
A custom useFetch is good for understanding and for small applications. In big projects data loading is usually handed
to the TanStack Query (React Query) or SWR library: they add a cache (reopening a page is instant), retries, and
refreshing when you return to the tab. They're built on the same pattern: a hook that returns data, isLoading and error.
And frameworks like Next.js can load data on the server, before the page even reaches the browser.
Summary
- A custom hook is a
useSomethingfunction that calls other hooks. It shares logic, not state. - Loading: the states
loading,data,error; the effect function isn't madeasync— declareasync function load()inside. - Request parameters go in the effect's dependencies.
- The response race:
let ignore = false+ the cleanup() => { ignore = true }, orAbortController. - In big projects — TanStack Query or SWR.