DEVELOPMENT

React hooks complete guide: useState, useEffect, and custom hooks

Master React hooks with comprehensive guide covering useState, useEffect, and custom hooks. Complete developer guide with performance optimization, best practices, and production patterns.

Vladimir Siedykh

Why Your Component Re-Renders 47 Times When You Expected Once

When React introduced hooks in version 16.8, it fundamentally changed how we write React components. Yet three years later, I still see developers struggling with the same patterns - infinite re-renders, stale closures, and performance bottlenecks that could be avoided with proper hook usage.

The problem isn't that hooks are difficult. The problem is that most tutorials teach you what hooks do, not how to use them effectively in real applications. You learn useState returns state and a setter, but nobody explains why your component re-renders 47 times when you expected it to render once.

This guide bridges that gap. Instead of basic syntax, we'll explore the patterns that separate beginner React developers from those who build production applications that scale. You'll learn how to avoid the most common pitfalls, optimize performance from day one, and create custom hooks that actually make your code more maintainable.

By the end, you'll understand not just how hooks work, but how to use them to build applications that perform well and remain manageable as they grow.

Understanding React hooks fundamentals

React hooks are functions that let you "hook into" React state and lifecycle features from functional components. But this simple definition misses the crucial insight: hooks follow specific rules that determine when your components re-render and how they maintain state between renders.

The key concept most developers miss is that every render creates a new function execution context. This means every variable, function, and hook call exists independently in each render. Understanding this principle is essential for avoiding the most common hook-related bugs.

The render cycle and hook behavior

When a component renders, React calls your component function from top to bottom. Each hook call happens in the same order every time, which is why you can't call hooks conditionally. React uses this consistent ordering to maintain state between renders.

function UserProfile({ userId }) {
  // First render: useState creates initial state
  // Subsequent renders: useState returns current state
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  
  // This happens on every render, but React
  // only runs the effect when dependencies change
  useEffect(() => {
    fetchUser(userId).then(userData => {
      setUser(userData);
      setLoading(false);
    });
  }, [userId]); // Dependencies array controls when effect runs
  
  if (loading) return <div>Loading user...</div>;
  return <div>Welcome, {user.name}!</div>;
}

Each time this component renders, React creates a new execution context where user, loading, setUser, and setLoading exist as separate variables. React maintains the actual state values internally and provides them to your component through the hook calls.

Mastering useState patterns and optimization

The useState hook seems simple, but using it effectively requires understanding several key patterns that impact both functionality and performance.

State initialization and lazy initialization

The most basic useState pattern initializes state with a simple value:

const [count, setCount] = useState(0);
const [name, setName] = useState('');

However, when your initial state requires computation, you should use lazy initialization to avoid running expensive calculations on every render:

// ❌ Bad: Runs expensive calculation on every render
const [expensiveValue, setExpensiveValue] = useState(
  calculateExpensiveValue()
);

// ✅ Good: Runs calculation only once
const [expensiveValue, setExpensiveValue] = useState(() =>
  calculateExpensiveValue()
);

The lazy initialization pattern is particularly useful when reading from localStorage or performing calculations based on props:

function ShoppingCart({ initialItems }) {
  // This only runs once, not on every render
  const [items, setItems] = useState(() => {
    const saved = localStorage.getItem('cartItems');
    return saved ? JSON.parse(saved) : initialItems;
  });
  
  return (
    <div>
      {items.map(item => (
        <CartItem key={item.id} item={item} />
      ))}
    </div>
  );
}

Functional state updates

When your new state depends on the previous state, always use the functional update pattern. This prevents bugs caused by stale state values in closures:

function Counter() {
  const [count, setCount] = useState(0);
  
  const handleIncrement = () => {
    // ❌ Bad: Can cause lost updates in rapid succession
    setCount(count + 1);
    
    // ✅ Good: Always gets the most recent state
    setCount(prevCount => prevCount + 1);
  };
  
  const handleMultipleIncrements = () => {
    // With functional updates, these all work correctly
    setCount(prev => prev + 1);
    setCount(prev => prev + 1);
    setCount(prev => prev + 1);
    // count will be incremented by 3
  };
  
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={handleIncrement}>Increment</button>
      <button onClick={handleMultipleIncrements}>+3</button>
    </div>
  );
}

Complex state management patterns

