Skip to main content

Custom Hooks

What are Custom Hooks?

Custom hooks are JavaScript functions whose names start with use and that may call other hooks. They let you extract component logic into reusable functions — the same way you'd extract any duplicated code into a helper function, but with the ability to use React's built-in hooks inside.

Think of them as your own hook library that you build as your app grows.

Why Custom Hooks?

Consider two components that both need to know whether the user is online:

// In ComponentA
const [isOnline, setIsOnline] = useState(navigator.onLine);
useEffect(() => {
const handleOnline = () => setIsOnline(true);
const handleOffline = () => setIsOnline(false);
window.addEventListener("online", handleOnline);
window.addEventListener("offline", handleOffline);
return () => {
window.removeEventListener("online", handleOnline);
window.removeEventListener("offline", handleOffline);
};
}, []);

// In ComponentB — same 12 lines again

That's a lot of duplication. A custom hook solves this cleanly.

Building Your First Custom Hook

Extract the repeated logic into a useOnlineStatus hook:

src/hooks/useOnlineStatus.js
import { useState, useEffect } from "react";

function useOnlineStatus() {
const [isOnline, setIsOnline] = useState(navigator.onLine);

useEffect(() => {
const handleOnline = () => setIsOnline(true);
const handleOffline = () => setIsOnline(false);

window.addEventListener("online", handleOnline);
window.addEventListener("offline", handleOffline);

return () => {
window.removeEventListener("online", handleOnline);
window.removeEventListener("offline", handleOffline);
};
}, []);

return isOnline;
}

export default useOnlineStatus;

Now any component can use it in one line:

import useOnlineStatus from "./hooks/useOnlineStatus";

function StatusBar() {
const isOnline = useOnlineStatus();
return <p>You are {isOnline ? "online ✅" : "offline ❌"}</p>;
}

function SaveButton() {
const isOnline = useOnlineStatus();
return (
<button disabled={!isOnline}>
{isOnline ? "Save" : "Reconnecting..."}
</button>
);
}

Both components share the logic, but each has its own state. Each call to a custom hook gets its own isolated state — they don't share state with each other.

Practical Examples

useFetch — Data Fetching Hook

One of the most common custom hooks you'll build:

src/hooks/useFetch.js
import { useState, useEffect } from "react";

function useFetch(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);

useEffect(() => {
let cancelled = false;

setLoading(true);
setError(null);

fetch(url)
.then((res) => {
if (!res.ok) throw new Error(`HTTP error ${res.status}`);
return res.json();
})
.then((json) => {
if (!cancelled) {
setData(json);
setLoading(false);
}
})
.catch((err) => {
if (!cancelled) {
setError(err.message);
setLoading(false);
}
});

return () => {
cancelled = true;
};
}, [url]);

return { data, loading, error };
}

export default useFetch;
tip
Why the cancelled flag?

When the component unmounts before the fetch completes (e.g., user navigates away), you don't want to call setData on an unmounted component. The cleanup function sets cancelled = true, so the response is ignored.

Usage is clean and declarative:

function PostList() {
const { data, loading, error } = useFetch(
"https://jsonplaceholder.typicode.com/posts"
);

if (loading) return <p>Loading...</p>;
if (error) return <p>Error: {error}</p>;

return (
<ul>
{data.slice(0, 5).map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}

useLocalStorage — Persistent State

State that survives a page refresh:

src/hooks/useLocalStorage.js
import { useState } from "react";

function useLocalStorage(key, initialValue) {
const [storedValue, setStoredValue] = useState(() => {
try {
const item = localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch {
return initialValue;
}
});

const setValue = (value) => {
const valueToStore = value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
localStorage.setItem(key, JSON.stringify(valueToStore));
};

return [storedValue, setValue];
}

export default useLocalStorage;

It works exactly like useState, but persists to localStorage:

function ThemeToggle() {
const [theme, setTheme] = useLocalStorage("theme", "light");

return (
<button onClick={() => setTheme(theme === "light" ? "dark" : "light")}>
Switch to {theme === "light" ? "dark" : "light"} mode
</button>
);
}

useDebounce — Debounced Input

Prevent firing API calls on every keystroke:

src/hooks/useDebounce.js
import { useState, useEffect } from "react";

function useDebounce(value, delay = 500) {
const [debouncedValue, setDebouncedValue] = useState(value);

useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);

return debouncedValue;
}

export default useDebounce;
function SearchBar() {
const [query, setQuery] = useState("");
const debouncedQuery = useDebounce(query, 400);

useEffect(() => {
if (debouncedQuery) {
// Only runs 400ms after the user stops typing
console.log("Searching for:", debouncedQuery);
}
}, [debouncedQuery]);

return (
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search..."
/>
);
}

Rules for Custom Hooks

Rules of Hooks apply here too
  1. Only call hooks at the top level — not inside loops, conditions, or nested functions.
  2. Only call hooks from React functions — either function components or other custom hooks.
  3. Name must start with use — this is how React (and linters) know it's a hook. Breaking this convention means you lose lint warnings about hook rule violations.

Organizing Your Hooks

As your app grows, put hooks in a dedicated folder:

src/
hooks/
useFetch.js
useLocalStorage.js
useDebounce.js
useOnlineStatus.js
index.js ← re-export all hooks here
src/hooks/index.js
export { default as useFetch } from "./useFetch";
export { default as useLocalStorage } from "./useLocalStorage";
export { default as useDebounce } from "./useDebounce";
export { default as useOnlineStatus } from "./useOnlineStatus";

Then import cleanly:

import { useFetch, useLocalStorage } from "./hooks";

Summary

ConceptKey Point
Custom hooks share logic, not stateEach component call gets its own isolated state
Name must start with useRequired for React's linting rules to work
Can call other hooksIncluding other custom hooks
Return anythingValues, objects, arrays, functions — whatever callers need

Custom hooks are one of the most powerful patterns in React. As you build features, watch for logic that appears in multiple components — that's your cue to extract a custom hook.