React Hooks revolutionized how we build React applications, making functional components the preferred way to write React code. While basic hooks like useState and useEffect are now familiar to most React developers, the true power of hooks emerges when you dive deeper into advanced patterns and custom hook creation. In this article, we'll explore advanced hook techniques that will take your React applications to the next level.

A Quick Review of Basic Hooks

Before diving into advanced patterns, let's briefly review the core hooks that form the foundation of React's functional component API:

  • useState: Manages local component state
  • useEffect: Handles side effects like data fetching, subscriptions, and DOM mutations
  • useContext: Accesses context values without nesting
  • useReducer: Manages complex state logic with a reducer pattern
  • useRef: Creates mutable references that persist across renders
  • useMemo: Memoizes expensive calculations
  • useCallback: Memoizes function references to prevent unnecessary renders

While these hooks are powerful individually, their real potential is unlocked when combined in sophisticated patterns and custom hooks.

Advanced useEffect Patterns

The useEffect hook is deceptively simple but can be used in sophisticated ways.

Handling Cleanup Properly

One of the most important aspects of useEffect is proper cleanup to prevent memory leaks:

import React, { useState, useEffect } from 'react';

function LiveDataComponent({ resourceId }) {
  const [data, setData] = useState(null);
  
  useEffect(() => {
    let isMounted = true;
    const controller = new AbortController();
    const signal = controller.signal;
    
    async function fetchData() {
      try {
        const response = await fetch(`/api/resources/${resourceId}`, { signal });
        const json = await response.json();
        
        // Only update state if component is still mounted
        if (isMounted) {
          setData(json);
        }
      } catch (error) {
        // Don't update state on AbortError (expected when we cleanup)
        if (error.name !== 'AbortError' && isMounted) {
          console.error('Error fetching data:', error);
        }
      }
    }
    
    fetchData();
    
    // Cleanup function
    return () => {
      isMounted = false;
      controller.abort();
    };
  }, [resourceId]); // Re-run when resourceId changes
  
  return (
    
{data ? (

{data.title}

{data.description}

) : (

Loading...

)}
); }

Pro Tip

Always check for component mounting status in asynchronous effects. The isMounted pattern prevents the "Warning: Can't perform a React state update on an unmounted component" error, which can occur when a component unmounts before an async operation completes.

Coordinating Multiple Effects

For complex components, splitting effects by concern improves readability and maintainability:

function UserDashboard({ userId }) {
  const [user, setUser] = useState(null);
  const [preferences, setPreferences] = useState(null);
  const [notifications, setNotifications] = useState([]);
  
  // Effect for user data
  useEffect(() => {
    // Fetch user data
    // ...
  }, [userId]);
  
  // Separate effect for preferences
  useEffect(() => {
    if (!user) return; // Only run once we have user data
    
    // Fetch user preferences
    // ...
  }, [user]);
  
  // Separate effect for real-time notifications
  useEffect(() => {
    if (!user) return;
    
    // Set up WebSocket connection for real-time notifications
    const ws = new WebSocket(`wss://api.example.com/notifications/${userId}`);
    
    ws.onmessage = (event) => {
      const newNotification = JSON.parse(event.data);
      setNotifications(prev => [...prev, newNotification]);
    };
    
    return () => {
      ws.close();
    };
  }, [userId, user]);
  
  // Component JSX...
}

The Dependency Array Pitfalls

Managing the dependency array correctly is crucial for useEffect. Here are common pitfalls and solutions:

// Problem: Object dependency that causes infinite renders
function SearchComponent() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);
  
  // đźš« Bad: New object created every render
  useEffect(() => {
    const fetchData = async () => {
      const response = await fetch(`/api/search?q=${query}&filters=${JSON.stringify(filters)}`);
      const data = await response.json();
      setResults(data);
    };
    
    fetchData();
  }, [query, { category: 'books' }]); // Object recreated each render
  
  // Component JSX...
}