For complex state objects, consider whether multiple useState calls or a single useReducer would be more appropriate. Multiple useState calls work well when state pieces are independent:

function UserProfile() {
  const [personalInfo, setPersonalInfo] = useState({
    name: '',
    email: '',
    bio: ''
  });
  const [preferences, setPreferences] = useState({
    theme: 'light',
    notifications: true
  });
  const [loading, setLoading] = useState(false);
  const [errors, setErrors] = useState({});
  
  const updatePersonalInfo = (field, value) => {
    setPersonalInfo(prev => ({
      ...prev,
      [field]: value
    }));
  };
  
  return (
    <form>
      <input
        value={personalInfo.name}
        onChange={(e) => updatePersonalInfo('name', e.target.value)}
        placeholder="Name"
      />
      <input
        value={personalInfo.email}
        onChange={(e) => updatePersonalInfo('email', e.target.value)}
        placeholder="Email"
      />
    </form>
  );
}

Advanced useEffect patterns and cleanup

The useEffect hook handles side effects, but using it correctly requires understanding dependency arrays, cleanup functions, and when effects actually run.

Dependency array patterns

The dependency array controls when your effect runs. Understanding these patterns prevents infinite re-renders and ensures your effects run at the right times:

function DataFetcher({ userId, filters }) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  
  // Runs on every render - usually wrong
  useEffect(() => {
    fetchData().then(setData);
  });
  
  // Runs once on mount - good for initial setup
  useEffect(() => {
    initializeAnalytics();
  }, []);
  
  // Runs when userId changes - good for data fetching
  useEffect(() => {
    setLoading(true);
    fetchUserData(userId)
      .then(setData)
      .finally(() => setLoading(false));
  }, [userId]);
  
  // Runs when any filter changes
  useEffect(() => {
    if (data) {
      const filtered = applyFilters(data, filters);
      setData(filtered);
    }
  }, [filters]); // Be careful: this might not include all dependencies
}

The ESLint plugin react-hooks/exhaustive-deps helps catch missing dependencies, but understanding why dependencies matter is crucial for writing correct effects.

Cleanup functions and preventing memory leaks

Always clean up subscriptions, timers, and async operations to prevent memory leaks and state updates on unmounted components:

function LiveNotifications() {
  const [notifications, setNotifications] = useState([]);
  
  useEffect(() => {
    let isSubscribed = true;
    
    const subscription = notificationService.subscribe(notification => {
      // Only update state if component is still mounted
      if (isSubscribed) {
        setNotifications(prev => [notification, ...prev]);
      }
    });
    
    // Cleanup function runs when effect is cleaned up
    return () => {
      isSubscribed = false;
      subscription.unsubscribe();
    };
  }, []);
  
  return (
    <div>
      {notifications.map(notification => (
        <NotificationItem key={notification.id} {...notification} />
      ))}
    </div>
  );
}

For timer-based effects, proper cleanup prevents timers from continuing after component unmount:

function AutoSaveForm({ data, onSave }) {
  useEffect(() => {
    const timer = setInterval(() => {
      onSave(data);
    }, 30000); // Auto-save every 30 seconds
    
    return () => clearInterval(timer);
  }, [data, onSave]);
}

Handling async operations in useEffect

Never make the useEffect callback async directly. Instead, define async functions inside the effect:

function UserData({ userId }) {
  const [user, setUser] = useState(null);
  const [error, setError] = useState(null);
  
  useEffect(() => {
    // ❌ Don't do this
    // useEffect(async () => { ... }, []);
    
    // ✅ Define async function inside effect
    async function fetchUser() {
      try {
        setError(null);
        const userData = await api.getUser(userId);
        setUser(userData);
      } catch (err) {
        setError(err.message);
      }
    }
    
    fetchUser();
  }, [userId]);
  
  if (error) return <div>Error: {error}</div>;
  if (!user) return <div>Loading...</div>;
  return <UserProfile user={user} />;
}

Creating powerful custom hooks

Custom hooks are where React's compositional power really shines. They let you extract stateful logic into reusable functions that multiple components can share.

Building a data fetching hook

A well-designed custom hook encapsulates complex logic and provides a clean API:

function useApi(url, options = {}) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  
  useEffect(() => {
    let cancelled = false;
    
    async function fetchData() {
      try {
        setLoading(true);
        setError(null);
        
        const response = await fetch(url, options);
        if (!response.ok) {
          throw new Error(`HTTP error! status: ${response.status}`);
        }
        
        const result = await response.json();
        
        if (!cancelled) {
          setData(result);
        }
      } catch (err) {
        if (!cancelled) {
          setError(err.message);
        }
      } finally {
        if (!cancelled) {
          setLoading(false);
        }
      }
    }
    
    fetchData();
    
    return () => {
      cancelled = true;
    };
  }, [url, JSON.stringify(options)]);
  
  const refetch = useCallback(() => {
    setLoading(true);
    setError(null);
    // Trigger useEffect by changing a dependency
  }, [url, options]);
  
  return { data, loading, error, refetch };
}

// Usage in components becomes much cleaner
function UserProfile({ userId }) {
  const { data: user, loading, error } = useApi(`/api/users/${userId}`);
  
  if (loading) return <div>Loading user...</div>;
  if (error) return <div>Error: {error}</div>;
  return <div>Welcome, {user.name}!</div>;
}

Local storage synchronization hook

This custom hook demonstrates how to synchronize state with localStorage while handling edge cases:

function useLocalStorage(key, initialValue) {
  // Get value from localStorage or use initial value
  const [storedValue, setStoredValue] = useState(() => {
    try {
      const item = window.localStorage.getItem(key);
      return item ? JSON.parse(item) : initialValue;
    } catch (error) {
      console.warn(`Error reading localStorage key "${key}":`, error);
      return initialValue;
    }
  });
  
  // Return a wrapped version of useState's setter that persists to localStorage
  const setValue = useCallback((value) => {
    try {
      // Allow value to be a function so we have the same API as useState
      const valueToStore = value instanceof Function ? value(storedValue) : value;
      setStoredValue(valueToStore);
      window.localStorage.setItem(key, JSON.stringify(valueToStore));
    } catch (error) {
      console.warn(`Error setting localStorage key "${key}":`, error);
    }
  }, [key, storedValue]);
  
  return [storedValue, setValue];
}

// Usage
function Settings() {
  const [theme, setTheme] = useLocalStorage('theme', 'light');
  const [autoSave, setAutoSave] = useLocalStorage('autoSave', true);
  
  return (
    <div>
      <select value={theme} onChange={(e) => setTheme(e.target.value)}>
        <option value="light">Light</option>
        <option value="dark">Dark</option>
      </select>
      <label>
        <input
          type="checkbox"
          checked={autoSave}
          onChange={(e) => setAutoSave(e.target.checked)}
        />
        Auto-save changes
      </label>
    </div>
  );
}

Form management hook

A custom hook for form handling reduces boilerplate and centralizes validation logic:

function useForm(initialValues, validate) {
  const [values, setValues] = useState(initialValues);
  const [errors, setErrors] = useState({});
  const [touched, setTouched] = useState({});
  const [isSubmitting, setIsSubmitting] = useState(false);
  
  const handleChange = useCallback((name, value) => {
    setValues(prev => ({ ...prev, [name]: value }));
    
    // Clear error when user starts typing
    if (errors[name]) {
      setErrors(prev => ({ ...prev, [name]: '' }));
    }
  }, [errors]);
  
  const handleBlur = useCallback((name) => {
    setTouched(prev => ({ ...prev, [name]: true }));
    
    if (validate) {
      const fieldErrors = validate({ ...values, [name]: values[name] });
      setErrors(prev => ({ ...prev, [name]: fieldErrors[name] }));
    }
  }, [values, validate]);
  
  const handleSubmit = useCallback(async (onSubmit) => {
    setIsSubmitting(true);
    
    if (validate) {
      const validationErrors = validate(values);
      setErrors(validationErrors);
      
      if (Object.keys(validationErrors).length > 0) {
        setIsSubmitting(false);
        return;
      }
    }
    
    try {
      await onSubmit(values);
    } catch (error) {
      setErrors({ submit: error.message });
    } finally {
      setIsSubmitting(false);
    }
  }, [values, validate]);
  
  const reset = useCallback(() => {
    setValues(initialValues);
    setErrors({});
    setTouched({});
    setIsSubmitting(false);
  }, [initialValues]);
  
  return {
    values,
    errors,
    touched,
    isSubmitting,
    handleChange,
    handleBlur,
    handleSubmit,
    reset
  };
}

Performance optimization strategies

