
Custom Hooks: Simplify and Organize
Moamen Sherif
-December 13, 2025
10 minutes read
Custom Hooks help move repeated or complex logic out of components so components stay focused on showing the UI. The result is cleaner code that’s easier to read, test, and reuse. 💡
What problem do Custom Hooks solve?
- The same logic (like fetching data, handling forms, or listening to events) appears in multiple components, causing copy‑paste and headaches when something changes.
- Components become long and hard to understand because UI and logic are mixed together.
- Custom Hooks let you write that logic once and reuse it everywhere, keeping components small and focused.
What is a Custom Hook?
- A Custom Hook is just a JavaScript function whose name starts with
use(likeuseFetchoruseToggle). - Inside, it uses React’s built‑in hooks (like
useState,useEffect,useMemo,useContext) to manage state and side effects. - A Custom Hook shares logic, not the actual state. Each component that calls it gets its own state instance.
How Custom Hooks fit into a React app
- Components handle “what to show” (the UI).
- Custom Hooks handle “how it works” (data fetching, timers, subscriptions, business rules).
- This separation makes code easier to read, test, and maintain. 🧪
Components vs Custom Hooks
| Concept | What it does | Where it runs |
|---|---|---|
| Component | Renders UI (JSX/HTML) | In the component tree |
| Custom Hook | Encapsulates reusable logic (state/effects) | Inside components (as a helper function) |
First mini example: useCounter
A tiny Hook to manage a number: increment, decrement, and reset.
// useCounter.js import { useCallback, useState } from 'react'; export function useCounter(initial = 0) { const [count, setCount] = useState(initial); const inc = useCallback(() => setCount(c => c + 1), []); const dec = useCallback(() => setCount(c => c - 1), []); const reset = useCallback(() => setCount(initial), [initial]); return { count, inc, dec, reset }; }
Use it in a component:
// Counter.jsx import { useCounter } from './useCounter'; export default function Counter() { const { count, inc, dec, reset } = useCounter(0); return ( <div> <p>Count: {count}</p> <button onClick={dec}>-</button> <button onClick={inc}>+</button> <button onClick={reset}>Reset</button> </div> ); }
What this gives:
- Clear, reusable state logic in one place.
- Smaller, easier‑to‑read UI components.
Practical example: useFetch for data fetching
Data fetching is a perfect example of repeated logic (loading, error handling, cleanup). Move it into a Custom Hook.
// useFetch.js import { useCallback, useEffect, useState } from 'react'; export function useFetch(url, options) { const [data, setData] = useState(null); const [loading, setLoading] = useState(Boolean(url)); const [error, setError] = useState(null); const [reloadKey, setReloadKey] = useState(0); // Trigger a refetch on demand const refetch = useCallback(() => setReloadKey(k => k + 1), []); useEffect(() => { if (!url) return; const controller = new AbortController(); setLoading(true); setError(null); fetch(url, { ...(options || {}), signal: controller.signal }) .then(res => { if (!res.ok) throw new Error(`HTTP ${res.status}`); const type = res.headers.get('content-type') || ''; return type.includes('application/json') ? res.json() : res.text(); }) .then(setData) .catch(e => { if (e.name !== 'AbortError') setError(e); }) .finally(() => setLoading(false)); // Cleanup on unmount or when url/options change return () => controller.abort(); }, [url, options, reloadKey]); return { data, loading, error, refetch }; }
Use it in a component:
// UserList.jsx import { useFetch } from './useFetch'; export default function UserList() { const { data, loading, error, refetch } = useFetch('/api/users'); if (loading) return <p>Loading users…</p>; if (error) return <p>Oops: {error.message}</p>; return ( <div> <button onClick={refetch}>Refresh</button> <ul> {Array.isArray(data) ? data.map(u => <li key={u.id}>{u.name}</li>) : null} </ul> </div> ); }
Why this is better:
- One standard place to manage loading, errors, and cleanup.
- Any component can use it with one line and a simple
refetch()action.
Turn messy component logic into a Hook (step‑by‑step)
1) Start with a component that mixes data fetching and UI:
function Posts() { const [posts, setPosts] = useState([]); const [loading, setLoading] = useState(true); useEffect(() => { fetch('/api/posts') .then(r => r.json()) .then(setPosts) .finally(() => setLoading(false)); }, []); if (loading) return <p>Loading…</p>; return posts.map(p => <article key={p.id}>{p.title}</article>); }
2) Extract the logic into a Hook and keep the UI focused:
function usePosts() { const { data: posts, loading } = useFetch('/api/posts'); return { posts: posts || [], loading }; } function Posts() { const { posts, loading } = usePosts(); if (loading) return <p>Loading…</p>; return posts.map(p => <article key={p.id}>{p.title}</article>); }
Cleaner, reusable, and easier to test. 🚀
Other handy Custom Hooks to try next
1) useToggle — manage a boolean flag
// useToggle.js import { useCallback, useState } from 'react'; export function useToggle(initial = false) { const [on, setOn] = useState(Boolean(initial)); const toggle = useCallback(() => setOn(v => !v), []); const setTrue = useCallback(() => setOn(true), []); const setFalse = useCallback(() => setOn(false), []); return { on, toggle, setTrue, setFalse }; }
2) useLocalStorage — persist state in the browser
// useLocalStorage.js import { useEffect, useState } from 'react'; export function useLocalStorage(key, initialValue) { const [value, setValue] = useState(() => { try { const raw = localStorage.getItem(key); return raw != null ? JSON.parse(raw) : initialValue; } catch { return initialValue; } }); useEffect(() => { try { localStorage.setItem(key, JSON.stringify(value)); } catch { // storage might be unavailable (private mode) } }, [key, value]); return [value, setValue]; }
3) useDebounce — wait before reacting to fast changes (like typing)
// useDebounce.js import { useEffect, useState } from 'react'; export function useDebounce(value, delay = 300) { const [debounced, setDebounced] = useState(value); useEffect(() => { const id = setTimeout(() => setDebounced(value), delay); return () => clearTimeout(id); }, [value, delay]); return debounced; }
4) useEventListener — subscribe to events safely with cleanup
// useEventListener.js import { useEffect, useRef } from 'react'; export function useEventListener(target, type, handler, options) { const saved = useRef(handler); useEffect(() => { saved.current = handler; }, [handler]); useEffect(() => { const el = typeof target === 'function' ? target() : target || window; if (!el?.addEventListener) return; const listener = (e) => saved.current(e); el.addEventListener(type, listener, options); return () => el.removeEventListener(type, listener, options); }, [target, type, options]); }
The “Rules of Hooks” in plain English
- Call Hooks only at the top level of a React function—never inside loops, conditions, or nested functions.
- Call Hooks only from React functions: components or other Hooks (not plain JS utilities).
FAQ (quick answers)
-
Do Hooks share state between components?
No, each call creates its own state; Hooks share the pattern, not the instance. -
Are Hooks the same as components?
No, components render UI; Hooks provide logic that components can use.
Final tips
- Start small: extract one repeated pattern into a Hook and reuse it.
- Name Hooks clearly (
useFetch,useForm,useToggle) and keep each focused on one responsibility. - Prefer returning an object (e.g.,
{ data, loading, error, refetch }) so you can add fields later without breaking code.