// Solution: Move object dependencies outside effect or memoize them
function ImprovedSearchComponent() {
  const [query, setQuery] = useState('');
  const [category, setCategory] = useState('books');
  const [results, setResults] = useState([]);
  
  // âś… Good: Memoize complex objects
  const filters = useMemo(() => ({ category }), [category]);
  
  useEffect(() => {
    const fetchData = async () => {
      const response = await fetch(`/api/search?q=${query}&filters=${JSON.stringify(filters)}`);
      const data = await response.json();
      setResults(data);
    };
    
    fetchData();
  }, [query, filters]); // Now filters only changes when category changes
  
  // Component JSX...
}

Building Powerful Custom Hooks

Custom hooks are the key to reusable logic in React. Let's explore some advanced patterns for creating custom hooks.

State Management Hooks

Create custom hooks that encapsulate complex state logic:

function useToggle(initialState = false) {
  const [state, setState] = useState(initialState);
  
  const toggle = useCallback(() => setState(state => !state), []);
  const setTrue = useCallback(() => setState(true), []);
  const setFalse = useCallback(() => setState(false), []);
  
  return [state, toggle, setTrue, setFalse];
}

// Usage
function ExpandableSection({ title, children }) {
  const [isExpanded, toggle, expand, collapse] = useToggle(false);
  
  return (
    

{title}

{isExpanded && (
{children}
)}
); }

Async Data Hooks

Custom hooks for data fetching provide a clean API for component data needs:

function useFetch(url, options = {}) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  const [refetchIndex, setRefetchIndex] = useState(0);
  
  const refetch = useCallback(() => {
    setRefetchIndex(prevIndex => prevIndex + 1);
  }, []);
  
  useEffect(() => {
    let isMounted = true;
    const controller = new AbortController();
    const signal = controller.signal;
    
    const fetchData = async () => {
      setLoading(true);
      
      try {
        const response = await fetch(url, {
          signal,
          ...options
        });
        
        if (!response.ok) {
          throw new Error(`HTTP error! Status: ${response.status}`);
        }
        
        const json = await response.json();
        
        if (isMounted) {
          setData(json);
          setError(null);
        }
      } catch (err) {
        if (isMounted && err.name !== 'AbortError') {
          setError(err.message);
          setData(null);
        }
      } finally {
        if (isMounted) {
          setLoading(false);
        }
      }
    };
    
    fetchData();
    
    return () => {
      isMounted = false;
      controller.abort();
    };
  }, [url, refetchIndex, JSON.stringify(options)]);
  
  return { data, loading, error, refetch };
}

// Usage
function UserProfile({ userId }) {
  const { 
    data: user, 
    loading, 
    error,
    refetch 
  } = useFetch(`/api/users/${userId}`);
  
  if (loading) return 
Loading...
; if (error) return
Error: {error}
; return (

{user.name}

{user.email}

); }

DOM Interaction Hooks

Create hooks that encapsulate DOM interactions and browser APIs:

function useMediaQuery(query) {
  const [matches, setMatches] = useState(false);
  
  useEffect(() => {
    const mediaQuery = window.matchMedia(query);
    setMatches(mediaQuery.matches);
    
    const handler = (event) => setMatches(event.matches);
    mediaQuery.addEventListener('change', handler);
    
    return () => mediaQuery.removeEventListener('change', handler);
  }, [query]);
  
  return matches;
}

function useClickOutside(ref, handler) {
  useEffect(() => {
    const listener = (event) => {
      if (!ref.current || ref.current.contains(event.target)) {
        return;
      }
      
      handler(event);
    };
    
    document.addEventListener('mousedown', listener);
    document.addEventListener('touchstart', listener);
    
    return () => {
      document.removeEventListener('mousedown', listener);
      document.removeEventListener('touchstart', listener);
    };
  }, [ref, handler]);
}

