React Hooks Complete Guide: useState, useEffect, useRef and More (2026)

React Hooks, introduced in React 16.8 and significantly expanded in React 18, fundamentally changed how we write React components. Instead of juggling class components, lifecycle methods, and higher-order components, hooks let you tap into state, side effects, context, and performance optimizations directly inside function components. This guide covers every major hook with practical, real-world examples — from the basics of useState to the concurrent-mode superpowers of useTransition and useDeferredValue.

useState: Managing State with Objects and Arrays

useState is the most fundamental hook. It returns a stateful value and a function to update it. One common mistake is mutating state directly — React requires you to replace state, not mutate it. This is especially important with objects and arrays.

// ❌ Wrong — direct mutation, React won't detect the change
const [user, setUser] = useState({ name: 'Alice', age: 30 });
user.age = 31; // WRONG
setUser(user); // same reference, no re-render

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

// ✅ Arrays: add item
const [items, setItems] = useState(['apple', 'banana']);
setItems(prev => [...prev, 'cherry']);

// ✅ Arrays: remove item
setItems(prev => prev.filter(item => item !== 'banana'));

// ✅ Arrays: update item at index
setItems(prev => prev.map((item, i) => i === 1 ? 'blueberry' : item));
Tip: Always use the functional update form setCount(prev => prev + 1) when the new state depends on the previous state. This prevents stale closure bugs, especially inside useEffect or event handlers that are created once.

Lazy initialization is another powerful feature — pass a function to useState when the initial value is expensive to compute:

// This function runs only once, on initial render
const [data, setData] = useState(() => {
  const saved = localStorage.getItem('formData');
  return saved ? JSON.parse(saved) : { name: '', email: '' };
});

useEffect: Side Effects and Cleanup

useEffect runs after the browser paints. It accepts a callback and a dependency array. Understanding the dependency array is critical to writing correct effects.

import { useState, useEffect } from 'react';

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

  useEffect(() => {
    let cancelled = false; // cleanup flag

    setLoading(true);
    fetch(`/api/users/${userId}`)
      .then(res => res.json())
      .then(data => {
        if (!cancelled) {
          setUser(data);
          setLoading(false);
        }
      });

    // Cleanup: runs before the next effect OR on unmount
    return () => {
      cancelled = true;
    };
  }, [userId]); // re-runs whenever userId changes

  if (loading) return <div>Loading...</div>;
  return <div>{user?.name}</div>;
}
Note: React 18 in Strict Mode intentionally mounts, unmounts, and remounts components in development to help you find cleanup bugs. If your effect fires twice during development, that's expected — make sure your cleanup function properly reverses the effect.

Common dependency pitfalls: objects and functions created during render are new references on every render. If you put them in deps, you'll get infinite loops. Move them inside the effect or stabilize them with useCallback/useMemo.

// ❌ Infinite loop — options is a new object every render
useEffect(() => {
  fetchData(options);
}, [options]);

// ✅ Stable reference — or move inside the effect
useEffect(() => {
  const options = { method: 'GET', headers: { Accept: 'application/json' } };
  fetchData(options);
}, []); // no external deps needed

useRef: DOM Access and Mutable Values

useRef serves two distinct purposes. First, it gives you a stable ref object to imperatively access DOM nodes. Second, it gives you a mutable container whose .current property can hold any value without triggering re-renders.

import { useRef, useEffect } from 'react';

function SearchInput() {
  const inputRef = useRef<HTMLInputElement>(null);

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

  return <input ref={inputRef} placeholder="Search..." />;
}

// useRef as a mutable value store (no re-render on change)
function Timer() {
  const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
  const [count, setCount] = useState(0);

  const start = () => {
    if (intervalRef.current) return; // already running
    intervalRef.current = setInterval(() => {
      setCount(c => c + 1);
    }, 1000);
  };

  const stop = () => {
    if (intervalRef.current) {
      clearInterval(intervalRef.current);
      intervalRef.current = null;
    }
  };

  useEffect(() => () => stop(), []); // cleanup on unmount

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={start}>Start</button>
      <button onClick={stop}>Stop</button>
    </div>
  );
}

