React Hooks: The Complete Guide for 2026

Everything you need to know about React Hooks — from useState basics to advanced custom hooks, performance optimization with useMemo and useCallback, and the patterns that separate clean React code from unmaintainable spaghetti.

Why Hooks Replaced Classes

Before React 16.8, the only way to use state or lifecycle methods was with class components. That meant writing constructor(), binding this in event handlers, scattering related logic across componentDidMount, componentDidUpdate, and componentWillUnmount, and wrapping everything in render props or higher-order components to share logic.

Hooks fixed all of that. A single useEffect replaces three lifecycle methods. State lives right next to the JSX that uses it. Shared logic goes into custom hooks instead of HOCs. And function components are shorter, easier to read, and easier for tools to optimize.

As of 2026, the React team considers class components legacy. React 19 and the React Compiler are built entirely around function components and hooks. If you are writing class components in new code, you are fighting the framework.

Key Point

Hooks are not just an alternative to classes — they are the foundation of modern React. Server Components, the React Compiler, Suspense, and concurrent features all assume function components with hooks.

useState: Managing Component State

useState is the most fundamental hook. It declares a state variable, gives you its current value, and provides a function to update it. Each call to the setter triggers a re-render.

jsx — Basic useState
import { useState } from 'react';

function Counter() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
      <button onClick={() => setCount(0)}>Reset</button>
    </div>
  );
}

Functional Updates

When the new state depends on the previous state, use the functional updater form. This avoids bugs with stale closures, especially in async code or effects.

jsx — Functional Updater
// Wrong: may use stale value in rapid clicks or async operations
setCount(count + 1);

// Correct: always uses the latest state
setCount(prev => prev + 1);

State with Objects

Unlike this.setState in class components, useState does not merge objects automatically. You must spread the previous state yourself.

jsx — Object State
const [user, setUser] = useState({ name: '', email: '', role: 'viewer' });

// Update one field while keeping the rest
setUser(prev => ({ ...prev, name: 'Alice' }));

Lazy Initialization

If computing the initial state is expensive (parsing JSON, reading localStorage), pass a function to useState. It runs only on the first render.

jsx — Lazy Initial State
// This runs on EVERY render (bad)
const [data, setData] = useState(JSON.parse(localStorage.getItem('data')));

// This runs only on the FIRST render (good)
const [data, setData] = useState(() => JSON.parse(localStorage.getItem('data')));

When working with complex state that involves JSON payloads, the JSON Formatter can help you inspect and debug your state objects during development.

useEffect: Side Effects and Lifecycle

useEffect runs side effects after React renders your component. It replaces componentDidMount, componentDidUpdate, and componentWillUnmount with a single, composable API.

jsx — useEffect Patterns
import { useEffect } from 'react';

// Run after EVERY render
useEffect(() => {
  document.title = `Count: ${count}`;
});

// Run only on mount (empty dependency array)
useEffect(() => {
  fetchUserData();
}, []);

// Run when specific values change
useEffect(() => {
  fetchUserData(userId);
}, [userId]);

// Cleanup (runs before re-execution and on unmount)
useEffect(() => {
  const ws = new WebSocket(url);
  ws.onmessage = handleMessage;

  return () => {
    ws.close(); // Cleanup: close connection
  };
}, [url]);

The Dependency Array

The dependency array tells React when to re-run your effect. If you omit it, the effect runs after every render. If you pass an empty array, it runs once on mount. If you list specific values, it runs when any of those values change (compared with Object.is).

Common Pitfall

Never lie about dependencies to "fix" infinite loops. If your effect uses a value, it must be in the dependency array. Use eslint-plugin-react-hooks to catch missing dependencies automatically. If you have a legitimate reason to skip a dependency, restructure the effect instead.

