Back to all articles
ReactJavaScriptTypeScriptFrontendHooks

Advanced React Hooks: Mastering Modern State Management and Side Effects

Deep dive into React hooks including custom hooks, performance optimization, and advanced patterns for building scalable React applications.

Advanced React Hooks: Mastering Modern State Management and Side Effects

React Hooks revolutionized the way we write React components by allowing us to use state and lifecycle features in functional components. This comprehensive guide explores advanced hook patterns, performance optimization techniques, and best practices for building scalable React applications.

Table of Contents

Understanding React Hooks

React Hooks are functions that allow you to "hook into" React features from functional components. They enable state management, side effects, and access to React's lifecycle without writing class components.

Core Principles

  1. Only call hooks at the top level - Never inside loops, conditions, or nested functions
  2. Only call hooks from React functions - React components or custom hooks
  3. Use the ESLint plugin - eslint-plugin-react-hooks catches common mistakes

Why Hooks Matter

  • Simpler component logic - No more complex lifecycle methods
  • Better code reuse - Share stateful logic between components
  • Easier testing - Pure functions are easier to test
  • Better performance - Avoid unnecessary re-renders with proper optimization

Built-in Hooks Deep Dive

useState: Advanced Patterns

The useState hook is more powerful than it appears. Here are advanced patterns:

import { useState, useCallback } from 'react';

// Functional updates for complex state
const useComplexState = () => {
  const [state, setState] = useState({
    user: null,
    loading: false,
    error: null,
  });

  const updateUser = useCallback((userData) => {
    setState((prev) => ({
      ...prev,
      user: userData,
      loading: false,
      error: null,
    }));
  }, []);

  return { state, updateUser };
};

// Lazy initial state for expensive computations
const ExpensiveComponent = () => {
  const [data] = useState(() => {
    // This function only runs on initial render
    return processLargeDataset();
  });

  return <div>{data}</div>;
};

useEffect: Beyond the Basics

useEffect handles side effects, but mastering it requires understanding dependency arrays and cleanup:

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

// Cleanup patterns
const useWebSocketConnection = (url: string) => {
  const [data, setData] = useState(null);
  const ws = useRef<WebSocket | null>(null);

  useEffect(() => {
    ws.current = new WebSocket(url);

    ws.current.onmessage = (event) => {
      setData(JSON.parse(event.data));
    };

    // Cleanup function
    return () => {
      if (ws.current) {
        ws.current.close();
      }
    };
  }, [url]); // Dependency array

  return data;
};

// Conditional effects
const useConditionalEffect = (condition: boolean, effect: () => void, deps: any[]) => {
  useEffect(() => {
    if (condition) {
      effect();
    }
  }, [condition, ...deps]);
};

useMemo and useCallback: Performance Optimization

These hooks prevent unnecessary re-computations and re-renders:

import { useMemo, useCallback, memo } from 'react';

interface Props {
  items: Item[];
  onItemClick: (id: string) => void;
}

const OptimizedList = memo(({ items, onItemClick }: Props) => {
  // Expensive computation only runs when items change
  const sortedItems = useMemo(() => {
    return items.sort((a, b) => a.name.localeCompare(b.name)).filter((item) => item.isActive);
  }, [items]);

  // Prevents child re-renders on parent re-render
  const handleItemClick = useCallback(
    (id: string) => {
      onItemClick(id);
    },
    [onItemClick],
  );

  return (
    <div>
      {sortedItems.map((item) => (
        <ListItem key={item.id} item={item} onClick={handleItemClick} />
      ))}
    </div>
  );
});

Custom Hooks Patterns

Custom hooks are the key to reusable logic in React. Here are powerful patterns:

Data Fetching Hook

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

interface UseFetchResult<T> {
  data: T | null;
  loading: boolean;
  error: Error | null;
  refetch: () => void;
}