useMemo and useCallback: Performance Hooks

useMemo memoizes the result of a computation. useCallback memoizes a function reference. Both accept a dependency array — the cached value is only recomputed when deps change. Use them to prevent expensive recalculations and to stabilize function references passed to memoized children.

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

// useMemo: expensive computation
function ProductList({ products, filter }: Props) {
  const filtered = useMemo(
    () => products.filter(p => p.category === filter && p.inStock),
    [products, filter] // only recomputes when these change
  );

  return <ul>{filtered.map(p => <li key={p.id}>{p.name}</li>)}</ul>;
}

// useCallback: stable function reference for child components
function Parent() {
  const [count, setCount] = useState(0);
  const [text, setText] = useState('');

  // Without useCallback, handleAdd is a new function every render
  // Child would re-render even when only `text` changed
  const handleAdd = useCallback(() => {
    setCount(c => c + 1);
  }, []); // empty deps — setCount is stable

  return (
    <div>
      <input value={text} onChange={e => setText(e.target.value)} />
      <Counter count={count} onAdd={handleAdd} />
    </div>
  );
}

// memo prevents re-render if props haven't changed
const Counter = memo(({ count, onAdd }: { count: number; onAdd: () => void }) => {
  console.log('Counter rendered');
  return <button onClick={onAdd}>Count: {count}</button>;
});
Tip: Don't over-use useMemo and useCallback. They have their own cost (memory + comparison logic). Profile with React DevTools first, then optimize only the components that show render performance problems.

useContext: Consuming Context

useContext subscribes a component to a React context. Every component consuming the context re-renders when the context value changes — so design your context carefully to avoid unnecessary re-renders.

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

interface ThemeContextType {
  theme: 'light' | 'dark';
  toggleTheme: () => void;
}

const ThemeContext = createContext<ThemeContextType | null>(null);

export function ThemeProvider({ children }: { children: React.ReactNode }) {
  const [theme, setTheme] = useState<'light' | 'dark'>('dark');
  const toggleTheme = () => setTheme(t => t === 'dark' ? 'light' : 'dark');

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

// Custom hook with built-in null guard
export function useTheme() {
  const ctx = useContext(ThemeContext);
  if (!ctx) throw new Error('useTheme must be used inside ThemeProvider');
  return ctx;
}

// Consumer component
function Header() {
  const { theme, toggleTheme } = useTheme();
  return <button onClick={toggleTheme}>Current: {theme}</button>;
}

useReducer: Complex State Logic

useReducer is the hook-based equivalent of Redux reducers. It shines when you have multiple related pieces of state that change together, or when the next state depends on the previous state in complex ways.

type State = {
  items: CartItem[];
  total: number;
  loading: boolean;
};

type Action =
  | { type: 'ADD_ITEM'; payload: CartItem }
  | { type: 'REMOVE_ITEM'; payload: string }
  | { type: 'SET_LOADING'; payload: boolean };

function cartReducer(state: State, action: Action): State {
  switch (action.type) {
    case 'ADD_ITEM':
      return {
        ...state,
        items: [...state.items, action.payload],
        total: state.total + action.payload.price,
      };
    case 'REMOVE_ITEM': {
      const item = state.items.find(i => i.id === action.payload);
      return {
        ...state,
        items: state.items.filter(i => i.id !== action.payload),
        total: state.total - (item?.price ?? 0),
      };
    }
    case 'SET_LOADING':
      return { ...state, loading: action.payload };
    default:
      return state;
  }
}

function Cart() {
  const [state, dispatch] = useReducer(cartReducer, {
    items: [], total: 0, loading: false,
  });

  return (
    <div>
      <p>Total: ${state.total.toFixed(2)}</p>
      {state.items.map(item => (
        <div key={item.id}>
          {item.name}
          <button onClick={() => dispatch({ type: 'REMOVE_ITEM', payload: item.id })}>
            Remove
          </button>
        </div>
      ))}
    </div>
  );
}

useTransition and useDeferredValue

React 18 introduced concurrent features. useTransition marks a state update as non-urgent, allowing React to keep the UI responsive while computing the expensive update in the background. useDeferredValue is similar but defers a derived value rather than the update itself.

import { useState, useTransition, useDeferredValue } from 'react';

// useTransition example: filter a large list without freezing the input
function SearchPage({ allItems }: { allItems: string[] }) {
  const [query, setQuery] = useState('');
  const [filter, setFilter] = useState('');
  const [isPending, startTransition] = useTransition();

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setQuery(e.target.value); // urgent — update input immediately
    startTransition(() => {
      setFilter(e.target.value); // non-urgent — can be deferred
    });
  };

  const results = allItems.filter(item =>
    item.toLowerCase().includes(filter.toLowerCase())
  );

  return (
    <div>
      <input value={query} onChange={handleChange} placeholder="Search..." />
      {isPending && <span>Loading...</span>}
      <ul>{results.map(r => <li key={r}>{r}</li>)}</ul>
    </div>
  );
}