Data Fetching Pattern

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

  useEffect(() => {
    let cancelled = false; // Prevent state updates after unmount

    async function fetchUser() {
      setLoading(true);
      const res = await fetch(`/api/users/${userId}`);
      const data = await res.json();

      if (!cancelled) {
        setUser(data);
        setLoading(false);
      }
    }

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

  if (loading) return <p>Loading...</p>;
  return <p>{user.name}</p>;
}

The cancelled flag prevents the classic "state update on unmounted component" warning. When userId changes, the cleanup function sets cancelled = true for the old fetch, and a new fetch starts for the new user.

useRef: DOM Access and Mutable Values

useRef returns a mutable object with a .current property that persists across renders without causing re-renders when changed. It has two primary uses: referencing DOM elements and storing mutable values.

jsx — DOM Reference
function SearchInput() {
  const inputRef = useRef(null);

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

  return <input ref={inputRef} placeholder="Search..." />;
}
jsx — Storing Previous Values
function usePrevious(value) {
  const ref = useRef();

  useEffect(() => {
    ref.current = value;
  }, [value]);

  return ref.current; // Returns the value from the previous render
}

// Usage
function PriceDisplay({ price }) {
  const previousPrice = usePrevious(price);
  const direction = price > previousPrice ? 'up' : 'down';

  return <span className={direction}>${price}</span>;
}

useRef vs useState: When to Use Which

Scenario Use
Value change should update the UI useState
Store a DOM element reference useRef
Store a timer/interval ID useRef
Track if component is mounted useRef
Form input value (controlled) useState
Cache previous prop value useRef

useContext: Shared State Without Prop Drilling

When multiple components need the same data and it is cumbersome to pass props through every level of the tree, useContext lets any descendant component read the value directly.

jsx — Theme Context
import { createContext, useContext, useState } from 'react';

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

// 2. Create a provider component
function ThemeProvider({ children }) {
  const [theme, setTheme] = useState('dark');

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

// 3. Consume with useContext (any depth)
function ThemeToggle() {
  const { theme, setTheme } = useContext(ThemeContext);

  return (
    <button onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}>
      Current: {theme}
    </button>
  );
}
Performance Warning

Every component that calls useContext(SomeContext) re-renders when the context value changes — even if it only uses one property of the value object. For frequently-updating contexts, split them (one for state, one for dispatch) or use an external state library like Zustand.

useMemo and useCallback: Performance

These two hooks are memoization tools. They prevent expensive recalculations or unnecessary re-renders by caching values between renders.

useMemo: Cache Computed Values

jsx — useMemo
function ProductList({ products, filter }) {
  // Only re-filters when products or filter changes
  const filteredProducts = useMemo(() => {
    return products.filter(p => p.category === filter);
  }, [products, filter]);

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

useCallback: Cache Functions

jsx — useCallback with React.memo
const ExpensiveChild = React.memo(function ExpensiveChild({ onClick, data }) {
  console.log('ExpensiveChild rendered');
  return <button onClick={onClick}>{data.label}</button>;
});

function Parent() {
  const [count, setCount] = useState(0);
  const [label, setLabel] = useState('Click me');

  // Without useCallback, a new function is created on every render,
  // which defeats React.memo on ExpensiveChild
  const handleClick = useCallback(() => {
    console.log('Clicked');
  }, []); // No dependencies: function never changes

  const data = useMemo(() => ({ label }), [label]);

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(c => c + 1)}>Increment</button>
      <ExpensiveChild onClick={handleClick} data={data} />
    </div>
  );
}

In the example above, incrementing count re-renders Parent but does not re-render ExpensiveChild because both handleClick and data maintain stable references.

React Compiler Note

The React Compiler (shipping with React 19) can automatically memoize values and functions at compile time. If you are on React 19+, you may not need manual useMemo and useCallback as often. However, understanding how they work is still essential for debugging and for codebases not yet using the compiler.

useReducer: Complex State Logic

When state transitions become complex — multiple actions, conditional logic, or deeply nested updates — useReducer provides a structured alternative to useState.

jsx — useReducer
import { useReducer } from 'react';

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