function useFetch<T>(url: string): UseFetchResult<T> {
  const [data, setData] = useState<T | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<Error | null>(null);
  const abortControllerRef = useRef<AbortController>();

  const fetchData = useCallback(async () => {
    try {
      // Cancel previous request
      abortControllerRef.current?.abort();
      abortControllerRef.current = new AbortController();

      setLoading(true);
      setError(null);

      const response = await fetch(url, {
        signal: abortControllerRef.current.signal,
      });

      if (!response.ok) {
        throw new Error(`HTTP error! status: ${response.status}`);
      }

      const result = await response.json();
      setData(result);
    } catch (err) {
      if (err.name !== 'AbortError') {
        setError(err as Error);
      }
    } finally {
      setLoading(false);
    }
  }, [url]);

  useEffect(() => {
    fetchData();

    return () => {
      abortControllerRef.current?.abort();
    };
  }, [fetchData]);

  return { data, loading, error, refetch: fetchData };
}

Local Storage Hook

import { useState, useEffect } from 'react';

function useLocalStorage<T>(key: string, initialValue: T) {
  // Get value from localStorage or use initial value
  const [storedValue, setStoredValue] = useState<T>(() => {
    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;
    }
  });

  // Update localStorage when state changes
  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 setting localStorage key "${key}":`, error);
    }
  };

  // Listen for changes to localStorage
  useEffect(() => {
    const handleStorageChange = (e: StorageEvent) => {
      if (e.key === key && e.newValue) {
        try {
          setStoredValue(JSON.parse(e.newValue));
        } catch (error) {
          console.error('Error parsing localStorage value:', error);
        }
      }
    };

    window.addEventListener('storage', handleStorageChange);
    return () => window.removeEventListener('storage', handleStorageChange);
  }, [key]);

  return [storedValue, setValue] as const;
}

### Debounce Hook

```tsx
import { useState, useEffect } from 'react';

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 example
const SearchComponent = () => {
  const [searchTerm, setSearchTerm] = useState('');
  const debouncedSearchTerm = useDebounce(searchTerm, 300);

  useEffect(() => {
    if (debouncedSearchTerm) {
      // Perform search
      performSearch(debouncedSearchTerm);
    }
  }, [debouncedSearchTerm]);

  return (
    <input
      value={searchTerm}
      onChange={(e) => setSearchTerm(e.target.value)}
      placeholder="Search..."
    />
  );
};

Performance Optimization

useReducer for Complex State

When useState becomes unwieldy, useReducer provides better state management:

import { useReducer, useCallback } from 'react';

interface State {
  user: User | null;
  posts: Post[];
  loading: boolean;
  error: string | null;
}

type Action =
  | { type: 'FETCH_START' }
  | { type: 'FETCH_SUCCESS'; payload: { user: User; posts: Post[] } }
  | { type: 'FETCH_ERROR'; payload: string }
  | { type: 'ADD_POST'; payload: Post }
  | { type: 'DELETE_POST'; payload: string };

const reducer = (state: State, action: Action): State => {
  switch (action.type) {
    case 'FETCH_START':
      return { ...state, loading: true, error: null };
    case 'FETCH_SUCCESS':
      return {
        ...state,
        loading: false,
        user: action.payload.user,
        posts: action.payload.posts,
      };
    case 'FETCH_ERROR':
      return { ...state, loading: false, error: action.payload };
    case 'ADD_POST':
      return { ...state, posts: [...state.posts, action.payload] };
    case 'DELETE_POST':
      return {
        ...state,
        posts: state.posts.filter((post) => post.id !== action.payload),
      };
    default:
      return state;
  }
};

const useUserPosts = (userId: string) => {
  const [state, dispatch] = useReducer(reducer, {
    user: null,
    posts: [],
    loading: false,
    error: null,
  });

  const fetchUserPosts = useCallback(async () => {
    dispatch({ type: 'FETCH_START' });
    try {
      const [user, posts] = await Promise.all([fetchUser(userId), fetchUserPosts(userId)]);
      dispatch({ type: 'FETCH_SUCCESS', payload: { user, posts } });
    } catch (error) {
      dispatch({ type: 'FETCH_ERROR', payload: error.message });
    }
  }, [userId]);

  return { ...state, fetchUserPosts, dispatch };
};

