DEV Community

楊東霖
楊東霖

Posted on • Originally published at devtoolkit.cc

React Hooks Complete Guide: useState, useEffect, and More

React Hooks, introduced in React 16.8, fundamentally changed how we write React components. They let you use state and other React features in functional components — no classes required. This guide covers every built-in hook with real examples, common patterns, and the pitfalls to avoid.

Why Hooks?

Before hooks, stateful logic was locked inside class components, which led to several problems: complex lifecycle methods that forced unrelated logic together, difficulty reusing stateful logic between components, and confusing this bindings. Hooks solve all of these by letting you extract stateful logic into reusable functions.

useState

The most fundamental hook. useState adds state to a functional component and returns the current value plus a function to update it.

import { useState } from 'react';

function Counter() {
  const [count, setCount] = useState(0);  // initial value: 0

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>+</button>
      <button onClick={() => setCount(count - 1)}>-</button>
      <button onClick={() => setCount(0)}>Reset</button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Functional Updates

When the new state depends on the previous state, use the functional form of the setter. This is important in async contexts where the state value might be stale.

// ❌ Potentially stale
setCount(count + 1);

// ✅ Always uses the latest state
setCount(prevCount => prevCount + 1);

// Practical example: toggling
const [isOpen, setIsOpen] = useState(false);
const toggle = () => setIsOpen(prev => !prev);
Enter fullscreen mode Exit fullscreen mode

Objects and Arrays in State

// Always replace, never mutate
const [user, setUser] = useState({ name: 'Alice', age: 30 });

// ❌ Wrong — mutates state directly
user.age = 31;
setUser(user);

// ✅ Correct — create a new object
setUser(prev => ({ ...prev, age: 31 }));

// Array state
const [items, setItems] = useState([]);

const addItem = (item) => setItems(prev => [...prev, item]);
const removeItem = (id) => setItems(prev => prev.filter(i => i.id !== id));
const updateItem = (id, changes) => setItems(prev =>
  prev.map(i => i.id === id ? { ...i, ...changes } : i)
);
Enter fullscreen mode Exit fullscreen mode

Lazy Initial State

// If initial state is expensive to compute, pass a function
// The function only runs once on mount
const [data, setData] = useState(() => {
  const stored = localStorage.getItem('data');
  return stored ? JSON.parse(stored) : defaultData;
});
Enter fullscreen mode Exit fullscreen mode

useEffect

useEffect lets you perform side effects in functional components: data fetching, subscriptions, DOM manipulation, timers.

import { useState, useEffect } from 'react';

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    // This runs after every render where userId changed
    let cancelled = false;

    async function fetchUser() {
      setLoading(true);
      try {
        const response = await fetch(`/api/users/${userId}`);
        const data = await response.json();
        if (!cancelled) setUser(data);
      } finally {
        if (!cancelled) setLoading(false);
      }
    }

    fetchUser();

    // Cleanup function: runs when component unmounts or userId changes
    return () => { cancelled = true; };
  }, [userId]);  // dependency array: re-run when userId changes

  if (loading) return <p>Loading...</p>;
  return <p>{user?.name}</p>;
}
Enter fullscreen mode Exit fullscreen mode

Effect Dependencies

// Run on every render (no dependency array)
useEffect(() => { console.log('rendered'); });

// Run only once on mount (empty dependency array)
useEffect(() => {
  initAnalytics();
  return () => cleanup();
}, []);

// Run when specific values change
useEffect(() => {
  document.title = `${count} notifications`;
}, [count]);

// Cleanup patterns
useEffect(() => {
  const subscription = subscribe(topic, handler);
  return () => subscription.unsubscribe();  // runs on unmount
}, [topic]);

useEffect(() => {
  const id = setInterval(tick, 1000);
  return () => clearInterval(id);
}, []);

useEffect(() => {
  window.addEventListener('resize', handleResize);
  return () => window.removeEventListener('resize', handleResize);
}, []);
Enter fullscreen mode Exit fullscreen mode

useContext

useContext lets you consume React context without nesting render props or consumer components.

import { createContext, useContext, useState } from 'react';

// 1. Create context
const ThemeContext = createContext('light');

// 2. Provide context
function App() {
  const [theme, setTheme] = useState('light');

  return (
    <ThemeContext.Provider value={{ theme, setTheme }}>
      <Main />
    </ThemeContext.Provider>
  );
}