function cartReducer(state, action) {
  switch (action.type) {
    case 'ADD_ITEM':
      return { ...state, items: [...state.items, action.payload] };
    case 'REMOVE_ITEM':
      return { ...state, items: state.items.filter(i => i.id !== action.payload) };
    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 ShoppingCart() {
  const [state, dispatch] = useReducer(cartReducer, initialState);

  const addItem = (item) => dispatch({ type: 'ADD_ITEM', payload: item });
  const removeItem = (id) => dispatch({ type: 'REMOVE_ITEM', payload: id });

  return (
    <div>
      {state.items.map(item => (
        <div key={item.id}>
          {item.name}
          <button onClick={() => removeItem(item.id)}>Remove</button>
        </div>
      ))}
    </div>
  );
}

The reducer pattern keeps all state transitions in one place, making them testable outside the component. The dispatch function is also stable across renders, so you can pass it through context without causing unnecessary re-renders.

If your reducer starts handling API response data, validate the shape with the JSON Schema Validator to catch malformed payloads early.

Custom Hooks: Reusable Logic

A custom hook is a function whose name starts with use and that calls other hooks inside it. Custom hooks let you extract and share stateful logic between components without changing the component tree.

useLocalStorage

jsx — useLocalStorage Hook
function useLocalStorage(key, initialValue) {
  const [storedValue, setStoredValue] = useState(() => {
    try {
      const item = window.localStorage.getItem(key);
      return item ? JSON.parse(item) : initialValue;
    } catch {
      return initialValue;
    }
  });

  const setValue = useCallback((value) => {
    const valueToStore = value instanceof Function ? value(storedValue) : value;
    setStoredValue(valueToStore);
    window.localStorage.setItem(key, JSON.stringify(valueToStore));
  }, [key, storedValue]);

  return [storedValue, setValue];
}

// Usage
function Settings() {
  const [theme, setTheme] = useLocalStorage('theme', 'dark');
  return <button onClick={() => setTheme(t => t === 'dark' ? 'light' : 'dark')}>{theme}</button>;
}

useDebounce

jsx — useDebounce Hook
function useDebounce(value, delay) {
  const [debouncedValue, setDebouncedValue] = useState(value);

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

  return debouncedValue;
}

// Usage: search input that waits 300ms after typing
function Search() {
  const [query, setQuery] = useState('');
  const debouncedQuery = useDebounce(query, 300);

  useEffect(() => {
    if (debouncedQuery) {
      searchAPI(debouncedQuery);
    }
  }, [debouncedQuery]);

  return <input value={query} onChange={e => setQuery(e.target.value)} />;
}

useFetch

jsx — useFetch Hook
function useFetch(url) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    let cancelled = false;

    async function fetchData() {
      setLoading(true);
      setError(null);

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

    fetchData();
    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>;
}

When converting HTML templates into React components, the HTML to JSX Converter handles the syntax transformation automatically — converting class to className, self-closing tags, and inline styles to objects.

Common Mistakes and How to Fix Them

1. Missing Dependency Array in useEffect

jsx — Bug: runs on every render
// Missing dependency array = runs after EVERY render
useEffect(() => {
  fetchData(); // This fires on every single re-render
});

// Fix: add the dependency array
useEffect(() => {
  fetchData();
}, []); // Runs once on mount

2. Object/Array as Dependency

jsx — Bug: infinite loop
function App({ userId }) {
  const options = { method: 'GET', headers: { auth: token } }; // New object every render

  useEffect(() => {
    fetch(`/api/users/${userId}`, options);
  }, [userId, options]); // options is new every time = infinite loop

  // Fix: memoize the object
  const options = useMemo(() => ({
    method: 'GET', headers: { auth: token }
  }), [token]);
}

3. Stale Closures

jsx — Bug: stale state in interval
function Timer() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const id = setInterval(() => {
      setCount(count + 1); // Bug: count is always 0 (stale closure)
    }, 1000);
    return () => clearInterval(id);
  }, []);

  // Fix: use functional updater
  useEffect(() => {
    const id = setInterval(() => {
      setCount(prev => prev + 1); // Always uses latest value
    }, 1000);
    return () => clearInterval(id);
  }, []);
}

4. Calling Hooks Conditionally

jsx — Bug: conditional hook call
// WRONG: hooks must not be inside conditions
function Profile({ isLoggedIn }) {
  if (!isLoggedIn) return <p>Please log in</p>;
  const [user, setUser] = useState(null); // Hook order changes!

  // FIX: always call hooks at the top level
  const [user, setUser] = useState(null);
  if (!isLoggedIn) return <p>Please log in</p>;
}

5. Over-Memoizing

Wrapping everything in useMemo and useCallback does not make your app faster. Each hook has overhead (storing the cached value, comparing dependencies). Only memoize when you have measured a performance problem or when a stable reference is required for correctness (e.g., preventing infinite useEffect loops).

Use the JavaScript Formatter to keep your hook code clean and consistently indented while working through refactors like these.

The Rules of Hooks

React enforces two rules for hooks. Breaking them causes bugs that are difficult to trace.

  1. Only call hooks at the top level. Do not call hooks inside loops, conditions, or nested functions. React relies on call order to match hooks to their state between renders. If the order changes, state gets mixed up.
  2. Only call hooks from React functions. Call hooks from function components or from custom hooks. Do not call them from regular JavaScript functions, class components, or event handlers.