Advanced Hook Patterns

Compound Hooks Pattern

Combine multiple hooks for complex functionality:

interface UseAsyncOptions {
  immediate?: boolean;
  onSuccess?: (data: any) => void;
  onError?: (error: Error) => void;
}

function useAsync<T>(asyncFunction: () => Promise<T>, options: UseAsyncOptions = {}) {
  const [state, setState] = useState({
    data: null as T | null,
    loading: false,
    error: null as Error | null,
  });

  const { immediate = true, onSuccess, onError } = options;

  const execute = useCallback(async () => {
    setState((prev) => ({ ...prev, loading: true, error: null }));

    try {
      const data = await asyncFunction();
      setState({ data, loading: false, error: null });
      onSuccess?.(data);
      return data;
    } catch (error) {
      const err = error as Error;
      setState((prev) => ({ ...prev, loading: false, error: err }));
      onError?.(err);
      throw err;
    }
  }, [asyncFunction, onSuccess, onError]);

  useEffect(() => {
    if (immediate) {
      execute();
    }
  }, [execute, immediate]);

  return { ...state, execute };
}

Context + Hooks Pattern

Create powerful state management with Context and hooks:

import { createContext, useContext, useReducer, ReactNode } from 'react';

// Theme context example
interface ThemeState {
  theme: 'light' | 'dark';
  primaryColor: string;
  fontSize: 'small' | 'medium' | 'large';
}

type ThemeAction =
  | { type: 'TOGGLE_THEME' }
  | { type: 'SET_PRIMARY_COLOR'; payload: string }
  | { type: 'SET_FONT_SIZE'; payload: 'small' | 'medium' | 'large' };

const themeReducer = (state: ThemeState, action: ThemeAction): ThemeState => {
  switch (action.type) {
    case 'TOGGLE_THEME':
      return { ...state, theme: state.theme === 'light' ? 'dark' : 'light' };
    case 'SET_PRIMARY_COLOR':
      return { ...state, primaryColor: action.payload };
    case 'SET_FONT_SIZE':
      return { ...state, fontSize: action.payload };
    default:
      return state;
  }
};

const ThemeContext = createContext<{
  state: ThemeState;
  dispatch: React.Dispatch<ThemeAction>;
} | null>(null);

export const ThemeProvider = ({ children }: { children: ReactNode }) => {
  const [state, dispatch] = useReducer(themeReducer, {
    theme: 'light',
    primaryColor: '#3b82f6',
    fontSize: 'medium',
  });

  return <ThemeContext.Provider value={{ state, dispatch }}>{children}</ThemeContext.Provider>;
};

export const useTheme = () => {
  const context = useContext(ThemeContext);
  if (!context) {
    throw new Error('useTheme must be used within a ThemeProvider');
  }

  const { state, dispatch } = context;

  const toggleTheme = () => dispatch({ type: 'TOGGLE_THEME' });
  const setPrimaryColor = (color: string) =>
    dispatch({ type: 'SET_PRIMARY_COLOR', payload: color });
  const setFontSize = (size: 'small' | 'medium' | 'large') =>
    dispatch({ type: 'SET_FONT_SIZE', payload: size });

  return {
    ...state,
    toggleTheme,
    setPrimaryColor,
    setFontSize,
  };
};

Testing React Hooks

Testing custom hooks requires special setup:

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

