Back to articles
React

React Hooks Deep Dive: Custom Hooks and Advanced Patterns

Explore advanced React Hooks patterns, create reusable custom hooks, and learn about hook composition, optimization, and common pitfalls to avoid.

12 mins read
ReactHooksCustom HooksPatterns

React Hooks revolutionized how we write React components by allowing state and lifecycle logic in functional components. This deep dive explores advanced hook patterns, custom hooks, and optimization techniques.

Understanding Built-in Hooks

Before diving into custom hooks, let's review the built-in hooks and their advanced use cases.

Interactive Hook Example

Try modifying this counter component to see hooks in action!

import { useState, useCallback } from 'react';

function Counter(): JSX.Element {
  const [count, setCount] = useState<number>(0);
  const [step, setStep] = useState<number>(1);

  const increment = useCallback(() => {
    setCount(prev => prev + step);
  }, [step]);

  const decrement = useCallback(() => {
    setCount(prev => prev - step);
  }, [step]);

  const reset = useCallback(() => {
    setCount(0);
  }, []);

  return (
    <div style={{ padding: '20px', textAlign: 'center' }}>
      <h1>Counter: {count}</h1>
      <div style={{ margin: '20px 0' }}>
        <label>
          Step: 
          <input 
            type="number" 
            value={step} 
            onChange={(e) => setStep(Number(e.target.value))}
            style={{ marginLeft: '10px', padding: '5px' }}
          />
        </label>
      </div>
      <div>
        <button onClick={decrement} style={{ margin: '0 10px', padding: '10px 20px' }}>
          - {step}
        </button>
        <button onClick={reset} style={{ margin: '0 10px', padding: '10px 20px' }}>
          Reset
        </button>
        <button onClick={increment} style={{ margin: '0 10px', padding: '10px 20px' }}>
          + {step}
        </button>
      </div>
    </div>
  );
}

export default function App(): JSX.Element {
  return <Counter />;
}
ReactAdvancedHookUsage.tsx
import { useState, useEffect, useCallback, useMemo, useRef, useReducer } from 'react';// Advanced useState patternsfunction 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 };}// useReducer for complex state logicinterface State {  count: number;  loading: boolean;  error: string | null;}type Action =   | { type: 'increment' }  | { type: 'decrement' }  | { type: 'setLoading'; payload: boolean }  | { type: 'setError'; payload: string | null };function counterReducer(state: State, action: Action): State {  switch (action.type) {    case 'increment':      return { ...state, count: state.count + 1, error: null };    case 'decrement':      return { ...state, count: state.count - 1, error: null };    case 'setLoading':      return { ...state, loading: action.payload };    case 'setError':      return { ...state, error: action.payload, loading: false };    default:      return state;  }}function ComplexCounter() {  const [state, dispatch] = useReducer(counterReducer, {    count: 0,    loading: false,    error: null  });  const incrementAsync = useCallback(async () => {    dispatch({ type: 'setLoading', payload: true });    try {      // Simulate API call      await new Promise(resolve => setTimeout(resolve, 1000));      dispatch({ type: 'increment' });    } catch (error) {      dispatch({ type: 'setError', payload: 'Failed to increment' });    } finally {      dispatch({ type: 'setLoading', payload: false });    }  }, []);  return (    <div>      <p>Count: {state.count}</p>      {state.error && <p style={{ color: 'red' }}>{state.error}</p>}      <button onClick={incrementAsync} disabled={state.loading}>        {state.loading ? 'Loading...' : 'Increment'}      </button>    </div>  );}

Custom Hook Patterns

TypeScriptuseLocalStorage.ts
import { useState, useEffect } from 'react';export function useLocalStorage<T>(key: string, initialValue: T) {  const [storedValue, setStoredValue] = useState<T>(() => {    try {      const item = window.localStorage.getItem(key);      return item ? JSON.parse(item) : initialValue;    } catch (error) {      return initialValue;    }  });  const setValue = (value: T | ((val: T) => T)) => {    try {      const valueToStore = value instanceof Function ? value(storedValue) : value;      setStoredValue(valueToStore);      window.localStorage.setItem(key, JSON.stringify(valueToStore));    } catch (error) {      console.error('Error saving to localStorage:', error);    }  };  return [storedValue, setValue] as const;}
TypeScriptuseFetch.ts
import { useState, useEffect, useCallback } from 'react';interface FetchState<T> {  data: T | null;  loading: boolean;  error: string | null;}interface FetchOptions {  immediate?: boolean;  dependencies?: any[];}export function useFetch<T>(  url: string,   options: FetchOptions = {}): FetchState<T> & { refetch: () => Promise<void> } {  const { immediate = true, dependencies = [] } = options;    const [state, setState] = useState<FetchState<T>>({    data: null,    loading: false,    error: null  });  const fetchData = useCallback(async () => {    setState(prev => ({ ...prev, loading: true, error: null }));        try {      const response = await fetch(url);      if (!response.ok) {        throw new Error(`HTTP error! status: ${response.status}`);      }      const data = await response.json();      setState({ data, loading: false, error: null });    } catch (error) {      setState({         data: null,         loading: false,         error: error instanceof Error ? error.message : 'An error occurred'       });    }  }, [url]);  useEffect(() => {    if (immediate) {      fetchData();    }  }, [fetchData, immediate, ...dependencies]);  return {    ...state,    refetch: fetchData  };}

