Frontend

React Custom Hooks: Creating Your Own

Mayur Dabhi
Mayur Dabhi
April 14, 2026
14 min read

Custom hooks are one of the most powerful and underutilized features in React. When React introduced hooks in version 16.8, it didn't just change how we write components — it fundamentally changed how we share stateful logic across an application. Custom hooks let you extract complex logic into reusable functions, making your components cleaner, your code more testable, and your team more productive. In this guide, we'll go from the basics of what a custom hook is, through building real-world hooks you'll actually use in production.

Before You Start

You should be comfortable with React's built-in hooks — especially useState, useEffect, useRef, and useCallback — before building custom hooks. Custom hooks are essentially compositions of these primitives, so a solid understanding of the fundamentals makes everything click.

What Is a Custom Hook?

A custom hook is simply a JavaScript function whose name starts with use and that can call other hooks. That's it. There's no special API, no magic syntax — just a convention React uses to identify hook calls and enforce the rules of hooks.

Here's why that simple convention is so powerful: before hooks, sharing stateful logic required render props or higher-order components — patterns that led to deeply nested component trees and hard-to-trace data flows. Custom hooks let you extract stateful logic into a standalone function that any component can call directly, without changing your component hierarchy at all.

The Problem Custom Hooks Solve

Consider this common scenario: you have multiple components that each need to fetch data from an API, manage a loading state, and handle errors. Without custom hooks, you duplicate this logic in every component:

UserProfile.jsx (without custom hook)
function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    setLoading(true);
    fetch(`/api/users/${userId}`)
      .then(res => {
        if (!res.ok) throw new Error('Failed to fetch');
        return res.json();
      })
      .then(data => {
        setUser(data);
        setLoading(false);
      })
      .catch(err => {
        setError(err.message);
        setLoading(false);
      });
  }, [userId]);

  if (loading) return <Spinner />;
  if (error) return <ErrorMessage message={error} />;
  return <div>{user.name}</div>;
}

Now imagine copy-pasting those 20 lines into every component that fetches data. Bugs get fixed in one place but not others. Custom hooks eliminate this entirely.

Your First Custom Hook: useFetch

Let's extract that fetch logic into a reusable useFetch hook. The key insight is that hooks are just functions — you move the state and effects into the function and return whatever the calling component needs.

1

Create the hook file

By convention, put custom hooks in a hooks/ directory. Name the file useFetch.js and the function useFetch.

2

Move state and effects in

Copy the useState and useEffect calls from the component directly into the function body.

3

Return what consumers need

Return an object (or array) with the values and functions the component needs to render and interact.

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(() => {
    // Abort controller prevents state updates on unmounted components
    const controller = new AbortController();

    setLoading(true);
    setError(null);

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

    // Cleanup: abort the request when URL changes or component unmounts
    return () => controller.abort();
  }, [url]);

  return { data, loading, error };
}

export default useFetch;

Now any component can use it in one line:

UserProfile.jsx (with custom hook)
import useFetch from './hooks/useFetch';

function UserProfile({ userId }) {
  const { data: user, loading, error } = useFetch(`/api/users/${userId}`);

  if (loading) return <Spinner />;
  if (error) return <ErrorMessage message={error} />;
  return <div>{user.name}</div>;
}

The component went from 25 lines to 8. More importantly, the AbortController cleanup (which many developers forget) is now handled automatically in every component that uses useFetch.

useLocalStorage: Persistent State

One of the most useful hooks you can build is useLocalStorage — it works exactly like useState but persists the value to localStorage automatically. This is perfect for user preferences, theme settings, form drafts, and anything else that should survive a page refresh.

hooks/useLocalStorage.js
import { useState, useEffect } from 'react';