// Usage
function MobileResponsiveMenu() {
  const isMobile = useMediaQuery('(max-width: 768px)');
  const [isMenuOpen, setIsMenuOpen] = useState(false);
  const menuRef = useRef(null);
  
  useClickOutside(menuRef, () => {
    if (isMenuOpen) setIsMenuOpen(false);
  });
  
  return (
    
{isMobile ? ( <> {isMenuOpen && (
{/* Menu items */}
)} ) : (
{/* Menu items */}
)}
); }

Complex State Management Patterns

For complex components, we need sophisticated state management approaches.

State Machines with useReducer

The useReducer hook can implement sophisticated state machines:

// State machine for a multi-step form
const FORM_STATES = {
  INITIAL: 'INITIAL',
  SUBMITTING: 'SUBMITTING',
  SUCCESS: 'SUCCESS',
  ERROR: 'ERROR'
};

const formReducer = (state, action) => {
  switch (action.type) {
    case 'SUBMIT':
      return { ...state, status: FORM_STATES.SUBMITTING };
    case 'SUCCESS':
      return { 
        ...state, 
        status: FORM_STATES.SUCCESS, 
        data: action.payload 
      };
    case 'ERROR':
      return { 
        ...state, 
        status: FORM_STATES.ERROR, 
        error: action.payload 
      };
    case 'FIELD_CHANGE':
      return {
        ...state,
        fields: {
          ...state.fields,
          [action.field]: action.value
        },
        touched: {
          ...state.touched,
          [action.field]: true
        }
      };
    case 'RESET':
      return initialFormState;
    default:
      return state;
  }
};

const initialFormState = {
  status: FORM_STATES.INITIAL,
  fields: {
    name: '',
    email: '',
    message: ''
  },
  touched: {},
  data: null,
  error: null
};

function ContactForm() {
  const [formState, dispatch] = useReducer(formReducer, initialFormState);
  const { status, fields, touched, error } = formState;
  
  const handleChange = (e) => {
    const { name, value } = e.target;
    dispatch({ 
      type: 'FIELD_CHANGE', 
      field: name, 
      value 
    });
  };
  
  const handleSubmit = async (e) => {
    e.preventDefault();
    dispatch({ type: 'SUBMIT' });
    
    try {
      const response = await fetch('/api/contact', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json'
        },
        body: JSON.stringify(fields)
      });
      
      if (!response.ok) throw new Error('Submission failed');
      
      const data = await response.json();
      dispatch({ type: 'SUCCESS', payload: data });
    } catch (err) {
      dispatch({ type: 'ERROR', payload: err.message });
    }
  };
  
  // Render different UI based on state
  if (status === FORM_STATES.SUCCESS) {
    return (
      

Thank you for your message!

We'll get back to you soon.

); } return (
{/* Form fields */}
{/* More fields... */} {status === FORM_STATES.ERROR && (
Error: {error}
)}
); }

Context + Reducer Pattern

Combine useContext and useReducer for state that spans multiple components:

// Create context for a shopping cart
const CartContext = React.createContext();

// Actions
const ADD_TO_CART = 'ADD_TO_CART';
const REMOVE_FROM_CART = 'REMOVE_FROM_CART';
const UPDATE_QUANTITY = 'UPDATE_QUANTITY';
const CLEAR_CART = 'CLEAR_CART';

// Reducer
function cartReducer(state, action) {
  switch (action.type) {
    case ADD_TO_CART: {
      const { product } = action.payload;
      const existingItem = state.items.find(item => item.id === product.id);
      
      if (existingItem) {
        return {
          ...state,
          items: state.items.map(item => 
            item.id === product.id 
              ? { ...item, quantity: item.quantity + 1 }
              : item
          )
        };
      } else {
        return {
          ...state,
          items: [...state.items, { ...product, quantity: 1 }]
        };
      }
    }
    
    case REMOVE_FROM_CART:
      return {
        ...state,
        items: state.items.filter(item => item.id !== action.payload.id)
      };
      
    case UPDATE_QUANTITY:
      return {
        ...state,
        items: state.items.map(item => 
          item.id === action.payload.id 
            ? { ...item, quantity: action.payload.quantity }
            : item
        )
      };
      
    case CLEAR_CART:
      return {
        ...state,
        items: []
      };
      
    default:
      return state;
  }
}