Performance Optimization with Hooks

ReactPerformanceOptimization.tsx
import { useState, useMemo, useCallback, memo } from 'react';interface User {  id: number;  name: string;  email: string;  department: string;}// Expensive computation examplefunction useFilteredUsers(users: User[], searchTerm: string, department: string) {  return useMemo(() => {    return users.filter(user => {      const matchesSearch = user.name.toLowerCase().includes(searchTerm.toLowerCase()) ||                          user.email.toLowerCase().includes(searchTerm.toLowerCase());      const matchesDepartment = department === 'all' || user.department === department;      return matchesSearch && matchesDepartment;    });  }, [users, searchTerm, department]);}// Memoized component to prevent unnecessary re-rendersconst UserCard = memo(({ user, onDelete }: { user: User; onDelete: (id: number) => void }) => {  console.log('UserCard rendered for', user.name);    return (    <div className="user-card">      <h3>{user.name}</h3>      <p>{user.email}</p>      <p>{user.department}</p>      <button onClick={() => onDelete(user.id)}>Delete</button>    </div>  );});function UserList({ users }: { users: User[] }) {  const [searchTerm, setSearchTerm] = useState('');  const [selectedDepartment, setSelectedDepartment] = useState('all');  // Memoize filtered users to avoid recalculation on every render  const filteredUsers = useFilteredUsers(users, searchTerm, selectedDepartment);  // Memoize callback to prevent child re-renders  const handleDelete = useCallback((id: number) => {    // Delete logic here    console.log('Delete user', id);  }, []);  return (    <div>      <input        type="text"        placeholder="Search users..."        value={searchTerm}        onChange={e => setSearchTerm(e.target.value)}      />            <select        value={selectedDepartment}        onChange={e => setSelectedDepartment(e.target.value)}      >        <option value="all">All Departments</option>        <option value="engineering">Engineering</option>        <option value="marketing">Marketing</option>        <option value="sales">Sales</option>      </select>      <div className="user-grid">        {filteredUsers.map(user => (          <UserCard key={user.id} user={user} onDelete={handleDelete} />        ))}      </div>    </div>  );}

Advanced Custom Hook Patterns

TypeScriptuseAsyncState.ts
import { useState, useCallback, useRef, useEffect } from 'react';interface AsyncState<T, E = Error> {  data: T | null;  error: E | null;  loading: boolean;}export function useAsyncState<T, E = Error>(  asyncFunction: () => Promise<T>): [AsyncState<T, E>, () => Promise<void>, () => void] {  const [state, setState] = useState<AsyncState<T, E>>({    data: null,    error: null,    loading: false  });  const cancelRef = useRef<boolean>(false);  const execute = useCallback(async () => {    setState({ data: null, error: null, loading: true });    cancelRef.current = false;    try {      const data = await asyncFunction();      if (!cancelRef.current) {        setState({ data, error: null, loading: false });      }    } catch (error) {      if (!cancelRef.current) {        setState({           data: null,           error: error as E,           loading: false         });      }    }  }, [asyncFunction]);  const cancel = useCallback(() => {    cancelRef.current = true;    setState(prev => ({ ...prev, loading: false }));  }, []);  useEffect(() => {    return () => {      cancelRef.current = true;    };  }, []);  return [state, execute, cancel];}
TypeScriptuseDebounce.ts
import { useState, useEffect } from 'react';export function useDebounce<T>(value: T, delay: number): T {  const [debouncedValue, setDebouncedValue] = useState<T>(value);  useEffect(() => {    const handler = setTimeout(() => {      setDebouncedValue(value);    }, delay);    return () => {      clearTimeout(handler);    };  }, [value, delay]);  return debouncedValue;}// Usage examplefunction SearchComponent() {  const [searchTerm, setSearchTerm] = useState('');  const debouncedSearchTerm = useDebounce(searchTerm, 300);  useEffect(() => {    if (debouncedSearchTerm) {      // Perform search API call      console.log('Searching for:', debouncedSearchTerm);    }  }, [debouncedSearchTerm]);  return (    <input      type="text"      placeholder="Search..."      value={searchTerm}      onChange={e => setSearchTerm(e.target.value)}    />  );}

Hook Composition and Patterns