React hooks can impact performance if not used carefully. Understanding these optimization techniques helps you build applications that remain responsive as they scale.

Preventing unnecessary re-renders with useMemo

Use useMemo for expensive calculations that don't need to run on every render:

function ExpensiveComponent({ items, filters }) {
  // This calculation only runs when items or filters change
  const filteredItems = useMemo(() => {
    return items.filter(item => {
      return filters.every(filter => filter.test(item));
    });
  }, [items, filters]);
  
  // This component only re-renders when necessary
  const itemComponents = useMemo(() => {
    return filteredItems.map(item => (
      <ExpensiveItemComponent key={item.id} item={item} />
    ));
  }, [filteredItems]);
  
  return <div>{itemComponents}</div>;
}

Optimizing function references with useCallback

Use useCallback to prevent child components from re-rendering when parent functions haven't actually changed:

function TodoList({ todos, onToggle, onDelete }) {
  const [filter, setFilter] = useState('all');
  
  // Without useCallback, these functions change on every render
  // causing all TodoItem components to re-render unnecessarily
  const handleToggle = useCallback((id) => {
    onToggle(id);
  }, [onToggle]);
  
  const handleDelete = useCallback((id) => {
    onDelete(id);
  }, [onDelete]);
  
  const filteredTodos = useMemo(() => {
    switch (filter) {
      case 'active':
        return todos.filter(todo => !todo.completed);
      case 'completed':
        return todos.filter(todo => todo.completed);
      default:
        return todos;
    }
  }, [todos, filter]);
  
  return (
    <div>
      <FilterButtons filter={filter} onFilterChange={setFilter} />
      {filteredTodos.map(todo => (
        <TodoItem
          key={todo.id}
          todo={todo}
          onToggle={handleToggle}
          onDelete={handleDelete}
        />
      ))}
    </div>
  );
}

// Wrap child components in React.memo to prevent re-renders
// when props haven't changed
const TodoItem = React.memo(({ todo, onToggle, onDelete }) => {
  return (
    <div>
      <input
        type="checkbox"
        checked={todo.completed}
        onChange={() => onToggle(todo.id)}
      />
      <span>{todo.text}</span>
      <button onClick={() => onDelete(todo.id)}>Delete</button>
    </div>
  );
});

State structure optimization

How you structure state affects performance. Keep related state together and split unrelated state:

// ❌ Bad: One large state object causes unnecessary re-renders
function Dashboard() {
  const [state, setState] = useState({
    user: null,
    notifications: [],
    sidebar: { open: false, width: 300 },
    theme: 'light',
    lastActivity: Date.now()
  });
  
  // Updating lastActivity re-renders entire component
  const updateActivity = () => {
    setState(prev => ({ ...prev, lastActivity: Date.now() }));
  };
}

// ✅ Good: Split state by update frequency and relationships
function Dashboard() {
  const [user, setUser] = useState(null);
  const [notifications, setNotifications] = useState([]);
  const [sidebarConfig, setSidebarConfig] = useState({ open: false, width: 300 });
  const [theme, setTheme] = useState('light');
  
  // Activity tracking doesn't affect main UI state
  const lastActivityRef = useRef(Date.now());
  const updateActivity = () => {
    lastActivityRef.current = Date.now();
  };
}

Testing React hooks effectively

Testing hooks requires understanding how to simulate the React environment and test both state changes and side effects.

Testing custom hooks with React Testing Library

Use the renderHook utility to test custom hooks in isolation:

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

describe('useCounter', () => {
  test('should initialize with provided value', () => {
    const { result } = renderHook(() => useCounter(10));
    
    expect(result.current.count).toBe(10);
  });
  
  test('should increment count', () => {
    const { result } = renderHook(() => useCounter(0));
    
    act(() => {
      result.current.increment();
    });
    
    expect(result.current.count).toBe(1);
  });
  
  test('should reset to initial value', () => {
    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);
  });
});

Testing hooks with async operations

For hooks that perform async operations, use async testing patterns:

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

// Mock fetch
global.fetch = jest.fn();

