Difficulty: Intermediate
What are custom hooks? How do you extract and compose reusable logic with custom hooks?
Alright, let's talk about custom hooks, because once you truly get them, they'll completely change how you write React code.
So here's the basic idea: a custom hook is just a regular JavaScript function that starts with the word 'use' and calls other hooks inside it. That's it. There's no magic registration, no special API, no ceremony. If you've ever written a function that uses useState or useEffect, congratulations, you basically already know how to write a custom hook.
But why do they exist? Let me paint you a picture. Imagine you're building an app and you need to track whether the user is online or offline. You write a useEffect that listens to the 'online' and 'offline' window events, you store the result in useState, and you clean up the listener when the component unmounts. Cool, it works. Now imagine you need that same online/offline status in three other components. Are you going to copy-paste that same useState + useEffect combo into all of them? That's where custom hooks come in. You extract that logic into a function called useOnlineStatus, and now any component can call it to get the current status. The logic lives in one place, and every component gets its own independent copy of the state.
This is the single most important thing to understand about custom hooks: they share logic, NOT state. If ComponentA and ComponentB both call useOnlineStatus, they each get their own separate useState and useEffect. They don't magically share the same state variable. Think of it like a cookie cutter: the hook is the shape, but each component gets its own cookie.
The naming convention matters more than you might think. The 'use' prefix isn't just a suggestion or a style preference. React's linter (eslint-plugin-react-hooks) actually uses that prefix to enforce the rules of hooks. If your function starts with 'use', the linter will check that you're calling hooks at the top level and not inside conditions or loops. If you name your function getOnlineStatus instead of useOnlineStatus, the linter won't catch violations, and you could end up with really subtle bugs.
Let's talk about the anatomy of a good custom hook. A custom hook typically does three things: it manages some internal state (useState, useReducer), it runs some side effects (useEffect), and it returns something useful to the component. What it returns can be anything: a single value, an array like [value, setValue] (similar to useState), or an object like { data, loading, error } (similar to a fetch result). There's no required return format.
Here are some common patterns you'll see in the real world:
1. useLocalStorage - wraps useState so the value persists in the browser's localStorage. This is incredibly useful for things like saving user preferences (theme, language, sidebar collapsed/expanded) without needing a backend.
2. useDebounce - takes a rapidly changing value (like a search input) and returns a 'debounced' version that only updates after the user stops typing for a specified delay. Without this, you'd fire an API request on every single keystroke, which hammers your server and gives a terrible user experience.
3. useFetch - wraps the common pattern of fetching data with loading and error states. You pass in a URL, and it gives you back { data, loading, error }. This is the basic idea behind libraries like React Query and SWR, just much more sophisticated.
4. useMediaQuery - checks if a CSS media query matches, so you can conditionally render different layouts in JavaScript. Useful for responsive design when CSS alone isn't enough.
One of the most powerful aspects of custom hooks is composition. You can build complex hooks from simpler ones. For example, you might have a useFetch hook for raw data fetching, then build a useUser hook on top of it that fetches a specific user and computes a full name, then build a useUserDashboard hook that combines useUser with useFetch for posts. Each layer adds a bit more domain-specific logic, but they're all independently testable and reusable.
Common mistakes beginners make with custom hooks:
- Forgetting that each call creates independent state. If you want to share state between components, you need Context or a state management library, not just a custom hook. - Not following the rules of hooks inside custom hooks. Even though your custom hook is 'just a function', it still must follow the rules: no hooks inside conditions, loops, or nested functions. - Over-extracting. Not every piece of logic needs to be a custom hook. If the logic is only used in one component and it's not particularly complex, just keep it in the component. Extract when you see duplication or when a component is getting too big to understand at a glance. - Naming hooks that don't actually use hooks. If your function doesn't call useState, useEffect, useRef, or any other hook, it's just a utility function, not a hook. Don't prefix it with 'use'.
In a real production codebase, custom hooks are everywhere. They're the primary way experienced React developers organize code. A well-structured React project has components that are mostly about rendering UI, and custom hooks that handle all the stateful logic, data fetching, subscriptions, and side effects. This separation makes the codebase dramatically easier to understand, test, and maintain.
When you're in an interview, have two or three custom hooks from your own projects ready to discuss. Walk through why you extracted the hook, what it does, and how multiple components use it. That shows way more depth than just reciting the definition.
import { useState, useEffect } from 'react';
function useLocalStorage(key, initialValue) {
// Lazy initialization - read from localStorage once
const [value, setValue] = useState(() => {
try {
const stored = localStorage.getItem(key);
return stored !== null ? JSON.parse(stored) : initialValue;
} catch {
return initialValue;
}
});
// Sync to localStorage whenever value changes
useEffect(() => {
try {
localStorage.setItem(key, JSON.stringify(value));
} catch (error) {
console.error('Failed to save to localStorage:', error);
}
}, [key, value]);
return [value, setValue];
}
// Usage - works just like useState but persists
function Settings() {
const [theme, setTheme] = useLocalStorage('theme', 'light');
const [fontSize, setFontSize] = useLocalStorage('fontSize', 16);
return (
<div>
<select value={theme} onChange={e => setTheme(e.target.value)}>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
</div>
);
}
useLocalStorage wraps useState with localStorage persistence. The lazy initializer reads the stored value only on the first render. Each component calling this hook gets its own independent state.
import { useState, useEffect } from 'react';
function useDebounce(value, delay = 300) {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}
// Usage: search input with debounced API call
function SearchPage() {
const [query, setQuery] = useState('');
const debouncedQuery = useDebounce(query, 500);
const [results, setResults] = useState([]);
// Only fetches when user stops typing for 500ms
useEffect(() => {
if (!debouncedQuery) return;
fetch(`/api/search?q=${debouncedQuery}`)
.then(res => res.json())
.then(setResults);
}, [debouncedQuery]);
return (
<div>
<input
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Search..."
/>
<ul>
{results.map(r => <li key={r.id}>{r.title}</li>)}
</ul>
</div>
);
}
useDebounce delays updating a value until a specified time has passed since the last change. The cleanup function cancels the previous timer on each new change.
// Hook 1: Fetch data
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);
fetch(url, { signal: controller.signal })
.then(res => res.json())
.then(data => { setData(data); setLoading(false); })
.catch(err => {
if (err.name !== 'AbortError') {
setError(err); setLoading(false);
}
});
return () => controller.abort();
}, [url]);
return { data, loading, error };
}
// Hook 2: Build on top of useFetch
function useUser(userId) {
const { data, loading, error } = useFetch(`/api/users/${userId}`);
const fullName = data ? `${data.firstName} ${data.lastName}` : '';
return { user: data, fullName, loading, error };
}
// Hook 3: Combine multiple hooks
function useUserDashboard(userId) {
const { user, loading: userLoading } = useUser(userId);
const { data: posts, loading: postsLoading } = useFetch(
`/api/users/${userId}/posts`
);
return {
user,
posts,
loading: userLoading || postsLoading,
};
}
Custom hooks compose naturally. Higher-level hooks build on lower-level ones, creating clean abstractions. Each hook is independently testable and reusable.
Custom Hooks, Reusability, Composition, Abstraction