function useLocalStorage(key, initialValue) {
  // Initialize state from localStorage if available
  const [storedValue, setStoredValue] = useState(() => {
    try {
      const item = window.localStorage.getItem(key);
      // Parse stored JSON or fall back to initialValue
      return item ? JSON.parse(item) : initialValue;
    } catch (error) {
      console.warn(`useLocalStorage: error reading key "${key}":`, error);
      return initialValue;
    }
  });

  // Persist to localStorage whenever state changes
  useEffect(() => {
    try {
      window.localStorage.setItem(key, JSON.stringify(storedValue));
    } catch (error) {
      console.warn(`useLocalStorage: error writing key "${key}":`, error);
    }
  }, [key, storedValue]);

  return [storedValue, setStoredValue];
}

export default useLocalStorage;
SSR Gotcha

If you're using Next.js or any server-side rendering framework, window is not available on the server. Guard window.localStorage calls inside useEffect (which only runs on the client) or check typeof window !== 'undefined' before accessing it.

Usage is identical to useState — it's a drop-in replacement with persistence:

ThemeToggle.jsx
import useLocalStorage from './hooks/useLocalStorage';

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

  return (
    <button onClick={() => setTheme(t => t === 'light' ? 'dark' : 'light')}>
      Current theme: {theme}
    </button>
  );
}

useDebounce: Taming Rapid Input

Debouncing is the technique of delaying execution until a user has stopped performing an action for a set period. It's essential for search inputs — you don't want to fire an API request on every keystroke. useDebounce is the cleanest way to add this to any component.

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);

    // Cancel the timer if value changes before delay elapses
    return () => clearTimeout(timer);
  }, [value, delay]);

  return debouncedValue;
}

export default useDebounce;
SearchBar.jsx
import { useState } from 'react';
import useDebounce from './hooks/useDebounce';
import useFetch from './hooks/useFetch';

function SearchBar() {
  const [query, setQuery] = useState('');
  // Only updates 400ms after the user stops typing
  const debouncedQuery = useDebounce(query, 400);

  // useFetch only re-runs when debouncedQuery changes
  const { data: results, loading } = useFetch(
    debouncedQuery ? `/api/search?q=${debouncedQuery}` : null
  );

  return (
    <div>
      <input
        value={query}
        onChange={e => setQuery(e.target.value)}
        placeholder="Search..."
      />
      {loading && <Spinner />}
      {results?.map(r => <ResultItem key={r.id} result={r} />)}
    </div>
  );
}

Notice how we compose useDebounce with useFetch — this is a major benefit of the hook pattern. Hooks compose naturally just like regular functions.

Component calls hooks renders UI useFetch fetches + manages state useDebounce delays value update Built-in Hooks useState useEffect useRef useCallback useMemo Your Component Custom Hooks Layer React Core

Custom hooks sit between your components and React's built-in hooks, encapsulating reusable logic.

useWindowSize: Responsive Logic in JS

Sometimes CSS media queries aren't enough — you need your JavaScript to know the window dimensions too. Maybe you're conditionally rendering a mobile menu, or calculating the number of columns in a grid. useWindowSize provides live window dimensions that update on resize.

hooks/useWindowSize.js
import { useState, useEffect } from 'react';

function useWindowSize() {
  const [size, setSize] = useState({
    width: window.innerWidth,
    height: window.innerHeight,
  });

  useEffect(() => {
    function handleResize() {
      setSize({
        width: window.innerWidth,
        height: window.innerHeight,
      });
    }

    window.addEventListener('resize', handleResize);
    // Cleanup removes the listener when component unmounts
    return () => window.removeEventListener('resize', handleResize);
  }, []);

  return size;
}

export default useWindowSize;
Navbar.jsx
import useWindowSize from './hooks/useWindowSize';

function Navbar() {
  const { width } = useWindowSize();
  const isMobile = width < 768;

  return (
    <nav>
      <Logo />
      {isMobile ? <HamburgerMenu /> : <DesktopNav />}
    </nav>
  );
}

usePrevious: Tracking Previous Values

React doesn't give you the previous value of a prop or state out of the box, but it's a common need — for example, animating between the old and new value, or detecting which direction a counter changed. usePrevious uses a useRef to capture the value before each render.