The eslint-plugin-react-hooks package enforces both rules automatically. Add it to your ESLint configuration — the ESLint Config Generator can help you set up a project-ready config with React rules included.

Quick Reference: All Built-in Hooks

State: useState, useReducer. Context: useContext. Refs: useRef, useImperativeHandle. Effects: useEffect, useLayoutEffect, useInsertionEffect. Performance: useMemo, useCallback, useTransition, useDeferredValue. Other: useId, useSyncExternalStore, useDebugValue, useActionState, useFormStatus, useOptimistic, use.

Format, Convert, and Validate Your Code

QTool has 269 free developer tools for JavaScript, TypeScript, JSON, and more. Format code, convert between formats, and validate schemas — all in your browser.

Open JS Formatter HTML to JSX

React Developer Tools


Frequently Asked Questions

React Hooks are functions that let you use state, lifecycle behavior, context, and other React features inside function components without writing classes. Introduced in React 16.8, hooks are now the standard way to build React applications. They simplify code by eliminating class boilerplate (constructor, this binding, render methods), make logic reusable through custom hooks, and compose better than higher-order components or render props. As of 2026, the React team recommends function components with hooks for all new code, and class components are considered legacy.

useMemo caches the result of a computation: useMemo(() => expensiveCalculation(a, b), [a, b]) returns the cached value and only recalculates when a or b changes. useCallback caches the function itself: useCallback((x) => x + a, [a]) returns the same function reference as long as a stays the same. In practice, useCallback(fn, deps) is equivalent to useMemo(() => fn, deps). Use useMemo when you have an expensive calculation you want to skip on re-renders. Use useCallback when you need a stable function reference, typically when passing callbacks to child components wrapped in React.memo to prevent unnecessary re-renders.

Use useRef when you need to store a value that persists across renders but should not trigger a re-render when it changes. Common use cases include referencing DOM elements (inputRef.current.focus()), storing previous values, holding interval or timeout IDs, and tracking whether a component is mounted. Use useState when changing the value should update the UI. The key difference is that updating a ref (ref.current = newValue) does not cause a re-render, while calling a state setter (setValue(newValue)) does. If you need the screen to reflect the change, use state. If you just need to remember something silently, use a ref.

Infinite loops happen when your useEffect updates a value that is also in its dependency array. For example, if your effect calls setState(count + 1) and count is in the dependency array, the state change triggers a re-render, which triggers the effect again, creating an infinite loop. To fix this: use the functional updater form setState(prev => prev + 1) so you do not need count in the dependency array; move objects and arrays that are recreated on every render outside the component or wrap them with useMemo; use a ref instead of state if you do not need to trigger a re-render; and always specify the correct dependency array (never omit it to hide the problem). The ESLint plugin eslint-plugin-react-hooks catches most of these issues at development time.

No. useCallback adds overhead (the hook call itself, the dependency comparison, and the cached reference). It only provides a benefit when the stable function reference actually prevents work, specifically when the function is passed as a prop to a child component wrapped in React.memo, or when it appears in the dependency array of another hook. If a function is only used inside the same component and not passed down, useCallback adds complexity without benefit. The React team recommends writing code without useCallback first, then adding it only where you measure an actual performance problem. The React Compiler (available since React 19) can automatically memoize functions, reducing the need for manual useCallback in many cases.

Use useContext combined with a context provider. Create a context with React.createContext(), wrap your component tree in a Provider that holds the shared state, and call useContext() in any descendant component to access that state. For simple global state (theme, auth, locale), this is sufficient. For complex state with frequent updates, consider splitting contexts (one for state, one for dispatch) to avoid re-rendering every consumer on every update. For application-scale state management with many consumers, external libraries like Zustand, Jotai, or Redux Toolkit may perform better because they allow components to subscribe to specific slices of state rather than the entire context value.

NT

Christian Bucher

We build free developer tools for JavaScript, React, TypeScript, and more. 269 tool pages, all browser-based, no signup required.

269 Developer Tools, One Place

Browse 269 indexed tool pages with no QTool account required, and inspect the source on GitHub.

Open Source — Free Forever Try Free Tools

Related Tools

CSS Box Shadow Generator · Emoji Picker & Search · Free Git Diff Viewer

Related Tools

Free JSON to YAML Converter · Free API Mock Server · Code Screenshot Generator - Beautiful Code Images

Related Articles

Built by Miguel

Need a custom tool or website?

From . Delivered in 24-48h. You own the code.

View Services →