React

React Hooks Explained With Examples: A Practical Guide

Stop guessing which hook to use. Master useState, useEffect, useRef, useMemo, useContext, and custom hooks with real-world examples you can drop into any React project.

May 22, 202611 min read
Share
Advertisement (not configured)

Introduction

Hooks changed React forever. Class components vanished from new codebases, lifecycle methods became one-liners, and reusable logic finally got a name: custom hooks. But there's a catch — most tutorials show you the syntax without explaining when to reach for each hook.

This guide walks through the six hooks you'll actually use 95% of the time, plus how to build your own:

  1. useState — local component state
  2. useEffect — side effects and lifecycle
  3. useRef — DOM access and mutable values
  4. useMemo and useCallback — performance optimization
  5. useContext — shared state without prop drilling
  6. Custom hooks — packaging reusable logic

Every example is real code you'd write in a production app.

1. useState — Local Component State

The simplest hook. Use it for any value that should re-render the component when it changes:

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

The functional updater pattern — use this when the new state depends on the previous state. It's safer because React batches updates:

// Wrong — may use stale state
setCount(count + 1);
setCount(count + 1);  // still increments by 1, not 2

// Right — always uses the latest value
setCount(prev => prev + 1);
setCount(prev => prev + 1);  // increments by 2

For objects and arrays, always create a new reference — React uses reference equality to detect changes:

// Wrong — mutating the existing object
user.name = 'New name';
setUser(user);  // React won't re-render

// Right — new object
setUser({ ...user, name: 'New name' });

2. useEffect — Side Effects and Lifecycle

useEffect handles anything that touches the world outside React: API calls, subscriptions, timers, DOM manipulation. The dependency array controls when it runs:

import { useState, useEffect } from 'react';

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

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

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

    return () => { cancelled = true; };  // cleanup
  }, [userId]);  // re-run when userId changes

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

Three patterns to remember:

Dependency array Runs
[] Only once, on mount
[userId, filter] On mount + whenever any listed value changes
(omitted) After every render — almost always a mistake

The cleanup function (the return () => ...) runs before the next effect and on unmount. Use it to cancel requests, unsubscribe, or clear timers — anything that would leak otherwise.

3. useRef — DOM Access and Mutable Values

useRef has two distinct jobs. First, accessing DOM nodes directly:

import { useRef, useEffect } from 'react';

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

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

  return <input ref={inputRef} placeholder="Type here…" />;
}

Second, holding mutable values that don't trigger re-renders — useful for things like interval IDs, previous values, or render counters:

import { useRef, useState, useEffect } from 'react';

function Stopwatch() {
  const [seconds, setSeconds] = useState(0);
  const intervalRef = useRef(null);

  const start = () => {
    if (intervalRef.current) return;
    intervalRef.current = setInterval(() => {
      setSeconds(s => s + 1);
    }, 1000);
  };

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

  useEffect(() => () => clearInterval(intervalRef.current), []);

  return (
    <div>
      <p>{seconds}s</p>
      <button onClick={start}>Start</button>
      <button onClick={stop}>Stop</button>
    </div>
  );
}

Rule of thumb: if changing a value should re-render → useState. If it shouldn't → useRef.

4. useMemo and useCallback — Performance Optimization

These two hooks cache values between renders. Don't use them by default — only when you've measured a real performance problem.

useMemo caches the result of an expensive calculation:

import { useMemo, useState } from 'react';

function ProductList({ products }) {
  const [search, setSearch] = useState('');

  const filtered = useMemo(() => {
    console.log('Filtering…');
    return products
      .filter(p => p.name.toLowerCase().includes(search.toLowerCase()))
      .sort((a, b) => a.price - b.price);
  }, [products, search]);  // recompute only when these change

  return (
    <>
      <input value={search} onChange={e => setSearch(e.target.value)} />
      <ul>{filtered.map(p => <li key={p.id}>{p.name}</li>)}</ul>
    </>
  );
}

useCallback caches a function reference — useful when passing callbacks to memoized child components:

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

const ExpensiveButton = memo(({ onClick, label }) => {
  console.log(`Rendering ${label}`);
  return <button onClick={onClick}>{label}</button>;
});

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

  // Without useCallback, this function is recreated every render,
  // breaking memo() on ExpensiveButton.
  const increment = useCallback(() => {
    setCount(c => c + 1);
  }, []);

  return (
    <>
      <p>Count: {count}</p>
      <ExpensiveButton onClick={increment} label="Add" />
    </>
  );
}