describe('useApi', () => {
  afterEach(() => {
    jest.resetAllMocks();
  });
  
  test('should fetch data successfully', async () => {
    const mockData = { id: 1, name: 'Test User' };
    fetch.mockResolvedValueOnce({
      ok: true,
      json: async () => mockData
    });
    
    const { result } = renderHook(() => useApi('/api/user/1'));
    
    // Initially loading
    expect(result.current.loading).toBe(true);
    expect(result.current.data).toBe(null);
    
    // Wait for async operation to complete
    await waitFor(() => {
      expect(result.current.loading).toBe(false);
    });
    
    expect(result.current.data).toEqual(mockData);
    expect(result.current.error).toBe(null);
  });
  
  test('should handle fetch errors', async () => {
    fetch.mockRejectedValueOnce(new Error('Network error'));
    
    const { result } = renderHook(() => useApi('/api/user/1'));
    
    await waitFor(() => {
      expect(result.current.loading).toBe(false);
    });
    
    expect(result.current.error).toBe('Network error');
    expect(result.current.data).toBe(null);
  });
});

Testing components that use hooks

When testing components that use hooks, focus on the behavior rather than implementation details:

import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import UserProfile from './UserProfile';

// Mock the API
jest.mock('./api', () => ({
  getUser: jest.fn()
}));

describe('UserProfile', () => {
  test('should display loading state initially', () => {
    render(<UserProfile userId="123" />);
    
    expect(screen.getByText('Loading user...')).toBeInTheDocument();
  });
  
  test('should display user data when loaded', async () => {
    const mockUser = { id: '123', name: 'John Doe', email: 'john@example.com' };
    require('./api').getUser.mockResolvedValueOnce(mockUser);
    
    render(<UserProfile userId="123" />);
    
    await waitFor(() => {
      expect(screen.queryByText('Loading user...')).not.toBeInTheDocument();
    });
    
    expect(screen.getByText('John Doe')).toBeInTheDocument();
    expect(screen.getByText('john@example.com')).toBeInTheDocument();
  });
});

Common pitfalls and debugging techniques

Understanding common hook pitfalls helps you write more reliable code and debug issues faster.

The stale closure problem

One of the most common issues with hooks is stale closures, where functions capture old values:

function Timer() {
  const [count, setCount] = useState(0);
  
  useEffect(() => {
    // ❌ This captures the initial value of count (0)
    const timer = setInterval(() => {
      console.log('Count:', count); // Always logs 0
      setCount(count + 1); // Always adds 1 to 0
    }, 1000);
    
    return () => clearInterval(timer);
  }, []); // Empty dependency array causes stale closure
  
  return <div>Count: {count}</div>;
}

// ✅ Fix 1: Include count in dependencies
function Timer() {
  const [count, setCount] = useState(0);
  
  useEffect(() => {
    const timer = setInterval(() => {
      console.log('Count:', count); // Always current value
      setCount(count + 1);
    }, 1000);
    
    return () => clearInterval(timer);
  }, [count]); // Timer restarts on every count change
  
  return <div>Count: {count}</div>;
}

// ✅ Fix 2: Use functional state updates
function Timer() {
  const [count, setCount] = useState(0);
  
  useEffect(() => {
    const timer = setInterval(() => {
      setCount(prevCount => {
        console.log('Count:', prevCount); // Always current value
        return prevCount + 1;
      });
    }, 1000);
    
    return () => clearInterval(timer);
  }, []); // Empty dependency array is safe now
  
  return <div>Count: {count}</div>;
}

Infinite re-render loops

Infinite re-renders usually occur when dependencies change on every render:

// ❌ Creates infinite loop
function UserList({ filters }) {
  const [users, setUsers] = useState([]);
  
  useEffect(() => {
    fetchUsers(filters).then(setUsers);
  }, [filters]); // If filters is a new object every render, infinite loop
  
  return users.map(user => <UserCard key={user.id} user={user} />);
}

// ✅ Stabilize object dependencies
function UserList({ filters }) {
  const [users, setUsers] = useState([]);
  
  // Memoize the filters object
  const stableFilters = useMemo(() => filters, [
    filters.search,
    filters.category,
    filters.sortBy
  ]);
  
  useEffect(() => {
    fetchUsers(stableFilters).then(setUsers);
  }, [stableFilters]);
  
  return users.map(user => <UserCard key={user.id} user={user} />);
}

Debugging hook behavior

Use React DevTools and console logging strategically to understand hook behavior:

function DebugHook() {
  const [count, setCount] = useState(0);
  const [name, setName] = useState('');
  
  // Log renders to understand when and why component re-renders
  console.log('Component rendered with:', { count, name });
  
  useEffect(() => {
    console.log('Effect ran, count changed to:', count);
  }, [count]);
  
  useEffect(() => {
    console.log('Effect ran, name changed to:', name);
  }, [name]);
  
  // Custom hook for debugging re-renders
  const useWhyDidYouUpdate = (name, props) => {
    const previous = useRef();
    
    useEffect(() => {
      if (previous.current) {
        const changedProps = Object.entries(props).reduce((ps, [k, v]) => {
          if (previous.current[k] !== v) {
            ps[k] = [previous.current[k], v];
          }
          return ps;
        }, {});
        
        if (Object.keys(changedProps).length > 0) {
          console.log('[why-did-you-update]', name, changedProps);
        }
      }
      
      previous.current = props;
    });
  };
  
  useWhyDidYouUpdate('DebugHook', { count, name });
  
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(c => c + 1)}>Increment</button>
      <input value={name} onChange={(e) => setName(e.target.value)} />
    </div>
  );
}

Advanced patterns and real-world applications

As your React applications grow more complex, these advanced patterns help you maintain clean, performant code.

Compound components with hooks

Create flexible component APIs using compound patterns:

// Context for sharing state between compound components
const TabsContext = createContext();

function Tabs({ children, defaultTab = 0 }) {
  const [activeTab, setActiveTab] = useState(defaultTab);
  
  return (
    <TabsContext.Provider value={{ activeTab, setActiveTab }}>
      <div className="tabs">{children}</div>
    </TabsContext.Provider>
  );
}

function TabList({ children }) {
  return <div className="tab-list">{children}</div>;
}

function Tab({ index, children }) {
  const { activeTab, setActiveTab } = useContext(TabsContext);
  const isActive = activeTab === index;
  
  return (
    <button
      className={`tab ${isActive ? 'active' : ''}`}
      onClick={() => setActiveTab(index)}
    >
      {children}
    </button>
  );
}

function TabPanels({ children }) {
  return <div className="tab-panels">{children}</div>;
}

function TabPanel({ index, children }) {
  const { activeTab } = useContext(TabsContext);
  
  if (activeTab !== index) return null;
  
  return <div className="tab-panel">{children}</div>;
}

// Compound API usage
function App() {
  return (
    <Tabs defaultTab={0}>
      <TabList>
        <Tab index={0}>Profile</Tab>
        <Tab index={1}>Settings</Tab>
        <Tab index={2}>Billing</Tab>
      </TabList>
      
      <TabPanels>
        <TabPanel index={0}>
          <UserProfile />
        </TabPanel>
        <TabPanel index={1}>
          <UserSettings />
        </TabPanel>
        <TabPanel index={2}>
          <BillingInfo />
        </TabPanel>
      </TabPanels>
    </Tabs>
  );
}

State machines with useReducer

For complex state logic, useReducer with state machine patterns provides better predictability:

const authReducer = (state, action) => {
  switch (state.status) {
    case 'idle':
      switch (action.type) {
        case 'LOGIN_START':
          return { status: 'loading', error: null };
        default:
          return state;
      }
    
    case 'loading':
      switch (action.type) {
        case 'LOGIN_SUCCESS':
          return { status: 'authenticated', user: action.user, error: null };
        case 'LOGIN_ERROR':
          return { status: 'idle', error: action.error };
        default:
          return state;
      }
    
    case 'authenticated':
      switch (action.type) {
        case 'LOGOUT':
          return { status: 'idle', user: null, error: null };
        default:
          return state;
      }
    
    default:
      return state;
  }
};

function useAuth() {
  const [state, dispatch] = useReducer(authReducer, {
    status: 'idle',
    user: null,
    error: null
  });
  
  const login = useCallback(async (credentials) => {
    dispatch({ type: 'LOGIN_START' });
    
    try {
      const user = await authService.login(credentials);
      dispatch({ type: 'LOGIN_SUCCESS', user });
    } catch (error) {
      dispatch({ type: 'LOGIN_ERROR', error: error.message });
    }
  }, []);
  
  const logout = useCallback(() => {
    dispatch({ type: 'LOGOUT' });
    authService.logout();
  }, []);
  
  return {
    ...state,
    login,
    logout,
    isLoading: state.status === 'loading',
    isAuthenticated: state.status === 'authenticated'
  };
}

If you're building complex React applications and need help implementing these patterns effectively, we specialize in creating performant React architectures that scale with your business needs.

