πͺ Custom Hooks
A great chef perfects a signature sauce once, then reuses it across dozens of dishes. Custom hooks are your recipes: you extract a chunk of stateful component logic β fetching, storing to localStorage, tracking window size β into a plain function, and reuse it everywhere without copy-pasting.
Week 5 · Monday: React Hooks Deep Dive · Lecture 3
π― Learning Objectives
By the end of this lesson, you will be able to:
- Define what a custom hook is and the rules every hook must follow
- Extract duplicated component logic into a reusable
use*function - Build the workhorses:
useFetch,useLocalStorage,useDebounce - Compose hooks β building bigger hooks from smaller ones
- Return a consistent, predictable shape from every hook
- Test a custom hook in isolation
Estimated Time: 65 minutes
Practice: Extract a useFetch hook, write a persistent useLocalStorage, and compose them into a useAuth.
In This Lesson
What Is a Custom Hook?
A custom hook is simply a JavaScript function whose name starts with use and that calls other hooks (useState, useEffect, even other custom hooks). It isn't a new React feature β it's a convention that lets you lift stateful logic out of a component and share it, the same way a regular function lets you share plain logic.
β οΈ The Rules of Hooks apply
- Names must start with
useβ that's how React's linter knows to enforce the rules - Call hooks only at the top level β never inside conditions, loops, or nested functions
- Call hooks only from React functions β components or other hooks
A custom hook shares logic, not state. Two components using the same hook each get their own independent state.
Extracting Your First Hook
Here's the classic duplication: every component that loads data repeats the same loading / error / data dance.
// β Repeated in every data-loading component
function ProductList() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [data, setData] = useState(null);
useEffect(() => {
fetch('/api/products')
.then(res => res.json())
.then(data => { setData(data); setLoading(false); })
.catch(err => { setError(err); setLoading(false); });
}, []);
if (loading) return <div>Loadingβ¦</div>;
if (error) return <div>Error: {error.message}</div>;
return <div>{/* render products */}</div>;
}
Lift the three state pieces and the effect into useFetch. The modern version adds cleanup so a response that arrives after the component unmounts (or after url changes) is ignored β a common source of "can't update state on an unmounted component" warnings.
function useFetch(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const controller = new AbortController();
setLoading(true);
setError(null);
fetch(url, { signal: controller.signal })
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then(json => { setData(json); setLoading(false); })
.catch(err => {
if (err.name === 'AbortError') return; // ignore cancelled fetch
setError(err);
setLoading(false);
});
// Cleanup: cancel the request if url changes or component unmounts.
return () => controller.abort();
}, [url]);
return { data, loading, error };
}
// β
Now the component is tiny and declarative
function ProductList() {
const { data, loading, error } = useFetch('/api/products');
if (loading) return <div>Loadingβ¦</div>;
if (error) return <div>Error: {error.message}</div>;
return <div>{/* render products */}</div>;
}
useFetch and gets its own independent { data, loading, error }.useLocalStorage
A hook that behaves exactly like useState but transparently persists to localStorage β so a preference survives a page refresh. This is the canonical "extract reusable logic" example.
function useLocalStorage(key, initialValue) {
// Lazy initializer: read localStorage only once, on mount.
const [storedValue, setStoredValue] = useState(() => {
try {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch (error) {
console.warn(`Error reading localStorage key "${key}":`, error);
return initialValue;
}
});
// A setter with the same API as useState (value OR updater function).
const setValue = (value) => {
try {
const valueToStore =
value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
window.localStorage.setItem(key, JSON.stringify(valueToStore));
} catch (error) {
console.warn(`Error setting localStorage key "${key}":`, error);
}
};
return [storedValue, setValue];
}
// Usage β identical feel to useState, but it remembers.
function DarkModeToggle() {
const [isDark, setIsDark] = useLocalStorage('darkMode', false);
return (
<button onClick={() => setIsDark(prev => !prev)}>
{isDark ? 'π Light Mode' : 'π Dark Mode'}
</button>
);
}
π‘ Why the lazy initializer
Passing a function to useState means React calls it only on the first render, not on every render. Reading and parsing localStorage is comparatively slow, so you do it once β not on every re-render.
useDebounce & useWindowSize
useDebounce β wait for the typing to stop
Firing an API call on every keystroke floods your server. Debouncing returns a value that only updates after the user pauses. It's a tiny hook with an outsized impact on search boxes.
function useDebounce(value, delay = 500) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const handler = setTimeout(() => setDebounced(value), delay);
// If value changes before `delay`, cancel the pending update.
return () => clearTimeout(handler);
}, [value, delay]);
return debounced;
}
function SearchBox() {
const [term, setTerm] = useState('');
const debouncedTerm = useDebounce(term, 400);
useEffect(() => {
if (debouncedTerm) searchAPI(debouncedTerm); // runs 400ms after typing stops
}, [debouncedTerm]);
return (
<input
value={term}
onChange={(e) => setTerm(e.target.value)}
placeholder="Searchβ¦"
/>
);
}
useWindowSize β subscribe to the viewport
Any hook that adds an event listener must remove it on cleanup, or you leak listeners every time the component mounts.
function useWindowSize() {
const [size, setSize] = useState({
width: window.innerWidth,
height: window.innerHeight,
});
useEffect(() => {
const handleResize = () =>
setSize({ width: window.innerWidth, height: window.innerHeight });
window.addEventListener('resize', handleResize);
handleResize(); // sync once on mount
return () => window.removeEventListener('resize', handleResize);
}, []);
return size;
}
function Responsive() {
const { width } = useWindowSize();
return width < 768 ? <MobileView /> : <DesktopView />;
}
Composing Hooks
The real superpower: a custom hook can call other custom hooks. Here useAuth is built entirely out of the smaller hooks we've written, layering behavior instead of rewriting it.
function useAuth() {
// Persist the token across refreshes with our own hookβ¦
const [token, setToken] = useLocalStorage('token', null);
const [user, setUser] = useLocalStorage('user', null);
// β¦and reuse useFetch to load the current user when a token exists.
const { data: freshUser, loading } = useFetch(
token ? `/api/me?token=${token}` : null
);
const login = async (email, password) => {
const res = await authAPI.login(email, password);
setToken(res.token);
setUser(res.user);
};
const logout = () => {
setToken(null);
setUser(null);
};
return {
user: freshUser ?? user,
isAuthenticated: Boolean(token),
loading,
login,
logout,
};
}
// A whole auth system in one line per component.
function App() {
const { isAuthenticated, loading } = useAuth();
if (loading) return <LoadingScreen />;
return isAuthenticated ? <Dashboard /> : <LoginPage />;
}
β Small hooks, big systems
Each hook does one thing well: useLocalStorage persists, useFetch loads. useAuth just orchestrates them. This is the same "compose small pieces" philosophy you'll use for components β applied to logic.
Testing Custom Hooks
Because a hook is just logic, you can test it without rendering a real UI. React Testing Library's renderHook mounts the hook in a throwaway component and act flushes updates.
// useCounter.js
function useCounter(initialValue = 0) {
const [count, setCount] = useState(initialValue);
return {
count,
increment: () => setCount(c => c + 1),
decrement: () => setCount(c => c - 1),
reset: () => setCount(initialValue),
};
}
// useCounter.test.js
import { renderHook, act } from '@testing-library/react';
import { useCounter } from './useCounter';
test('increments the count', () => {
const { result } = renderHook(() => useCounter(10));
expect(result.current.count).toBe(10);
act(() => result.current.increment());
expect(result.current.count).toBe(11);
});
test('resets to the initial value', () => {
const { result } = renderHook(() => useCounter(5));
act(() => { result.current.increment(); result.current.reset(); });
expect(result.current.count).toBe(5);
});
π‘ act wraps state updates
Any call that triggers a state change must run inside act(...) so React can process the update before you assert on it. Forgetting act is the #1 cause of flaky hook tests.
Practice & Quiz
ποΈ Exercise 1: useToggle
Goal: Write a useToggle(initial = false) hook returning [on, toggle], where toggle() flips the boolean and toggle(true/false) sets it explicitly.
π‘ Hint
Use useState plus useCallback so the returned function is stable. Inside it, check typeof value === 'boolean' to decide between setting and flipping.
β Solution
function useToggle(initial = false) {
const [on, setOn] = useState(initial);
const toggle = useCallback((value) => {
setOn(prev => (typeof value === 'boolean' ? value : !prev));
}, []);
return [on, toggle];
}
ποΈ Exercise 2: usePrevious
Goal: Write a usePrevious(value) hook that returns the value from the previous render (useful for comparing "old vs new").
β Solution
function usePrevious(value) {
const ref = useRef();
useEffect(() => {
// Runs AFTER render, so ref holds the prior value during render.
ref.current = value;
}, [value]);
return ref.current;
}
// Usage
function Counter({ count }) {
const prev = usePrevious(count);
return <p>Now: {count}, before: {prev ?? 'β'}</p>;
}
π― Quick Quiz
Question 1: What makes a function a "custom hook"?
Question 2: Two components call the same useCounter() hook. Their counts areβ¦
Question 3: Why does useDebounce return a cleanup that calls clearTimeout?
Best Practices & Pitfalls
β Do
- Name every hook
use*so the linter enforces the Rules of Hooks - Keep each hook focused on one responsibility
- Return a consistent shape every render (array like
useState, or a stable object) - Clean up listeners, timers, and requests in the effect's return function
- Compose small hooks into larger ones instead of duplicating logic
β Don't
- Call hooks conditionally or inside loops β always top level
- Run side effects directly in the hook body (they belong in
useEffect) - Return different shapes on different renders (
{ loading }then{ user }) - Expect two components to share state through one hook β use Context for that
β οΈ Inconsistent returns break consumers
// β Bad: shape changes β destructuring in the component breaks
function useAuth() {
if (loading) return { loading };
if (user) return { user };
return null;
}
// β
Good: same keys every time
function useAuth() {
return { user, loading, error };
}
Consumers destructure your return value. If the keys come and go, their code throws. Always return the full shape.
Summary
π Key Takeaways
- A custom hook is a
use*function that calls other hooks to share logic - Extract duplicated
useState/useEffectlogic into hooks likeuseFetchanduseLocalStorage - Hooks share logic, not state β each caller gets independent state
- Compose small hooks into bigger ones (
useAuthfromuseFetch+useLocalStorage) - Always clean up effects and return a consistent shape; test hooks with
renderHook
π Additional Resources
- react.dev β Reusing Logic with Custom Hooks
- react.dev β Rules of Hooks
- react.dev β useRef reference
π What's Next?
You've completed the hooks deep dive β useContext, useReducer, and custom hooks. Next we start assembling multi-page apps: Client-Side Routing Concepts, where the URL drives which component renders without a full page reload.
π Hooks mastered!
Reusable, testable logic is what separates tidy React codebases from tangled ones. You can build those tools now.