// Provider component
function CartProvider({ children }) {
  const [cart, dispatch] = useReducer(cartReducer, { items: [] });
  
  const addToCart = useCallback((product) => {
    dispatch({ type: ADD_TO_CART, payload: { product } });
  }, []);
  
  const removeFromCart = useCallback((id) => {
    dispatch({ type: REMOVE_FROM_CART, payload: { id } });
  }, []);
  
  const updateQuantity = useCallback((id, quantity) => {
    dispatch({ type: UPDATE_QUANTITY, payload: { id, quantity } });
  }, []);
  
  const clearCart = useCallback(() => {
    dispatch({ type: CLEAR_CART });
  }, []);
  
  // Calculate derived state
  const itemCount = useMemo(() => 
    cart.items.reduce((total, item) => total + item.quantity, 0),
    [cart.items]
  );
  
  const totalPrice = useMemo(() => 
    cart.items.reduce((total, item) => total + (item.price * item.quantity), 0),
    [cart.items]
  );
  
  const value = {
    cart,
    itemCount,
    totalPrice,
    addToCart,
    removeFromCart,
    updateQuantity,
    clearCart
  };
  
  return (
    
      {children}
    
  );
}

// Custom hook for consuming the context
function useCart() {
  const context = useContext(CartContext);
  if (!context) {
    throw new Error('useCart must be used within a CartProvider');
  }
  return context;
}

// Usage
function App() {
  return (
    
      
); } function ProductCard({ product }) { const { addToCart } = useCart(); return (

{product.name}

${product.price.toFixed(2)}

); } function Cart() { const { cart, itemCount, totalPrice, removeFromCart, updateQuantity, clearCart } = useCart(); if (itemCount === 0) { return
Your cart is empty
; } return (

Your Cart ({itemCount} items)

{cart.items.map(item => (

{item.name}

${item.price.toFixed(2)} each

{ const value = parseInt(e.target.value); if (value > 0) { updateQuantity(item.id, value); } }} />
))}

Total: ${totalPrice.toFixed(2)}

); }

Performance Optimization Hooks

React's performance optimization hooks—useMemo and useCallback—require thoughtful application.

Smart Memoization Strategies

Use memoization strategically to avoid performance pitfalls:

function DataGrid({ data, sortField, filterText }) {
  // âś… Memoize expensive filtering and sorting
  const processedData = useMemo(() => {
    console.log('Processing data...');
    
    // Filter data
    let result = filterText
      ? data.filter(item => 
          item.name.toLowerCase().includes(filterText.toLowerCase()))
      : data;
    
    // Sort data
    result = [...result].sort((a, b) => {
      if (a[sortField] < b[sortField]) return -1;
      if (a[sortField] > b[sortField]) return 1;
      return 0;
    });
    
    return result;
  }, [data, sortField, filterText]); // Recompute only when these values change
  
  // âś… Memoize callback used in child components
  const handleRowClick = useCallback((id) => {
    console.log(`Row clicked: ${id}`);
    // Handle row selection...
  }, []); // No dependencies = stable function reference
  
  return (
    
{processedData.map(item => ( ))}
); } // âś… Memoize child component to prevent unnecessary renders const DataRow = React.memo(function DataRow({ data, onClick }) { console.log(`Rendering row: ${data.id}`); return (
onClick(data.id)} >
{data.name}
{data.email}
{/* More cells... */}
); });

Using useRef for Performance

The useRef hook can help optimize performance in specific scenarios:

function SearchComponent() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);
  
  // Store the previous query to avoid unnecessary API calls
  const previousQueryRef = useRef('');
  
  // Debounce search input to reduce API calls
  const debouncedSearch = useRef(
    debounce(async (searchTerm) => {
      if (searchTerm === previousQueryRef.current) return;
      
      previousQueryRef.current = searchTerm;
      
      try {
        const response = await fetch(`/api/search?q=${searchTerm}`);
        const data = await response.json();
        setResults(data);
      } catch (error) {
        console.error('Search failed:', error);
      }
    }, 300)
  ).current;
  
  // Use this pattern to avoid recreating functions in every render
  const handleInputChange = (e) => {
    const value = e.target.value;
    setQuery(value);
    debouncedSearch(value);
  };
  
  return (
    
{results.map(item => (
{item.title}
))}
); } // Simple debounce implementation function debounce(fn, delay) { let timeoutId; return function(...args) { clearTimeout(timeoutId); timeoutId = setTimeout(() => fn(...args), delay); }; }

Testing Custom Hooks

Testing hooks is essential for reliable applications. Let's examine effective approaches to hook testing.

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

export function useCounter(initialValue = 0) {
  const [count, setCount] = useState(initialValue);
  
  const increment = useCallback(() => setCount(c => c + 1), []);
  const decrement = useCallback(() => setCount(c => c - 1), []);
  const reset = useCallback(() => setCount(initialValue), [initialValue]);
  
  return { count, increment, decrement, reset };
}

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

describe('useCounter', () => {
  test('should initialize with default value', () => {
    const { result } = renderHook(() => useCounter());
    expect(result.current.count).toBe(0);
  });
  
  test('should initialize with provided value', () => {
    const { result } = renderHook(() => useCounter(10));
    expect(result.current.count).toBe(10);
  });
  
  test('should increment counter', () => {
    const { result } = renderHook(() => useCounter());
    
    act(() => {
      result.current.increment();
    });
    
    expect(result.current.count).toBe(1);
  });
  
  test('should decrement counter', () => {
    const { result } = renderHook(() => useCounter(5));
    
    act(() => {
      result.current.decrement();
    });
    
    expect(result.current.count).toBe(4);
  });
  
  test('should reset counter', () => {
    const { result } = renderHook(() => useCounter(5));
    
    act(() => {
      result.current.increment();
      result.current.increment();
    });
    
    expect(result.current.count).toBe(7);
    
    act(() => {
      result.current.reset();
    });
    
    expect(result.current.count).toBe(5);
  });
  
  test('should update with new initial value', () => {
    const { result, rerender } = renderHook(
      ({ initialValue }) => useCounter(initialValue),
      { initialProps: { initialValue: 5 } }
    );
    
    expect(result.current.count).toBe(5);
    
    rerender({ initialValue: 10 });
    
    act(() => {
      result.current.reset();
    });
    
    expect(result.current.count).toBe(10);
  });
});

Organizing Custom Hooks

As your application grows, you'll create many custom hooks. Here are strategies for organizing them:

  • Group by function: Create directories like /hooks/api, /hooks/forms, and /hooks/ui to organize hooks by purpose
  • Co-locate with components: For component-specific hooks, keep them in the same directory as the components that use them
  • Create hook libraries: For reusable hooks used across projects, consider creating internal libraries
  • Document extensively: Include JSDoc comments that describe the hook's purpose, parameters, return values, and example usage
// Example hook structure
src/
  hooks/
    api/
      useFetch.js
      useQuery.js
      useMutation.js
    forms/
      useForm.js
      useField.js
      useValidation.js
    ui/
      useMediaQuery.js
      useClickOutside.js
      useLocalStorage.js
    index.js  // Re-export all hooks

Conclusion

React Hooks have fundamentally changed how we build React applications, enabling cleaner, more reusable code with functional components. By mastering advanced hook patterns and creating well-designed custom hooks, you can build more maintainable, performant React applications.

The techniques covered in this article—from sophisticated useEffect patterns to state machines with useReducer to performance optimizations—provide a foundation for taking your React skills to the next level. Custom hooks in particular represent one of React's most powerful patterns for code reuse and abstraction.

As you apply these techniques in your own projects, remember that hooks are tools to make your code more readable and maintainable. The best hook implementations are those that simplify your components and create clear separations of concerns. Happy hooking!