DEV Community

Cover image for Custom React Hooks — Extracting and Reusing Stateful Logic
Aon infotech
Aon infotech

Posted on

Custom React Hooks — Extracting and Reusing Stateful Logic

Custom hooks are one of React's most underused tools. They let you extract stateful logic from components into reusable functions — without adding components to your tree, without changing component hierarchy, and without any of the complexity of higher-order components or render props.

Here's the pattern, with practical examples from the kind of stateful logic that shows up repeatedly in real applications, including the generation state patterns used at pixova.io/blog/free-ai-architecture-generator.

The Core Idea

Any logic that uses React hooks (useState, useEffect, useRef, useCallback) can be extracted into a custom hook. The hook is just a function whose name starts with use. It can call any hooks internally and return whatever the calling component needs.

// Before — component with embedded stateful logic
function SearchBox() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);
  const [isLoading, setIsLoading] = useState(false);

  useEffect(() => {
    if (!query) {
      setResults([]);
      return;
    }

    const timeoutId = setTimeout(async () => {
      setIsLoading(true);
      const data = await searchApi(query);
      setResults(data);
      setIsLoading(false);
    }, 300);

    return () => clearTimeout(timeoutId);
  }, [query]);

  return (/* ...render... */);
}
Enter fullscreen mode Exit fullscreen mode

That search logic — debounced API call, loading state, results — is now locked inside this component. You can't reuse it in a different search component without copying the logic.

// After — extracted into a custom hook
function useSearch(searchFn: (query: string) => Promise<any[]>, debounceMs = 300) {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);
  const [isLoading, setIsLoading] = useState(false);

  useEffect(() => {
    if (!query) {
      setResults([]);
      return;
    }

    const timeoutId = setTimeout(async () => {
      setIsLoading(true);
      const data = await searchFn(query);
      setResults(data);
      setIsLoading(false);
    }, debounceMs);

    return () => clearTimeout(timeoutId);
  }, [query, searchFn, debounceMs]);

  return { query, setQuery, results, isLoading };
}

// Reusable anywhere
function SearchBox() {
  const { query, setQuery, results, isLoading } = useSearch(searchApi);
  return (/* ...render... */);
}

function UserSearch() {
  const { query, setQuery, results, isLoading } = useSearch(searchUsers, 500);
  return (/* ...render... */);
}
Enter fullscreen mode Exit fullscreen mode

Practical Custom Hook Examples

useLocalStorage — Persisted State

function useLocalStorage<T>(key: string, initialValue: T) {
  const [storedValue, setStoredValue] = useState<T>(() => {
    if (typeof window === 'undefined') return initialValue;

    try {
      const item = window.localStorage.getItem(key);
      return item ? JSON.parse(item) : initialValue;
    } catch {
      return initialValue;
    }
  });

  const setValue = useCallback((value: T | ((val: T) => T)) => {
    try {
      const valueToStore = value instanceof Function ? value(storedValue) : value;
      setStoredValue(valueToStore);

      if (typeof window !== 'undefined') {
        window.localStorage.setItem(key, JSON.stringify(valueToStore));
      }
    } catch (error) {
      console.error(`Error saving to localStorage key "${key}":`, error);
    }
  }, [key, storedValue]);

  return [storedValue, setValue] as const;
}

// Usage — exactly like useState but persisted
const [theme, setTheme] = useLocalStorage('theme', 'light');
const [preferences, setPreferences] = useLocalStorage('user-prefs', defaultPrefs);
Enter fullscreen mode Exit fullscreen mode

useAsync — Managing Async Operations

type AsyncState<T> = {
  status: 'idle' | 'pending' | 'success' | 'error';
  data: T | null;
  error: Error | null;
};

function useAsync<T>() {
  const [state, setState] = useState<AsyncState<T>>({
    status: 'idle',
    data: null,
    error: null,
  });

  const execute = useCallback(async (promise: Promise<T>) => {
    setState({ status: 'pending', data: null, error: null });

    try {
      const data = await promise;
      setState({ status: 'success', data, error: null });
      return data;
    } catch (error) {
      const err = error instanceof Error ? error : new Error(String(error));
      setState({ status: 'error', data: null, error: err });
      throw err;
    }
  }, []);

  const reset = useCallback(() => {
    setState({ status: 'idle', data: null, error: null });
  }, []);

  return {
    ...state,
    execute,
    reset,
    isIdle: state.status === 'idle',
    isPending: state.status === 'pending',
    isSuccess: state.status === 'success',
    isError: state.status === 'error',
  };
}