describe('useCounter', () => {
  it('should initialize with initial value', () => {
    const { result } = renderHook(() => useCounter(5));
    expect(result.current.count).toBe(5);
  });

  it('should increment count', () => {
    const { result } = renderHook(() => useCounter(0));

    act(() => {
      result.current.increment();
    });

    expect(result.current.count).toBe(1);
  });

  it('should handle async operations', async () => {
    const { result, waitForNextUpdate } = renderHook(() => useFetch('/api/data'));

    expect(result.current.loading).toBe(true);

    await waitForNextUpdate();

    expect(result.current.loading).toBe(false);
    expect(result.current.data).toBeDefined();
  });
});

Common Pitfalls and Solutions

1. Stale Closures

// ❌ Problem: Stale closure
const BadCounter = () => {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const interval = setInterval(() => {
      setCount(count + 1); // Always adds 1 to initial value
    }, 1000);

    return () => clearInterval(interval);
  }, []); // Empty dependency array creates stale closure

  return <div>{count}</div>;
};

// ✅ Solution: Functional update
const GoodCounter = () => {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const interval = setInterval(() => {
      setCount((prev) => prev + 1); // Uses current value
    }, 1000);

    return () => clearInterval(interval);
  }, []);

  return <div>{count}</div>;
};

2. Missing Dependencies

// ❌ Problem: Missing dependency
const SearchResults = ({ query, filters }) => {
  const [results, setResults] = useState([]);

  useEffect(() => {
    fetchResults(query, filters).then(setResults);
  }, [query]); // Missing 'filters' dependency

  return <ResultsList results={results} />;
};

// ✅ Solution: Include all dependencies
const SearchResults = ({ query, filters }) => {
  const [results, setResults] = useState([]);

  useEffect(() => {
    fetchResults(query, filters).then(setResults);
  }, [query, filters]); // All dependencies included

  return <ResultsList results={results} />;
};

Best Practices

1. Extract Custom Hooks Early

Don't wait until logic becomes complex. Extract reusable logic into custom hooks:

// Instead of this in multiple components
const Component = () => {
  const [windowSize, setWindowSize] = useState({ width: 0, height: 0 });

  useEffect(() => {
    const updateSize = () => {
      setWindowSize({
        width: window.innerWidth,
        height: window.innerHeight,
      });
    };

    window.addEventListener('resize', updateSize);
    updateSize();

    return () => window.removeEventListener('resize', updateSize);
  }, []);

  // ... rest of component
};

// Extract to custom hook
const useWindowSize = () => {
  const [windowSize, setWindowSize] = useState({ width: 0, height: 0 });

  useEffect(() => {
    const updateSize = () => {
      setWindowSize({
        width: window.innerWidth,
        height: window.innerHeight,
      });
    };

    window.addEventListener('resize', updateSize);
    updateSize();

    return () => window.removeEventListener('resize', updateSize);
  }, []);

  return windowSize;
};

2. Use TypeScript for Better Developer Experience

interface UseApiOptions<T> {
  initialData?: T;
  onSuccess?: (data: T) => void;
  onError?: (error: Error) => void;
}

function useApi<T>(
  url: string,
  options: UseApiOptions<T> = {},
): {
  data: T | null;
  loading: boolean;
  error: Error | null;
  refetch: () => Promise<void>;
} {
  // Implementation with full type safety
}

3. Optimize Bundle Size

Only import what you need and use code splitting for heavy hooks:

// Use dynamic imports for heavy utilities
const useHeavyProcessing = () => {
  const [result, setResult] = useState(null);

  const process = useCallback(async (data) => {
    const { heavyProcessor } = await import('./heavyProcessor');
    const result = heavyProcessor(data);
    setResult(result);
  }, []);

  return { result, process };
};

Conclusion

React Hooks have fundamentally changed how we build React applications. By mastering these patterns and techniques, you can:

  • Write more reusable and testable code
  • Improve application performance
  • Simplify complex state management
  • Create better developer experiences

The key is to start simple and gradually adopt more advanced patterns as your applications grow in complexity. Remember to always consider performance implications and use the right tool for the job.

Happy coding with React Hooks! 🎣