Frontend

React Custom Hooks: Creating Your Own

Mayur Dabhi
Mayur Dabhi
April 14, 2026
14 min read

You open a React codebase and find the same three useState calls and the same useEffect fetch block in six different components. One of them aborts the request on unmount, two of them reset the error state, and the rest do neither. Somebody fixed a bug in one copy last month and the other five still have it.

Custom hooks are how you stop that from happening. This post starts with what a custom hook actually is, then builds a handful I use all the time (useFetch, useLocalStorage, useDebounce, useWindowSize, usePrevious, useToggle), covers testing them, and ends with where they go wrong.

Before You Start

You'll want to be comfortable with useState, useEffect, useRef, and useCallback first. Every custom hook here is just those built-ins combined, so if they make sense, the rest will too.

What Is a Custom Hook?

It's a plain JavaScript function whose name starts with use and that calls other hooks. That's it. There's no special API and no magic syntax. The use prefix is a convention React (and the ESLint plugin) relies on to spot hook calls and enforce the rules of hooks.

Before hooks, sharing stateful logic meant render props or higher-order components. Both work, but they wrap your component in extra layers, and in a big app you end up with a deeply nested tree where it's hard to tell which wrapper injected which prop. A custom hook is a function the component calls directly, so the component tree doesn't change at all.

The Problem Custom Hooks Solve

Say several components each need to fetch data from an API, track a loading state, and handle errors. Without a custom hook, every one of them carries its own copy of this:

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

Copy those 20 lines into every component that fetches data and you get exactly the situation from the intro: bugs fixed in one place but not the others. Notice this version also has no cleanup, so a fast userId change can let an older response land after a newer one.

Your First Custom Hook: useFetch

Pulling that logic into a useFetch hook is mostly cut and paste. You move the state and effects into a function and return whatever the 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 shrank from about 25 lines to 8. The bigger win is the AbortController cleanup. People forget it all the time, and now every component that calls useFetch gets it without thinking about it.

useLocalStorage: Persistent State

useLocalStorage behaves like useState, except the value is written to localStorage and read back on the next load. I reach for it for theme settings, user preferences, and form drafts: anything 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 other server-side rendering setup, window doesn't exist on the server and you'll get ReferenceError: window is not defined. Guard window.localStorage calls inside useEffect (which only runs on the client) or check typeof window !== 'undefined' first. Also note this hook doesn't sync between tabs. If that matters, listen for the storage event on window.

You use it exactly like useState:

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

Type "react hooks" into a search box with no debounce and you've sent eleven API requests, most of them for results nobody will see. Debouncing waits until the user stops typing for a set time and only then passes the value along. useDebounce wraps that in a few lines.

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

Here useDebounce feeds straight into useFetch. Hooks compose the same way regular functions do, which is a big part of why the pattern works.

Watch the null URL

This component passes null to useFetch when the query is empty, but the first useFetch above doesn't check for that. fetch(null) gets turned into a request for the relative path /null, so you'll see a stray 404 in the Network tab. Add if (!url) return; at the top of the effect, like the useReducer version later in this post does. You also can't "fix" it by wrapping the hook call in if (query): ESLint will complain that React Hook "useFetch" is called conditionally, and it's right.

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 and your JavaScript needs the window size too. Maybe you render a completely different menu on mobile, or you calculate the number of grid columns. useWindowSize gives you the current dimensions and updates on resize. (The same SSR warning applies here: window.innerWidth in the initial state will throw on the server.)

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 hand you the previous value of a prop or state, but you need it more often than you'd think: animating from the old value to the new one, or figuring out which direction a counter moved. usePrevious stores the value in a useRef after each render, so during the next render the ref still holds the old one.

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: Less Boolean Boilerplate

Modals, dropdowns, dark mode, accordion panels: every UI toggles booleans. You write setOpen(o => !o) so often that it's worth a tiny 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, so it's 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. This is roughly how I 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 renderHook tests it without building UI
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 Make it a plain function and drop the "use" prefix

Testing Custom Hooks

Logic inside a hook is easier to test than the same logic buried in a component. renderHook from @testing-library/react mounts the hook in a throwaway test component and hands you its return value through result.current, so you don't have to build any UI or query the DOM.

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, anything that triggers a state update (calling toggle, calling setTrue, firing events) needs to go inside act(), so React flushes effects and re-renders before your assertions run. Skip it and you'll get the "not wrapped in act(...)" warning in the console, plus tests that pass or fail depending on timing. Also, read from result.current after each update. A value you destructured earlier is stale.

Advanced: useReducer-Based Hooks

When a hook has several state fields that always change together, useReducer is tidier than a pile of useState calls. Here's useFetch again, rewritten with a reducer and with the null URL check added:

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;

Each state transition is now spelled out in one place, and "impossible states" go away. You can't end up with loading: true and error: "something" at the same time, because every action replaces the whole state object.

Composing Multiple Custom Hooks

Hooks can be stacked into bigger hooks. This one combines useDebounce, useFetch, and useLocalStorage into a search hook that remembers the last query:

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;

useSearch saves the query to localStorage, debounces the API calls, and tracks loading and error state. A component that needs search just calls it and renders the results.

Mistakes I See Most Often

  • Expecting hooks to share state. Two components calling useLocalStorage('theme') each get their own useState. Change it in one and the other won't update until a reload. A hook shares logic, not state. For shared state you want context or a store.
  • Passing a fresh object or array as a dependency. useFetch(url, { headers }) with an inline object gives the effect a new dependency every render, so it refetches in a loop. Memoize it or pass primitives.
  • Forgetting cleanup. Timers, listeners, subscriptions and in-flight fetches all need a cleanup function, or you'll see stale data and memory leaks.
  • Prefixing non-hooks with use. If a function calls no hooks, the prefix just makes the linter apply rules that don't matter and confuses the next reader.

When I'd Write a Custom Hook (and When I Wouldn't)

I extract a hook when the same state-plus-effect pattern shows up in a second component, or when a component's logic gets long enough that I can't see the JSX for the useEffect blocks. Wrapping something like a WebSocket connection or a resize listener in a hook also keeps the cleanup in one place, which is where most of the bugs live.

I wouldn't write one for logic used once, and I'd wait for the second occurrence before abstracting. A hook with seven options and three boolean flags is usually two hooks pretending to be one. And for data fetching specifically, the useFetch in this post is fine for small apps, but it has no caching, no request deduplication, and no retries. Once you need those, use TanStack Query or SWR instead of growing your own.

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.