React Custom Hooks: Extracting Reusable Logic

Custom hooks are JavaScript functions that leverage React's built-in hooks to encapsulate reusable stateful logic. They're one of React's most powerful features for code organization, yet many...

Key Insights

  • Custom hooks eliminate code duplication by extracting stateful logic into reusable functions that follow React’s hooks rules
  • Well-designed custom hooks should handle their own cleanup, error states, and edge cases to provide a clean API to consuming components
  • Testing custom hooks in isolation using renderHook from React Testing Library ensures they work correctly across different scenarios

Introduction to Custom Hooks

Custom hooks are JavaScript functions that leverage React’s built-in hooks to encapsulate reusable stateful logic. They’re one of React’s most powerful features for code organization, yet many developers underutilize them by keeping repetitive logic scattered across components.

The problem is simple: you find yourself copying the same useState, useEffect, and event handler patterns across multiple components. A modal needs toggle logic. A dropdown needs toggle logic. A sidebar needs toggle logic. Each implementation is slightly different, making bugs harder to track and updates tedious.

Custom hooks solve this by extracting the shared logic into a single function. The rules are straightforward: your hook must start with “use” (so React can enforce hooks rules), and you can only call hooks at the top level of your custom hook, never inside conditions or loops. Beyond that, you have complete freedom to compose React’s primitives however you need.

Your First Custom Hook: useToggle

Let’s start with a common pattern: toggling boolean state. Before extraction, your component might look like this:

function Modal() {
  const [isOpen, setIsOpen] = useState(false);
  
  const open = () => setIsOpen(true);
  const close = () => setIsOpen(false);
  const toggle = () => setIsOpen(prev => !prev);
  
  return (
    <>
      <button onClick={toggle}>Toggle Modal</button>
      {isOpen && <div className="modal">Content</div>}
    </>
  );
}

You’ll write this exact pattern dozens of times. Here’s the extracted hook:

function useToggle(initialValue = false) {
  const [value, setValue] = useState(initialValue);
  
  const toggle = useCallback(() => {
    setValue(prev => !prev);
  }, []);
  
  const setTrue = useCallback(() => {
    setValue(true);
  }, []);
  
  const setFalse = useCallback(() => {
    setValue(false);
  }, []);
  
  return [value, { toggle, setTrue, setFalse }];
}

Now your components become cleaner:

function Modal() {
  const [isOpen, { toggle, setFalse }] = useToggle();
  
  return (
    <>
      <button onClick={toggle}>Toggle Modal</button>
      {isOpen && (
        <div className="modal">
          <button onClick={setFalse}>Close</button>
          Content
        </div>
      )}
    </>
  );
}

Notice the useCallback wrappers. They ensure the returned functions maintain stable references, preventing unnecessary re-renders in components that depend on them.

Hooks with External Dependencies: useDebounce

Debouncing is another perfect candidate for extraction. Without a custom hook, debouncing search input requires managing timers and cleanup across multiple components:

function useDebounce(value, delay = 500) {
  const [debouncedValue, setDebouncedValue] = useState(value);
  
  useEffect(() => {
    const timer = setTimeout(() => {
      setDebouncedValue(value);
    }, delay);
    
    return () => {
      clearTimeout(timer);
    };
  }, [value, delay]);
  
  return debouncedValue;
}

The cleanup function is critical here. Every time value changes, we clear the previous timer before setting a new one. This prevents memory leaks and ensures only the latest value triggers the debounce.

Usage in a search component:

function SearchUsers() {
  const [searchTerm, setSearchTerm] = useState('');
  const debouncedSearch = useDebounce(searchTerm, 300);
  
  useEffect(() => {
    if (debouncedSearch) {
      fetch(`/api/users?q=${debouncedSearch}`)
        .then(res => res.json())
        .then(data => {
          // Handle results
        });
    }
  }, [debouncedSearch]);
  
  return (
    <input
      value={searchTerm}
      onChange={(e) => setSearchTerm(e.target.value)}
      placeholder="Search users..."
    />
  );
}

The component doesn’t know or care about timer management. It just receives a debounced value.

Hooks that Return Multiple Values: useFetch

Data fetching is repetitive boilerplate in most applications. You need loading states, error handling, and data management every single time. Here’s a robust implementation:

function useFetch(url, options = {}) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  
  useEffect(() => {
    let cancelled = false;
    
    const fetchData = async () => {
      setLoading(true);
      setError(null);
      
      try {
        const response = await fetch(url, options);
        
        if (!response.ok) {
          throw new Error(`HTTP error! status: ${response.status}`);
        }
        
        const json = await response.json();
        
        if (!cancelled) {
          setData(json);
        }
      } catch (err) {
        if (!cancelled) {
          setError(err.message);
        }
      } finally {
        if (!cancelled) {
          setLoading(false);
        }
      }
    };
    
    fetchData();
    
    return () => {
      cancelled = true;
    };
  }, [url, options]);
  
  return { data, loading, error };
}