Performance monitoring and optimization

Building performant React applications requires ongoing monitoring and optimization. Here's how to implement systematic performance tracking:

React DevTools Profiler integration

Use the Profiler API to measure component performance in production:

import { Profiler } from 'react';

function onRenderCallback(id, phase, actualDuration, baseDuration, startTime, commitTime) {
  // Send performance data to analytics
  analytics.track('react_component_render', {
    componentId: id,
    phase, // 'mount' or 'update'
    actualDuration, // Time spent rendering
    baseDuration, // Estimated time without memoization
    startTime,
    commitTime
  });
}

function App() {
  return (
    <Profiler id="App" onRender={onRenderCallback}>
      <Header />
      <MainContent />
      <Footer />
    </Profiler>
  );
}

Custom performance hooks

Create hooks to monitor specific performance metrics:

function useRenderCount() {
  const renderCount = useRef(0);
  
  useEffect(() => {
    renderCount.current += 1;
  });
  
  return renderCount.current;
}

function usePerformanceMonitor(componentName) {
  const renderCount = useRenderCount();
  const startTime = useRef(performance.now());
  
  useEffect(() => {
    const renderTime = performance.now() - startTime.current;
    
    if (renderTime > 16) { // Slower than 60fps
      console.warn(`${componentName} slow render:`, {
        renderTime,
        renderCount
      });
    }
    
    startTime.current = performance.now();
  });
  
  return { renderCount };
}

// Usage
function ExpensiveComponent() {
  const { renderCount } = usePerformanceMonitor('ExpensiveComponent');
  
  // Component logic...
  
  return <div>Render #{renderCount}</div>;
}

Memory leak detection

Implement hooks to detect and prevent common memory leaks:

function useMemoryLeakDetection() {
  const subscriptions = useRef(new Set());
  const timers = useRef(new Set());
  
  const addSubscription = useCallback((subscription) => {
    subscriptions.current.add(subscription);
    return () => subscriptions.current.delete(subscription);
  }, []);
  
  const addTimer = useCallback((timerId) => {
    timers.current.add(timerId);
    return () => {
      clearTimeout(timerId);
      timers.current.delete(timerId);
    };
  }, []);
  
  useEffect(() => {
    return () => {
      // Cleanup all subscriptions
      subscriptions.current.forEach(sub => {
        if (sub && typeof sub.unsubscribe === 'function') {
          sub.unsubscribe();
        }
      });
      
      // Clear all timers
      timers.current.forEach(timerId => clearTimeout(timerId));
      
      console.log('Cleaned up:', {
        subscriptions: subscriptions.current.size,
        timers: timers.current.size
      });
    };
  }, []);
  
  return { addSubscription, addTimer };
}

Building production-ready applications

When moving from development to production, these patterns ensure your React hooks perform well under real-world conditions.

Error boundaries for hook errors

While hooks can't directly use error boundaries, you can create patterns that handle errors gracefully:

function useErrorHandler() {
  const [error, setError] = useState(null);
  
  const resetError = useCallback(() => setError(null), []);
  
  const handleError = useCallback((error) => {
    console.error('Hook error:', error);
    setError(error);
    
    // Report to error tracking service
    errorTracking.report(error);
  }, []);
  
  // Wrap async operations to catch errors
  const handleAsync = useCallback(async (asyncOperation) => {
    try {
      setError(null);
      return await asyncOperation();
    } catch (error) {
      handleError(error);
      throw error;
    }
  }, [handleError]);
  
  return { error, resetError, handleError, handleAsync };
}

// Usage with data fetching
function useApiWithErrorHandling(url) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const { error, handleAsync } = useErrorHandler();
  
  useEffect(() => {
    handleAsync(async () => {
      setLoading(true);
      const response = await fetch(url);
      const result = await response.json();
      setData(result);
    }).finally(() => {
      setLoading(false);
    });
  }, [url, handleAsync]);
  
  return { data, loading, error };
}

Concurrent features preparation

Prepare your hooks for React's concurrent features:

function useDeferredValue(value) {
  // Use React's useDeferredValue when available
  if (typeof React.useDeferredValue !== 'undefined') {
    return React.useDeferredValue(value);
  }
  
  // Fallback for older React versions
  const [deferredValue, setDeferredValue] = useState(value);
  
  useEffect(() => {
    const timer = setTimeout(() => {
      setDeferredValue(value);
    }, 0);
    
    return () => clearTimeout(timer);
  }, [value]);
  
  return deferredValue;
}

