Back to blog

Mastering React Hooks: Beyond useState and useEffect

June 18, 2024 (1y ago)

React hooks revolutionized how we write React components. While most developers know useState and useEffect, there's a rich ecosystem of hooks that can dramatically improve your code.

Custom Hooks: The Real Power

Custom hooks are the key to reusable logic in React. Here are some patterns I've found invaluable:

1. useLocalStorage Hook

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 (error) {
      console.error(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);
    }
  };

  return [storedValue, setValue] as const;
}

2. useDebounce Hook

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;
}

Advanced Built-in Hooks

useReducer for Complex State

When state logic becomes complex, useReducer shines:

interface State {
  loading: boolean;
  error: string | null;
  data: any;
}

type Action =
  | { type: 'FETCH_START' }
  | { type: 'FETCH_SUCCESS'; payload: any }
  | { type: 'FETCH_ERROR'; payload: string };

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case 'FETCH_START':
      return { loading: true, error: null, data: null };
    case 'FETCH_SUCCESS':
      return { loading: false, error: null, data: action.payload };
    case 'FETCH_ERROR':
      return { loading: false, error: action.payload, data: null };
    default:
      return state;
  }
}

useCallback and useMemo

Performance optimization hooks that are often misunderstood:

// Memoize expensive calculations
const expensiveValue = useMemo(() => {
  return data.reduce((sum, item) => sum + item.value, 0);
}, [data]);

// Memoize functions to prevent unnecessary re-renders
const handleClick = useCallback((id: string) => {
  onItemClick(id);
}, [onItemClick]);

Context API Patterns

1. Split Contexts

Don't put everything in one context:

// Separate contexts for different concerns
const AuthContext = createContext<AuthState | null>(null);
const ThemeContext = createContext<ThemeState | null>(null);
const NotificationContext = createContext<NotificationState | null>(null);

2. Context Providers Composition

function AppProviders({ children }: { children: React.ReactNode }) {
  return (
    <AuthProvider>
      <ThemeProvider>
        <NotificationProvider>
          {children}
        </NotificationProvider>
      </ThemeProvider>
    </AuthProvider>
  );
}

Testing Hooks

Testing custom hooks is crucial:

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

test('should increment counter', () => {
  const { result } = renderHook(() => useCounter());
  
  act(() => {
    result.current.increment();
  });
  
  expect(result.current.count).toBe(1);
});

Best Practices

  1. Keep hooks focused - Single responsibility principle
  2. Use TypeScript - Better type safety and IDE support
  3. Test your hooks - They're just functions
  4. Document usage - Custom hooks need clear documentation
  5. Consider performance - Not everything needs to be memoized

Common Pitfalls

  • Overusing useMemo/useCallback - Profile before optimizing
  • Dependencies arrays - Missing dependencies cause bugs
  • Infinite loops - Effects that trigger themselves
  • Stale closures - Not updating dependencies properly

Conclusion

React hooks are more than just a way to use state in functional components. They're a powerful pattern for composing logic, sharing code, and building maintainable applications.

Mastering hooks takes time, but the investment pays off in cleaner, more reusable code. Start with simple custom hooks and gradually build more complex ones as you understand the patterns.