// useDeferredValue: defer a value passed from parent
function ResultList({ query }: { query: string }) {
  const deferredQuery = useDeferredValue(query);
  const isStale = query !== deferredQuery;

  const results = expensiveFilter(deferredQuery);

  return (
    <ul style={{ opacity: isStale ? 0.5 : 1 }}>
      {results.map(r => <li key={r}>{r}</li>)}
    </ul>
  );
}

useId and Other Modern Hooks

useId generates a stable, unique ID that is consistent between server and client renders — perfect for accessibility attributes like htmlFor/aria-describedby.

import { useId } from 'react';

function FormField({ label }: { label: string }) {
  const id = useId();
  return (
    <div>
      <label htmlFor={id}>{label}</label>
      <input id={id} type="text" aria-describedby={`${id}-help`} />
      <span id={`${id}-help`}>Enter your {label.toLowerCase()}</span>
    </div>
  );
}

// useSyncExternalStore — subscribe to external stores (Redux, browser APIs)
import { useSyncExternalStore } from 'react';

function useOnlineStatus() {
  return useSyncExternalStore(
    (callback) => {
      window.addEventListener('online', callback);
      window.addEventListener('offline', callback);
      return () => {
        window.removeEventListener('online', callback);
        window.removeEventListener('offline', callback);
      };
    },
    () => navigator.onLine,    // client snapshot
    () => true                  // server snapshot
  );
}

Rules of Hooks

React hooks have two fundamental rules enforced by ESLint's eslint-plugin-react-hooks:

  • Only call hooks at the top level — never inside loops, conditions, or nested functions. React relies on call order to match hook state across renders.
  • Only call hooks from React functions — function components or custom hooks (prefixed with use).
// ❌ Wrong — conditional hook call
function BadComponent({ show }: { show: boolean }) {
  if (show) {
    const [count, setCount] = useState(0); // breaks hook order!
  }
}

// ✅ Correct — always call hooks at the top level
function GoodComponent({ show }: { show: boolean }) {
  const [count, setCount] = useState(0);
  if (!show) return null;
  return <div>{count}</div>;
}

Frequently Asked Questions

What is the difference between useMemo and useCallback?

useMemo caches a computed value — it runs a function and stores the result. useCallback caches the function itself — it stores the function reference without calling it. Use useMemo for expensive calculations, useCallback for stable function props passed to memoized children.

When should I use useReducer instead of useState?

Use useReducer when you have 3+ pieces of related state that update together, when the next state depends heavily on the previous one, or when you want to centralize and test update logic independently. For simple scalar values or independent state, useState is cleaner.

Why does useEffect run twice in development?

React 18 Strict Mode mounts each component twice in development to help detect side effects that aren't cleaned up properly. Your cleanup function should reverse everything the effect does. This double-mount only happens in development; production builds run effects once.

What is the difference between useTransition and useDeferredValue?

useTransition wraps a state setter call to mark it as non-urgent. useDeferredValue wraps a value to defer downstream computation. Use useTransition when you control the state update; use useDeferredValue when the value comes from a parent and you can't control when it updates.

Can I call hooks inside a regular JavaScript function?

No. Hooks can only be called from React function components or from other custom hooks. Calling them from regular JavaScript functions, class methods, or event handlers outside components will throw a "Hooks can only be called inside of the body of a function component" error.