When NOT to use these: tiny calculations, primitive values, components without memo. The bookkeeping cost can be higher than the calculation itself.

5. useContext — Shared State Without Prop Drilling

Passing props through 5 layers of components is painful. useContext lets any descendant grab a value directly:

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

const ThemeContext = createContext(null);

function ThemeProvider({ children }) {
  const [theme, setTheme] = useState('dark');
  const toggle = () => setTheme(t => t === 'dark' ? 'light' : 'dark');

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

// Custom hook wrapping useContext for a cleaner API
function useTheme() {
  const ctx = useContext(ThemeContext);
  if (!ctx) throw new Error('useTheme must be used inside ThemeProvider');
  return ctx;
}

function ThemedButton() {
  const { theme, toggle } = useTheme();
  return (
    <button
      onClick={toggle}
      style={{
        background: theme === 'dark' ? '#222' : '#eee',
        color: theme === 'dark' ? '#fff' : '#000',
      }}
    >
      Toggle ({theme})
    </button>
  );
}

export default function App() {
  return (
    <ThemeProvider>
      <ThemedButton />
    </ThemeProvider>
  );
}

Important: every consumer of a context re-renders whenever the context value changes. For global state that changes often (chat messages, live data), use a real state manager like Zustand or Redux. Use useContext for things that change rarely — theme, locale, current user.

6. Custom Hooks — Packaging Reusable Logic

When two components share the same stateful logic, extract a custom hook. The only rule: the function name must start with use.

Here's a useFetch hook you can drop into any project:

import { useState, useEffect } from 'react';

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

  useEffect(() => {
    const controller = new AbortController();
    setLoading(true);
    setError(null);

    fetch(url, { ...options, signal: controller.signal })
      .then(res => {
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        return res.json();
      })
      .then(setData)
      .catch(err => {
        if (err.name !== 'AbortError') setError(err);
      })
      .finally(() => setLoading(false));

    return () => controller.abort();
  }, [url]);

  return { data, error, loading };
}

// Using it
function Posts() {
  const { data, loading, error } = useFetch('/api/posts');

  if (loading) return <p>Loading…</p>;
  if (error) return <p>Error: {error.message}</p>;
  return <ul>{data.map(p => <li key={p.id}>{p.title}</li>)}</ul>;
}

Another useful one — useLocalStorage syncs a state value with localStorage:

import { useState, useEffect } from 'react';

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

  useEffect(() => {
    localStorage.setItem(key, JSON.stringify(value));
  }, [key, value]);

  return [value, setValue];
}

// Usage
function Settings() {
  const [name, setName] = useLocalStorage('username', '');
  return <input value={name} onChange={e => setName(e.target.value)} />;
}

Now any component in your app can persist state with one line.

The Rules of Hooks (Don't Break These)

Two rules, enforced by the eslint-plugin-react-hooks plugin:

1. Only call hooks at the top level — never inside loops,
   conditions, or nested functions.
2. Only call hooks from React functions — components or
   other custom hooks. Not from regular functions.

These exist because React tracks hooks by call order. Skip a hook on one render and the next one gets the wrong state.

Hook Cheat Sheet

Hook Use it for
useState Any value that should trigger a re-render
useEffect API calls, subscriptions, timers, DOM side effects
useRef DOM access, mutable values that shouldn't re-render
useMemo Caching expensive calculations
useCallback Stable function references for memoized children
useContext Sharing rarely-changing state across the tree
Custom hooks Reusing stateful logic between components

Final Thought

You don't need to memorize every hook React ships with. Master these six and you can build 95% of any React app. The other hooks — useReducer, useLayoutEffect, useImperativeHandle, useTransition — are tools for specific problems you'll recognize when you hit them.

The best way to internalize hooks isn't reading more articles. Pick one of the examples above, copy it into a fresh React project, and break it on purpose. Move a useState inside an if and watch what happens. Skip a dependency in useEffect and watch the infinite loop. That's how the rules stop being rules and start being intuition.

Advertisement (not configured)

Written by

Raretechsol

Software company from Pakistan, specializing in Python and JavaScript. Passionate about automation, AI, and building practical web applications.