// Usage
function GenerateButton() {
  const { execute, isPending, data, error } = useAsync<GeneratedImage>();

  const handleGenerate = async () => {
    await execute(generateImage(prompt));
  };

  return (
    <div>
      <button onClick={handleGenerate} disabled={isPending}>
        {isPending ? 'Generating...' : 'Generate'}
      </button>
      {data && <img src={data.url} alt="Generated" />}
      {error && <p>Error: {error.message}</p>}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

useMediaQuery — Responsive Logic in Components

function useMediaQuery(query: string): boolean {
  const [matches, setMatches] = useState(() => {
    if (typeof window === 'undefined') return false;
    return window.matchMedia(query).matches;
  });

  useEffect(() => {
    if (typeof window === 'undefined') return;

    const mediaQuery = window.matchMedia(query);
    const handler = (event: MediaQueryListEvent) => setMatches(event.matches);

    mediaQuery.addEventListener('change', handler);
    setMatches(mediaQuery.matches);

    return () => mediaQuery.removeEventListener('change', handler);
  }, [query]);

  return matches;
}

// Usage
function ResponsiveComponent() {
  const isMobile = useMediaQuery('(max-width: 768px)');
  const prefersReducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)');

  return (
    <div>
      {isMobile ? <MobileLayout /> : <DesktopLayout />}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

useClickOutside — Dismissing Popups

function useClickOutside<T extends HTMLElement>(
  callback: () => void
): React.RefObject<T> {
  const ref = useRef<T>(null);

  useEffect(() => {
    const handler = (event: MouseEvent) => {
      if (ref.current && !ref.current.contains(event.target as Node)) {
        callback();
      }
    };

    document.addEventListener('mousedown', handler);
    return () => document.removeEventListener('mousedown', handler);
  }, [callback]);

  return ref;
}

// Usage
function Dropdown({ onClose }) {
  const dropdownRef = useClickOutside<HTMLDivElement>(onClose);

  return (
    <div ref={dropdownRef} className="dropdown">
      {/* dropdown content */}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Rules for Custom Hooks

Must start with use. React's linting rules enforce this. It's not just convention — it tells React to apply its hook rules to the function.

Can call other hooks. Custom hooks can call useState, useEffect, other custom hooks — anything. The rules of hooks (call at top level, not in conditions) apply inside custom hooks too.

Don't share state. Each component that calls a custom hook gets its own isolated state. If you need shared state between components, use context, Zustand, or another state management solution.

Return what the caller needs. There's no required return shape — objects, arrays, single values, or nothing. Design the return value around what calling components actually need.

When to Extract a Custom Hook

A good signal: you've copied the same useState + useEffect combination to two different components. Extract it.

Another signal: a component has more than two or three separate concerns managed with hooks. Separate them into hooks with clear single responsibilities.

A final signal: you want to test stateful logic without mounting a component. Custom hooks are testable with React Testing Library's renderHook without any component mounting.

Summary

Custom hooks extract stateful logic into reusable functions. They're the clean solution to the "I need to reuse this logic" problem — no extra components, no prop drilling, no wrapper complexity.

The pattern is simple: identify logic that uses React hooks, move it to a use-prefixed function, return what components need. Start with your most-duplicated stateful patterns and extract from there.

Testing Custom Hooks

renderHook from React Testing Library tests hooks without component wrappers:

import { renderHook, act } from '@testing-library/react';
import { useLocalStorage } from './useLocalStorage';

test('useLocalStorage initializes with stored value', () => {
  localStorage.setItem('test-key', JSON.stringify('stored-value'));

  const { result } = renderHook(() => useLocalStorage('test-key', 'default'));

  expect(result.current[0]).toBe('stored-value');
});

test('useLocalStorage updates localStorage on setValue', () => {
  const { result } = renderHook(() => useLocalStorage('test-key', 'initial'));

  act(() => {
    result.current[1]('updated');
  });

  expect(result.current[0]).toBe('updated');
  expect(JSON.parse(localStorage.getItem('test-key')!)).toBe('updated');
});
Enter fullscreen mode Exit fullscreen mode

Testing hooks in isolation makes it easy to cover all the edge cases — error handling, initial state, state transitions — without rendering full component trees.

Top comments (0)