function SearchResults({ query }) {
  const deferredQuery = useDeferredValue(query);
  const { data: results } = useApi(`/search?q=${deferredQuery}`);
  
  return (
    <div>
      {results?.map(result => (
        <SearchResult key={result.id} result={result} />
      ))}
    </div>
  );
}

Hook composition patterns

Create flexible, composable hook patterns for complex applications:

function useCompositeState(initialState) {
  const [state, setState] = useState(initialState);
  const [history, setHistory] = useState([initialState]);
  const [historyIndex, setHistoryIndex] = useState(0);
  
  const updateState = useCallback((newState) => {
    const nextState = typeof newState === 'function' 
      ? newState(state) 
      : newState;
    
    setState(nextState);
    
    // Add to history
    const newHistory = history.slice(0, historyIndex + 1);
    newHistory.push(nextState);
    setHistory(newHistory);
    setHistoryIndex(newHistory.length - 1);
  }, [state, history, historyIndex]);
  
  const undo = useCallback(() => {
    if (historyIndex > 0) {
      const prevIndex = historyIndex - 1;
      setHistoryIndex(prevIndex);
      setState(history[prevIndex]);
    }
  }, [history, historyIndex]);
  
  const redo = useCallback(() => {
    if (historyIndex < history.length - 1) {
      const nextIndex = historyIndex + 1;
      setHistoryIndex(nextIndex);
      setState(history[nextIndex]);
    }
  }, [history, historyIndex]);
  
  return {
    state,
    updateState,
    undo,
    redo,
    canUndo: historyIndex > 0,
    canRedo: historyIndex < history.length - 1
  };
}

// Usage in a rich text editor
function TextEditor() {
  const {
    state: content,
    updateState: setContent,
    undo,
    redo,
    canUndo,
    canRedo
  } = useCompositeState('');
  
  return (
    <div>
      <div>
        <button onClick={undo} disabled={!canUndo}>Undo</button>
        <button onClick={redo} disabled={!canRedo}>Redo</button>
      </div>
      <textarea
        value={content}
        onChange={(e) => setContent(e.target.value)}
      />
    </div>
  );
}

Mastering React hooks for scalable applications

React hooks fundamentally changed how we build React applications, but their true power emerges when you understand the patterns that separate beginner implementations from production-ready code. The techniques we've covered - from preventing stale closures to building reusable custom hooks - form the foundation of maintainable React applications.

The key insight is that hooks are not just about state management; they're about creating composable, testable, and performant abstractions that scale with your application's complexity. Whether you're optimizing re-renders with useMemo and useCallback, building sophisticated custom hooks for data fetching, or implementing error handling patterns, the principles remain consistent: understand the render cycle, manage dependencies carefully, and always consider performance implications.

As React continues evolving with features like concurrent rendering and Suspense, the hook patterns you've learned here will serve as building blocks for even more powerful applications. The investment in understanding these fundamentals pays dividends as your applications grow from simple prototypes to complex, user-facing products.

Ready to implement these React hook patterns in your next project? Contact us to discuss how we can help you build scalable React applications that perform well and remain maintainable as they grow.

React hooks - FAQ & implementation guide

React hooks are functions that let you use state and lifecycle features in functional components. They provide cleaner code, better reusability, easier testing, and improved performance compared to class components.

Use useState for simple state like strings, numbers, and booleans. Use useReducer for complex state with multiple sub-values, when state logic is complex, or when you need to ensure state transitions follow specific patterns.

Always include dependencies in the useEffect dependency array. Use useCallback for function dependencies, useMemo for object dependencies, and consider splitting effects that have different dependency requirements.

Custom hooks are JavaScript functions that start with "use" and can call other hooks. They allow you to extract component logic into reusable functions, sharing stateful logic between multiple components.

Use useMemo for expensive calculations, useCallback for function references, React.memo for component memoization, and split state to minimize re-renders. Profile with React DevTools to identify actual bottlenecks.

No, hooks can only be used in functional components and other custom hooks. To use hooks with class components, you need to convert the class to a functional component or create a higher-order component wrapper.

Stay ahead with expert insights

Get practical tips on web design, business growth, SEO strategies, and development best practices delivered to your inbox.