Extract stateful logic into reusable, composable, testable hooks.
Open this lesson in KodokonA custom hook is a function whose name starts with use and that calls other hooks. It extracts stateful logic, not shared state: every component that calls it gets its own useState and useEffect, fully isolated. It is React's most direct tool for factoring out logic - where HOCs and render props added layers to the tree, a hook slips into the body of the component, composes easily, and is easy to test.
import { useEffect, useState } from "react";
function useDebouncedValue(value, delay = 300) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = setTimeout(() => {
setDebounced(value);
}, delay);
return () => clearTimeout(id);
}, [value, delay]);
return debounced;
}The use prefix is not decorative: it is what lets the react-hooks ESLint plugin check the rules of hooks inside your function - unconditional calls, at the top level. Also take care with the shape of the returned API: a [value, setValue] tuple when the usage mirrors useState, an object with named keys as soon as there are more than two things to expose.
function useLocalStorage(key, initialValue) {
const [value, setValue] = useState(() => {
const stored = localStorage.getItem(key);
return stored !== null
? JSON.parse(stored)
: initialValue;
});
useEffect(() => {
localStorage.setItem(key, JSON.stringify(value));
}, [key, value]);
return [value, setValue];
}Hooks compose: a hook can call other hooks, including your own. That is how readable business abstractions emerge, built on generic building blocks. Note the pairing of AbortController with the cleanup function: without it, a request fired for an older input can respond after the most recent one and overwrite its result - the classic race condition of search fields.
function useUserSearch(query) {
const debounced = useDebouncedValue(query, 400);
const [results, setResults] = useState([]);
useEffect(() => {
if (debounced === "") {
setResults([]);
return;
}
const controller = new AbortController();
fetch("/api/search?q=" + debounced, {
signal: controller.signal,
})
.then((res) => res.json())
.then(setResults)
.catch(() => {});
return () => controller.abort();
}, [debounced]);
return results;
}