// 3. Consume context anywhere in the tree
function ThemedButton() {
  const { theme, setTheme } = useContext(ThemeContext);
  const toggle = () => setTheme(t => t === 'light' ? 'dark' : 'light');

  return (
    <button
      style={{ background: theme === 'light' ? '#fff' : '#333' }}
      onClick={toggle}
    >
      Current: {theme}
    </button>
  );
}

// Custom hook for context (recommended pattern)
function useTheme() {
  const context = useContext(ThemeContext);
  if (!context) throw new Error('useTheme must be inside ThemeProvider');
  return context;
}
Enter fullscreen mode Exit fullscreen mode

useRef

useRef returns a mutable ref object that persists across renders without causing re-renders. Use it for DOM references and storing mutable values.

import { useRef, useEffect } from 'react';

function TextInput() {
  const inputRef = useRef(null);

  useEffect(() => {
    inputRef.current.focus(); // auto-focus on mount
  }, []);

  return <input ref={inputRef} type="text" />;
}

// Store previous value
function usePrevious(value) {
  const ref = useRef();
  useEffect(() => { ref.current = value; });
  return ref.current; // returns previous value before this render
}

// Store a mutable value that doesn't trigger re-renders
function Timer() {
  const [isRunning, setIsRunning] = useState(false);
  const intervalRef = useRef(null);

  const start = () => {
    setIsRunning(true);
    intervalRef.current = setInterval(() => tick(), 1000);
  };

  const stop = () => {
    setIsRunning(false);
    clearInterval(intervalRef.current);
  };

  return (
    <div>
      {isRunning
        ? <button onClick={stop}>Stop</button>
        : <button onClick={start}>Start</button>
      }
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

useMemo

useMemo memoizes an expensive computation, only recalculating when dependencies change.

import { useMemo } from 'react';

function ProductList({ products, filter, sortBy }) {
  // Only recomputed when products, filter, or sortBy changes
  const processedProducts = useMemo(() => {
    return products
      .filter(p => p.category === filter || filter === 'all')
      .sort((a, b) => {
        if (sortBy === 'price') return a.price - b.price;
        if (sortBy === 'name') return a.name.localeCompare(b.name);
        return 0;
      });
  }, [products, filter, sortBy]);

  return (
    <ul>
      {processedProducts.map(p => (
        <li key={p.id}>{p.name}  ${p.price}</li>
      ))}
    </ul>
  );
}
Enter fullscreen mode Exit fullscreen mode

Don't overuse useMemo. The memoization itself has a cost. Only use it for genuinely expensive computations (complex data transformations, sorting large arrays) or when referential equality matters for child component re-renders.

useCallback

useCallback memoizes a function itself. It's most useful when passing callbacks to optimized child components that rely on reference equality to skip re-renders.

import { useCallback, memo } from 'react';

// Child component wrapped in memo — only re-renders if props change
const Button = memo(({ onClick, children }) => {
  console.log('Button rendered');
  return <button onClick={onClick}>{children}</button>;
});

function Parent() {
  const [count, setCount] = useState(0);
  const [text, setText] = useState('');

  // Without useCallback: new function reference on every render
  // → Button re-renders every time text changes

  // With useCallback: stable reference unless count changes
  const handleClick = useCallback(() => {
    setCount(prev => prev + 1);
  }, []); // no dependencies — function never changes

  return (
    <div>
      <input value={text} onChange={e => setText(e.target.value)} />
      <Button onClick={handleClick}>Count: {count}</Button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

useReducer

useReducer is an alternative to useState for complex state logic with multiple sub-values or when the next state depends on the previous state in non-trivial ways.

import { useReducer } from 'react';

const initialState = {
  items: [],
  total: 0,
  loading: false,
  error: null,
};

function cartReducer(state, action) {
  switch (action.type) {
    case 'ADD_ITEM':
      return {
        ...state,
        items: [...state.items, action.payload],
        total: state.total + action.payload.price,
      };
    case 'REMOVE_ITEM':
      const removed = state.items.find(i => i.id === action.payload);
      return {
        ...state,
        items: state.items.filter(i => i.id !== action.payload),
        total: state.total - (removed?.price || 0),
      };
    case 'SET_LOADING':
      return { ...state, loading: action.payload };
    case 'SET_ERROR':
      return { ...state, error: action.payload, loading: false };
    case 'CLEAR':
      return initialState;
    default:
      throw new Error(`Unknown action: ${action.type}`);
  }
}

function Cart() {
  const [state, dispatch] = useReducer(cartReducer, initialState);

  const addToCart = (product) =>
    dispatch({ type: 'ADD_ITEM', payload: product });

  const removeFromCart = (id) =>
    dispatch({ type: 'REMOVE_ITEM', payload: id });

  return (
    <div>
      <h2>Cart ({state.items.length} items)</h2>
      <p>Total: ${state.total.toFixed(2)}</p>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Custom Hooks

Custom hooks let you extract and reuse stateful logic. A custom hook is just a function that starts with use and calls other hooks.

// useFetch — data fetching with loading/error states
function useFetch(url) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    let cancelled = false;
    setLoading(true);
    setError(null);

    fetch(url)
      .then(res => {
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        return res.json();
      })
      .then(data => { if (!cancelled) setData(data); })
      .catch(err => { if (!cancelled) setError(err.message); })
      .finally(() => { if (!cancelled) setLoading(false); });

    return () => { cancelled = true; };
  }, [url]);

  return { data, loading, error };
}

// Usage
function UserList() {
  const { data: users, loading, error } = useFetch('/api/users');
  if (loading) return <p>Loading...</p>;
  if (error) return <p>Error: {error}</p>;
  return <ul>{users.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
}

// useLocalStorage — persist state to localStorage
function useLocalStorage(key, initialValue) {
  const [value, setValue] = useState(() => {
    try {
      const item = localStorage.getItem(key);
      return item ? JSON.parse(item) : initialValue;
    } catch {
      return initialValue;
    }
  });

  const setStoredValue = (newValue) => {
    setValue(newValue);
    localStorage.setItem(key, JSON.stringify(newValue));
  };

  return [value, setStoredValue];
}

// useDebounce — debounce a value
function useDebounce(value, delay) {
  const [debouncedValue, setDebouncedValue] = useState(value);

  useEffect(() => {
    const timer = setTimeout(() => setDebouncedValue(value), delay);
    return () => clearTimeout(timer);
  }, [value, delay]);

  return debouncedValue;
}

// useWindowSize — track window dimensions
function useWindowSize() {
  const [size, setSize] = useState({
    width: window.innerWidth,
    height: window.innerHeight,
  });

  useEffect(() => {
    const handleResize = () => setSize({
      width: window.innerWidth,
      height: window.innerHeight,
    });
    window.addEventListener('resize', handleResize);
    return () => window.removeEventListener('resize', handleResize);
  }, []);

  return size;
}
Enter fullscreen mode Exit fullscreen mode

Rules of Hooks

React enforces two rules (and the ESLint plugin eslint-plugin-react-hooks enforces them automatically):

  • Only call hooks at the top level. Never inside loops, conditions, or nested functions. React relies on the order of hook calls to associate state correctly.
  • Only call hooks from React functions. Call them from React functional components or from custom hooks.
// ❌ Wrong — conditional hook call
function Component({ condition }) {
  if (condition) {
    const [val, setVal] = useState(0); // breaks rules!
  }
}

// ✅ Correct — condition inside the hook logic
function Component({ condition }) {
  const [val, setVal] = useState(0);
  const displayVal = condition ? val : null;
}
Enter fullscreen mode Exit fullscreen mode

Quick Reference

useState(initial)          // local state
useEffect(fn, deps)        // side effects, subscriptions
useContext(Context)        // consume context
useRef(initialValue)       // DOM refs, mutable values
useMemo(() => value, deps) // memoize expensive computation
useCallback(fn, deps)      // memoize function reference
useReducer(reducer, init)  // complex state logic
useId()                    // generate unique IDs (React 18)
useTransition()            // mark updates as non-urgent (React 18)
useDeferredValue(value)    // defer re-rendering (React 18)
useLayoutEffect(fn, deps)  // like useEffect but synchronous
useImperativeHandle        // customize ref values
useDebugValue              // custom label in DevTools
Enter fullscreen mode Exit fullscreen mode

Hooks take a bit of time to get used to — especially understanding when to use useEffect correctly — but once they click, they make React development significantly more pleasant. Start with useState and useEffect, then add useContext and useRef as you need them. Build custom hooks for anything you find yourself copy-pasting between components.

Free Developer Tools

If you found this article helpful, check out DevToolkit — 40+ free browser-based developer tools with no signup required.

Popular tools: JSON Formatter · Regex Tester · JWT Decoder · Base64 Encoder

🛒 Get the DevToolkit Starter Kit on Gumroad — source code, deployment guide, and customization templates.

Top comments (0)