The cancelled flag prevents state updates after unmount, avoiding the infamous “Can’t perform a React state update on an unmounted component” warning.

Using it in a component:

function UserList() {
  const { data: users, loading, error } = useFetch('/api/users');
  
  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error}</div>;
  
  return (
    <ul>
      {users.map(user => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

Clean, declarative, and all the complexity is hidden in the hook.

Advanced Pattern: useLocalStorage

Syncing state with localStorage requires handling serialization, parsing errors, and storage events. Here’s a production-ready implementation:

function useLocalStorage(key, initialValue) {
  const [storedValue, setStoredValue] = useState(() => {
    try {
      const item = window.localStorage.getItem(key);
      return item ? JSON.parse(item) : initialValue;
    } catch (error) {
      console.error(`Error reading localStorage key "${key}":`, error);
      return initialValue;
    }
  });
  
  const setValue = useCallback((value) => {
    try {
      const valueToStore = value instanceof Function 
        ? value(storedValue) 
        : value;
      
      setStoredValue(valueToStore);
      window.localStorage.setItem(key, JSON.stringify(valueToStore));
    } catch (error) {
      console.error(`Error setting localStorage key "${key}":`, error);
    }
  }, [key, storedValue]);
  
  useEffect(() => {
    const handleStorageChange = (e) => {
      if (e.key === key && e.newValue) {
        try {
          setStoredValue(JSON.parse(e.newValue));
        } catch (error) {
          console.error(`Error parsing storage event for key "${key}":`, error);
        }
      }
    };
    
    window.addEventListener('storage', handleStorageChange);
    return () => window.removeEventListener('storage', handleStorageChange);
  }, [key]);
  
  return [storedValue, setValue];
}

This handles lazy initialization, functional updates, sync across tabs, and graceful error handling. Use it like useState:

function DarkModeToggle() {
  const [isDark, setIsDark] = useLocalStorage('darkMode', false);
  
  return (
    <button onClick={() => setIsDark(prev => !prev)}>
      {isDark ? '☀️' : '🌙'}
    </button>
  );
}

The theme preference persists across sessions and syncs between tabs automatically.

Testing Custom Hooks

Custom hooks should be tested in isolation. React Testing Library provides renderHook for this:

import { renderHook, waitFor } from '@testing-library/react';
import { useFetch } from './useFetch';

describe('useFetch', () => {
  beforeEach(() => {
    global.fetch = jest.fn();
  });
  
  it('should fetch data successfully', async () => {
    const mockData = { users: ['Alice', 'Bob'] };
    global.fetch.mockResolvedValueOnce({
      ok: true,
      json: async () => mockData,
    });
    
    const { result } = renderHook(() => useFetch('/api/users'));
    
    expect(result.current.loading).toBe(true);
    expect(result.current.data).toBe(null);
    
    await waitFor(() => {
      expect(result.current.loading).toBe(false);
    });
    
    expect(result.current.data).toEqual(mockData);
    expect(result.current.error).toBe(null);
  });
  
  it('should handle errors', async () => {
    global.fetch.mockResolvedValueOnce({
      ok: false,
      status: 404,
    });
    
    const { result } = renderHook(() => useFetch('/api/users'));
    
    await waitFor(() => {
      expect(result.current.loading).toBe(false);
    });
    
    expect(result.current.error).toBeTruthy();
    expect(result.current.data).toBe(null);
  });
});

Testing hooks separately from components makes tests faster and more focused.

Best Practices and Common Pitfalls

Extract a custom hook when you find the same logic in three or more places. Two instances might be coincidence; three is a pattern worth abstracting.

Avoid premature extraction. A hook used in only one component adds indirection without benefit. Keep it inline until you need it elsewhere.

Name your hooks descriptively. useUserAuthentication is better than useAuth. The extra characters communicate intent.

Return objects for hooks with many values: const { data, loading, error, refetch } = useFetch(). Return arrays for hooks with two values following the useState pattern: const [value, setValue] = useLocalStorage().

Watch for unnecessary re-renders. If your hook returns new object or function references on every render, wrap them in useMemo or useCallback.

Don’t reinvent the wheel. Libraries like react-use and ahooks provide battle-tested implementations of common hooks. Use them for standard patterns, write custom hooks for your domain-specific logic.

Custom hooks are React’s answer to mixins and higher-order components. They provide composition without the wrapper hell. Master them, and your components become cleaner, your logic more testable, and your codebase more maintainable.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.