TypeScriptuseFormValidation.ts
import { useState, useCallback, useMemo } from 'react';interface ValidationRule<T> {  message: string;  validator: (value: T) => boolean;}interface UseFormValidationOptions<T> {  initialValues: T;  validationRules: Partial<Record<keyof T, ValidationRule<T[keyof T]>[]>>;  onSubmit: (values: T) => void | Promise<void>;}export function useFormValidation<T extends Record<string, any>>({  initialValues,  validationRules,  onSubmit}: UseFormValidationOptions<T>) {  const [values, setValues] = useState<T>(initialValues);  const [errors, setErrors] = useState<Partial<Record<keyof T, string>>>({});  const [touched, setTouched] = useState<Partial<Record<keyof T, boolean>>>({});  const [isSubmitting, setIsSubmitting] = useState(false);  const validateField = useCallback((name: keyof T, value: T[keyof T]) => {    const rules = validationRules[name];    if (!rules) return '';    for (const rule of rules) {      if (!rule.validator(value)) {        return rule.message;      }    }    return '';  }, [validationRules]);  const handleChange = useCallback((name: keyof T) => (    event: React.ChangeEvent<HTMLInputElement>  ) => {    const value = event.target.value as T[keyof T];    setValues(prev => ({ ...prev, [name]: value }));        if (touched[name]) {      const error = validateField(name, value);      setErrors(prev => ({ ...prev, [name]: error }));    }  }, [touched, validateField]);  const handleBlur = useCallback((name: keyof T) => () => {    setTouched(prev => ({ ...prev, [name]: true }));    const error = validateField(name, values[name]);    setErrors(prev => ({ ...prev, [name]: error }));  }, [values, validateField]);  const isValid = useMemo(() => {    return Object.keys(validationRules).every(key => {      const fieldName = key as keyof T;      return !validateField(fieldName, values[fieldName]);    });  }, [values, validationRules, validateField]);  const handleSubmit = useCallback(async (event: React.FormEvent) => {    event.preventDefault();        // Mark all fields as touched    const allTouched = Object.keys(initialValues).reduce((acc, key) => ({      ...acc,      [key]: true    }), {} as Record<keyof T, boolean>);    setTouched(allTouched);    // Validate all fields    const allErrors = Object.keys(validationRules).reduce((acc, key) => {      const fieldName = key as keyof T;      const error = validateField(fieldName, values[fieldName]);      return { ...acc, [fieldName]: error };    }, {} as Record<keyof T, string>);    setErrors(allErrors);    if (isValid) {      setIsSubmitting(true);      try {        await onSubmit(values);      } catch (error) {        console.error('Form submission error:', error);      } finally {        setIsSubmitting(false);      }    }  }, [values, isValid, onSubmit, initialValues, validationRules, validateField]);  return {    values,    errors,    touched,    isSubmitting,    isValid,    handleChange,    handleBlur,    handleSubmit,    setValues,    setErrors  };}

Testing Custom Hooks

TypeScriptuseLocalStorage.test.ts
import { renderHook, act } from '@testing-library/react';import { useLocalStorage } from './useLocalStorage';// Mock localStorageconst localStorageMock = {  getItem: jest.fn(),  setItem: jest.fn(),  removeItem: jest.fn(),  clear: jest.fn(),};Object.defineProperty(window, 'localStorage', {  value: localStorageMock});describe('useLocalStorage', () => {  beforeEach(() => {    localStorageMock.getItem.mockClear();    localStorageMock.setItem.mockClear();  });  it('should return initial value when localStorage is empty', () => {    localStorageMock.getItem.mockReturnValue(null);        const { result } = renderHook(() => useLocalStorage('test-key', 'default-value'));        expect(result.current[0]).toBe('default-value');  });  it('should return stored value from localStorage', () => {    localStorageMock.getItem.mockReturnValue(JSON.stringify('stored-value'));        const { result } = renderHook(() => useLocalStorage('test-key', 'default-value'));        expect(result.current[0]).toBe('stored-value');  });  it('should update localStorage when value changes', () => {    localStorageMock.getItem.mockReturnValue(null);        const { result } = renderHook(() => useLocalStorage('test-key', 'default-value'));        act(() => {      result.current[1]('new-value');    });        expect(localStorageMock.setItem).toHaveBeenCalledWith(      'test-key',      JSON.stringify('new-value')    );    expect(result.current[0]).toBe('new-value');  });  it('should handle function updates', () => {    localStorageMock.getItem.mockReturnValue(JSON.stringify('initial'));        const { result } = renderHook(() => useLocalStorage('test-key', 'default'));        act(() => {      result.current[1]((prev: string) => prev + '-updated');    });        expect(result.current[0]).toBe('initial-updated');  });});
Custom Hooks

Reusable stateful logic that encapsulates complex behavior.

Hook Composition

Combining multiple hooks to create powerful abstractions.

Performance

Optimization with useMemo, useCallback, and React.memo.

Testing

Comprehensive testing strategies for custom hooks.

Custom hooks are where the real power of React Hooks shines. They allow you to extract component logic into reusable functions that can be shared across your entire application.