hooks/usePrevious.js
import { useRef, useEffect } from 'react';

function usePrevious(value) {
  const ref = useRef();

  // useEffect runs after render, so ref.current still holds
  // the previous value during the render where value changed
  useEffect(() => {
    ref.current = value;
  }, [value]);

  return ref.current;
}

export default usePrevious;
Counter.jsx
import { useState } from 'react';
import usePrevious from './hooks/usePrevious';

function Counter() {
  const [count, setCount] = useState(0);
  const prevCount = usePrevious(count);

  return (
    <div>
      <p>Current: {count} | Previous: {prevCount ?? 'none'}</p>
      <button onClick={() => setCount(c => c + 1)}>Increment</button>
      <button onClick={() => setCount(c => c - 1)}>Decrement</button>
    </div>
  );
}

useToggle: Boolean State Made Elegant

Toggling boolean state is something every UI does — modals, dropdowns, dark mode, accordion panels. The pattern is repetitive enough that it deserves its own hook.

hooks/useToggle.js
import { useState, useCallback } from 'react';

function useToggle(initialValue = false) {
  const [value, setValue] = useState(initialValue);

  // useCallback ensures toggle is the same function reference
  // across renders — safe to pass as a prop or useEffect dep
  const toggle = useCallback(() => setValue(v => !v), []);
  const setTrue = useCallback(() => setValue(true), []);
  const setFalse = useCallback(() => setValue(false), []);

  return [value, toggle, setTrue, setFalse];
}

export default useToggle;
Modal.jsx
import useToggle from './hooks/useToggle';

function Modal() {
  const [isOpen, toggleModal, openModal, closeModal] = useToggle(false);

  return (
    <div>
      <button onClick={openModal}>Open Modal</button>
      {isOpen && (
        <div className="modal">
          <p>Modal content here</p>
          <button onClick={closeModal}>Close</button>
        </div>
      )}
    </div>
  );
}

When to Extract a Custom Hook

Not every piece of logic needs its own hook. Here's how to decide:

Signal Extract to Hook? Reason
Same state + effect pattern in 2+ components Yes DRY principle, single fix point
Complex logic making component hard to read Yes Separation of concerns
Logic needs to be independently tested Yes Hooks are testable without rendering
Simple one-liner in a single component No Premature abstraction adds indirection
Logic is only about rendering (no state/effects) No Extract as a utility function, not a hook
Logic uses no hooks internally No Doesn't need to be a hook — name it without "use"

Testing Custom Hooks

Custom hooks are significantly easier to test than component logic because you can test the hook in isolation using @testing-library/react's renderHook utility. You get the hook's return values directly without needing to render any UI.

hooks/useToggle.test.js
import { renderHook, act } from '@testing-library/react';
import useToggle from './useToggle';

describe('useToggle', () => {
  test('initializes with the provided value', () => {
    const { result } = renderHook(() => useToggle(true));
    expect(result.current[0]).toBe(true);
  });

  test('toggles the value', () => {
    const { result } = renderHook(() => useToggle(false));
    const [, toggle] = result.current;

    act(() => toggle());
    expect(result.current[0]).toBe(true);

    act(() => toggle());
    expect(result.current[0]).toBe(false);
  });

  test('setTrue always sets to true', () => {
    const { result } = renderHook(() => useToggle(false));
    const [, , setTrue] = result.current;

    act(() => setTrue());
    expect(result.current[0]).toBe(true);

    // Calling setTrue again should still be true
    act(() => setTrue());
    expect(result.current[0]).toBe(true);
  });
});
Always Wrap State Updates in act()

In tests, any action that causes state updates (calling toggle, calling setTrue, simulating events) must be wrapped in act(). This tells React Testing Library to flush all effects and re-renders before you make assertions. Skipping this leads to flaky tests and console warnings.

Advanced: useReducer-Based Hooks

When a hook's internal state has multiple related fields that change together, useReducer is cleaner than multiple useState calls. Here's a more robust useFetch rewritten with useReducer:

hooks/useFetchReducer.js
import { useReducer, useEffect } from 'react';

const initialState = { data: null, loading: false, error: null };

function fetchReducer(state, action) {
  switch (action.type) {
    case 'FETCH_START':
      return { data: null, loading: true, error: null };
    case 'FETCH_SUCCESS':
      return { data: action.payload, loading: false, error: null };
    case 'FETCH_ERROR':
      return { data: null, loading: false, error: action.payload };
    default:
      return state;
  }
}

function useFetch(url) {
  const [state, dispatch] = useReducer(fetchReducer, initialState);

  useEffect(() => {
    if (!url) return;
    const controller = new AbortController();

    dispatch({ type: 'FETCH_START' });

    fetch(url, { signal: controller.signal })
      .then(res => {
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        return res.json();
      })
      .then(data => dispatch({ type: 'FETCH_SUCCESS', payload: data }))
      .catch(err => {
        if (err.name !== 'AbortError') {
          dispatch({ type: 'FETCH_ERROR', payload: err.message });
        }
      });

    return () => controller.abort();
  }, [url]);

  return state;
}

export default useFetch;

The useReducer approach makes state transitions explicit and eliminates the "impossible state" problem — for example, it's no longer possible to have loading: true and error: "something" at the same time, because each action replaces the entire state.

Composing Multiple Custom Hooks

One of the best things about custom hooks is how naturally they compose. Here's a real-world example combining useDebounce, useFetch, and useLocalStorage into a cached search experience:

hooks/useSearch.js
import useDebounce from './useDebounce';
import useFetch from './useFetch';
import useLocalStorage from './useLocalStorage';

function useSearch(endpoint) {
  const [query, setQuery] = useLocalStorage('lastSearch', '');
  const debouncedQuery = useDebounce(query, 400);
  const url = debouncedQuery.trim()
    ? `${endpoint}?q=${encodeURIComponent(debouncedQuery)}`
    : null;
  const { data: results, loading, error } = useFetch(url);

  return {
    query,
    setQuery,
    results: results ?? [],
    loading,
    error,
  };
}

export default useSearch;

This useSearch hook automatically persists the search query to localStorage, debounces API calls, and handles loading/error states — all in 18 lines. Any component that needs search functionality gets all of this for free.

Key Takeaways

  • Custom hooks are just functions — they follow the use naming convention and can call other hooks internally.
  • Extract when you see duplication — if the same state + effect pattern appears in two components, it belongs in a hook.
  • Always clean up effects — return a cleanup function from useEffect to cancel subscriptions, abort fetches, and clear timers.
  • Compose hooks naturally — hooks call other hooks just like functions call other functions, enabling powerful compositions.
  • Test hooks with renderHook — you get clean, isolated tests that don't depend on component rendering.
  • Use useReducer for complex state — when multiple state fields change together, a reducer makes transitions explicit and eliminates impossible states.
  • Don't over-abstract — a single use of a pattern doesn't warrant a custom hook. Wait for the second occurrence before extracting.

Conclusion

Custom hooks are React's answer to the age-old question of how to reuse stateful logic. They give you the power of higher-order components and render props without the awkward nesting, and they make your component code dramatically cleaner in the process. The hooks we built here — useFetch, useLocalStorage, useDebounce, useWindowSize, usePrevious, and useToggle — are genuinely useful in production and represent the most common patterns you'll encounter.

As you build more React applications, you'll develop an instinct for when logic deserves its own hook. Start by watching for copy-pasted state and effect patterns, extract them, and you'll find your components becoming focused, readable, and easy to test. The custom hook you write today might be the building block that saves your team hours next month.

React Hooks Custom JavaScript Frontend
Mayur Dabhi

Mayur Dabhi

Full Stack Developer passionate about building clean, performant web applications. Writes about React, Laravel, DevOps, and everything in between.