Skip to main content

Command Palette

Search for a command to run...

React Hooks Masterclass: useState, useEffect, and Custom Hooks Explained

Updated
17 min readView as Markdown
React Hooks Masterclass: useState, useEffect, and Custom Hooks Explained

React Hooks Masterclass: useState, useEffect, and Custom Hooks Explained

TL;DR: React Hooks replaced class components by giving functional components the ability to manage state, run side effects, and share logic. This article builds a complete mental model — from how React remembers values between renders, to writing production-quality custom hooks — with runnable code every step of the way.

Introduction

Before Hooks, writing a React component that fetched data on mount, subscribed to a browser event, and cleaned up after itself required a class component with componentDidMount, componentDidUpdate, and componentWillUnmount — three separate places to express what is fundamentally one idea. Logic that belonged together was scattered. Logic that didn't belong together was crammed into the same lifecycle method.

Hooks changed that. Introduced in React 16.8, they let you use state and other React features from plain functions. But Hooks aren't just a syntax preference. They are a different way of thinking about components — one that centers on what your component is doing rather than when the framework calls it.

This article is for developers who know React basics but want to understand Hooks deeply enough to use them confidently in production. We'll build every concept from first principles, starting with the most fundamental question:

How does React remember information between renders?


Background: How React Remembers Between Renders

A React functional component is just a function. Every time React needs to display an updated UI, it calls that function again. Variables declared inside the function are recreated from scratch on each call. So how does useState remember its value?

React maintains a per-component, per-render fiber node — an internal data structure that lives outside the component function itself. When you call useState, React reads from a slot in that fiber's memoized state array, keyed by the order in which hooks are called. This is why the Rules of Hooks exist: call hooks unconditionally, and always in the same order, so React can reliably match each hook call to its internal slot.

flowchart TD
  A["Component Function Called"] --> B["Hook 1: useState — reads slot 0"]
  B --> C["Hook 2: useState — reads slot 1"]
  C --> D["Hook 3: useEffect — reads slot 2"]
  D --> E["Returns JSX"]
  E --> F["React reconciles & commits to DOM"]
  F --> G{"State update triggered?"}
  G -- Yes --> A
  G -- No --> H["Idle — waiting"]

This slot-based model has a concrete implication: you cannot call hooks inside if statements or loops. If you did, the slot positions would shift between renders and React would read wrong values.

Diagram showing React's slot-based hook memory model: each render reads from an ordered array of state slots inside the fiber node


Understanding useState

What It Is

useState is a hook that lets a component own a piece of data that persists across renders and, when changed, triggers a re-render to display the updated UI.

The API is intentionally minimal:

const [value, setValue] = useState(initialValue);

value is the current state. setValue is a stable function that schedules an update. When you call setValue, React queues a re-render, the component function runs again, and the new value reflects your update.

A Real Counter

Let's start with a counter, but build it in a way that shows the mechanics:

import { useState } from 'react';

export function ArticleVoteCounter({ articleId }: { articleId: string }) {
  const [upvotes, setUpvotes] = useState(0);
  const [downvotes, setDownvotes] = useState(0);

  const score = upvotes - downvotes;

  return (
    <div className="vote-counter">
      <h3>Article Score: {score}</h3>
      <button onClick={() => setUpvotes(prev => prev + 1)}>👍 {upvotes}</button>
      <button onClick={() => setDownvotes(prev => prev + 1)}>👎 {downvotes}</button>
    </div>
  );
}

A few things worth noting here:

  1. Two independent state variables: upvotes and downvotes each occupy their own slot. They don't need to be grouped into one object unless their updates are always coordinated.
  2. Functional update form (prev => prev + 1): When the new state depends on the old state, always use the functional form. This avoids stale closures in async scenarios.
  3. Derived values don't need their own state: score is computed from existing state. Storing it separately would create a source of truth problem.

State with Objects

When state is an object, remember that setState replaces the entire value — it doesn't merge like this.setState in class components:

import { useState } from 'react';

interface UserProfile {
  name: string;
  bio: string;
  avatarUrl: string;
}

export function ProfileEditor() {
  const [profile, setProfile] = useState<UserProfile>({
    name: 'Ada Lovelace',
    bio: 'Mathematician and writer.',
    avatarUrl: '/avatars/ada.jpg',
  });

  function updateBio(newBio: string) {
    // Spread existing fields — otherwise name and avatarUrl are lost
    setProfile(prev => ({ ...prev, bio: newBio }));
  }

  return (
    <div>
      <img src={profile.avatarUrl} alt={profile.name} />
      <h2>{profile.name}</h2>
      <textarea
        value={profile.bio}
        onChange={e => updateBio(e.target.value)}
      />
    </div>
  );
}

Warning: Forgetting to spread ...prev when updating object state is one of the most common bugs in React. Always ensure you're carrying forward the fields you're not explicitly changing.


Understanding React Re-renders

Every call to a state setter causes React to schedule a re-render of that component and its descendants. Understanding what triggers a re-render — and what doesn't — is essential.

Trigger Re-render? Notes
setState(newValue) ✅ Yes Even if newValue === currentValue for objects/arrays — use same reference to bail out
setState(sameValue) (primitive) ❌ No React bails out if the new primitive value is the same via Object.is
Parent re-renders ✅ Yes Unless wrapped in React.memo
useContext value change ✅ Yes All consumers re-render
useRef value change ❌ No Refs are mutable but don't trigger re-renders

This table reveals an important design decision: don't put something in state if a change to it shouldn't update the UI. For values you need to persist across renders without causing re-renders — like a timer ID, a WebSocket instance, or a previous value — use useRef instead.

import { useRef, useState } from 'react';

export function Stopwatch() {
  const [elapsedMs, setElapsedMs] = useState(0);
  const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);

  function start() {
    if (intervalRef.current !== null) return;
    const startTime = Date.now() - elapsedMs;
    intervalRef.current = setInterval(() => {
      setElapsedMs(Date.now() - startTime);
    }, 10);
  }

  function stop() {
    if (intervalRef.current === null) return;
    clearInterval(intervalRef.current);
    intervalRef.current = null;
  }

  return (
    <div>
      <p>{(elapsedMs / 1000).toFixed(2)}s</p>
      <button onClick={start}>Start</button>
      <button onClick={stop}>Stop</button>
    </div>
  );
}

The interval ID is stored in a ref — mutating it doesn't cause a render, which is exactly what we want. Only elapsedMs (which drives the display) lives in state.


Understanding useEffect

The Right Mental Model

Most developers initially think of useEffect as lifecycle methods ported to functions: "run on mount", "run on update", "run on unmount". This mental model leads to bugs.

The better model: useEffect synchronizes your component with something outside React — a server, a browser API, a third-party library. The question to ask isn't when does this run? but what external system does this synchronize with, and what does it need to stay in sync?

Side Effects in Practice

Side effects are anything that reaches outside the React render cycle: fetching data, subscribing to events, setting document titles, starting timers.

import { useEffect, useState } from 'react';

interface Post {
  id: number;
  title: string;
  body: string;
}

export function PostDetail({ postId }: { postId: number }) {
  const [post, setPost] = useState<Post | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

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

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

      try {
        const res = await fetch(`https://jsonplaceholder.typicode.com/posts/${postId}`);
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        const data: Post = await res.json();
        if (!cancelled) {
          setPost(data);
        }
      } catch (err) {
        if (!cancelled) {
          setError(err instanceof Error ? err.message : 'Unknown error');
        }
      } finally {
        if (!cancelled) setLoading(false);
      }
    }

    fetchPost();

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

  if (loading) return <p>Loading post {postId}…</p>;
  if (error) return <p>Error: {error}</p>;
  if (!post) return null;

  return (
    <article>
      <h2>{post.title}</h2>
      <p>{post.body}</p>
    </article>
  );
}

This example demonstrates several critical patterns:

  • cancelled flag: When postId changes quickly (e.g., user clicks through posts), multiple fetches can be in flight. The flag prevents a slow response from an earlier request overwriting a later one. This is called a race condition guard.
  • Cleanup function: The function returned from useEffect runs before the next effect fires and when the component unmounts. This is where you cancel requests, clear timers, and unsubscribe from events.
  • Dependency array [postId]: The effect re-runs whenever postId changes.

Dependency Arrays Explained

The dependency array is the mechanism React uses to know when to re-synchronize. Think of it as declaring: "This effect depends on these values. Re-run it when any of them change."

// Runs once after mount (stable empty dependency set)
useEffect(() => {
  document.title = 'My App';
}, []);

// Runs after every render (no dependency array at all)
useEffect(() => {
  console.log('Component rendered');
});

// Runs when `searchQuery` changes
useEffect(() => {
  const controller = new AbortController();
  fetch(`/api/search?q=${searchQuery}`, { signal: controller.signal })
    .then(r => r.json())
    .then(setResults)
    .catch(() => {});
  return () => controller.signal.abort();
}, [searchQuery]);

Warning: The most common mistake is leaving dependencies out of the array. If your effect uses a value from the component (state, props, or a function), it must be in the dependency array — or you'll read stale values. Use the eslint-plugin-react-hooks exhaustive-deps rule to catch this automatically.

The table below summarizes dependency array behaviors:

Dependency Array When Effect Runs
Omitted (no array) After every render
Empty [] Once, after initial mount
[a] After mount, and whenever a changes
[a, b] After mount, and whenever a or b changes

Event Subscriptions and Cleanup

import { useEffect, useState } from 'react';

export function MouseTracker() {
  const [position, setPosition] = useState({ x: 0, y: 0 });

  useEffect(() => {
    function handleMouseMove(event: MouseEvent) {
      setPosition({ x: event.clientX, y: event.clientY });
    }

    window.addEventListener('mousemove', handleMouseMove);

    // Cleanup: remove listener when component unmounts or before re-running
    return () => {
      window.removeEventListener('mousemove', handleMouseMove);
    };
  }, []); // Only subscribe once

  return (
    <p>
      Mouse: ({position.x}, {position.y})
    </p>
  );
}

Without the cleanup, every time this component mounts it adds another listener. If it renders in a modal that opens and closes repeatedly, you'll accumulate hundreds of listeners — a classic memory leak.

Diagram illustrating the useEffect cleanup lifecycle: effect runs, cleanup fires before next effect and on unmount


Custom Hooks: Reusable Stateful Logic

What a Custom Hook Is

A custom hook is a function whose name starts with use and that calls other hooks internally. That's it. No special API, no registration. The use prefix is a signal to React's linter rules that this function follows hook rules — and a signal to your team that this function owns stateful logic.

The power of custom hooks is extracting behavior without extracting markup. Unlike a component, a custom hook doesn't render anything. It returns data and functions that a component can use however it likes.

Building useFetch

The data-fetching pattern from the PostDetail example above appears in every application. Let's extract it:

import { useEffect, useReducer } from 'react';

interface FetchState<T> {
  data: T | null;
  loading: boolean;
  error: string | null;
}

type FetchAction<T> =
  | { type: 'LOADING' }
  | { type: 'SUCCESS'; payload: T }
  | { type: 'ERROR'; payload: string };

function fetchReducer<T>(state: FetchState<T>, action: FetchAction<T>): FetchState<T> {
  switch (action.type) {
    case 'LOADING': return { data: null, loading: true, error: null };
    case 'SUCCESS': return { data: action.payload, loading: false, error: null };
    case 'ERROR': return { data: null, loading: false, error: action.payload };
  }
}

export function useFetch<T>(url: string): FetchState<T> {
  const [state, dispatch] = useReducer(fetchReducer<T>, {
    data: null,
    loading: true,
    error: null,
  });

  useEffect(() => {
    if (!url) return;

    let cancelled = false;
    const controller = new AbortController();

    dispatch({ type: 'LOADING' });

    fetch(url, { signal: controller.signal })
      .then(res => {
        if (!res.ok) throw new Error(`HTTP \({res.status}: \){res.statusText}`);
        return res.json() as Promise<T>;
      })
      .then(data => {
        if (!cancelled) dispatch({ type: 'SUCCESS', payload: data });
      })
      .catch(err => {
        if (!cancelled && err.name !== 'AbortError') {
          dispatch({ type: 'ERROR', payload: err.message });
        }
      });

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

  return state;
}

Now any component that needs remote data can consume this:

interface User {
  id: number;
  name: string;
  email: string;
}

export function UserCard({ userId }: { userId: number }) {
  const { data: user, loading, error } = useFetch<User>(
    `https://jsonplaceholder.typicode.com/users/${userId}`
  );

  if (loading) return <div className="skeleton" />;
  if (error) return <p className="error-text">{error}</p>;
  if (!user) return null;

  return (
    <div className="user-card">
      <h3>{user.name}</h3>
      <p>{user.email}</p>
    </div>
  );
}

The component is now only responsible for rendering. All the fetch mechanics live in useFetch.

Building useLocalStorage

import { useState, useEffect } from 'react';

export function useLocalStorage<T>(key: string, initialValue: T) {
  const [storedValue, setStoredValue] = useState<T>(() => {
    // Lazy initializer: only runs on first render
    try {
      const item = window.localStorage.getItem(key);
      return item ? (JSON.parse(item) as T) : initialValue;
    } catch {
      return initialValue;
    }
  });

  function setValue(value: T | ((prev: T) => T)) {
    try {
      const valueToStore = value instanceof Function ? value(storedValue) : value;
      setStoredValue(valueToStore);
      window.localStorage.setItem(key, JSON.stringify(valueToStore));
    } catch (err) {
      console.warn(`useLocalStorage: could not write key "${key}"`, err);
    }
  }

  return [storedValue, setValue] as const;
}

Usage is identical to useState, but the value persists across page refreshes:

export function ThemeSwitcher() {
  const [theme, setTheme] = useLocalStorage<'light' | 'dark'>('app-theme', 'light');

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

Building useWindowSize

Device and viewport utilities are another perfect use case:

import { useState, useEffect } from 'react';

interface WindowSize {
  width: number;
  height: number;
}

export function useWindowSize(): WindowSize {
  const [size, setSize] = useState<WindowSize>({
    width: window.innerWidth,
    height: window.innerHeight,
  });

  useEffect(() => {
    function handleResize() {
      setSize({ width: window.innerWidth, height: window.innerHeight });
    }

    window.addEventListener('resize', handleResize);
    return () => window.removeEventListener('resize', handleResize);
  }, []);

  return size;
}

// Usage
export function ResponsiveLayout() {
  const { width } = useWindowSize();
  const isMobile = width < 768;

  return (
    <div className={isMobile ? 'layout-mobile' : 'layout-desktop'}>
      {isMobile ? <MobileNav /> : <DesktopNav />}
    </div>
  );
}

Architecture diagram showing how a custom hook extracts shared stateful logic and is consumed by multiple independent components


Trade-offs & Pitfalls

Pattern Risk Mitigation
Missing deps in useEffect Stale closures reading old values Use eslint-plugin-react-hooks exhaustive-deps
Object/array in deps array New reference every render → infinite loop Memoize with useMemo/useCallback, or restructure
Too much state in one object Complex merging, hard to read Split into multiple useState calls
Effects for derived data Unnecessary renders, complexity Compute directly during render instead
No cleanup in subscriptions Memory leaks, stale listeners Always return cleanup function
Custom hooks that do too much Hard to test and reuse Single-responsibility principle: one behavior per hook

The Infinite Loop Trap

The most dangerous pitfall is accidentally creating an infinite loop:

// ❌ BROKEN: `options` is a new object on every render
useEffect(() => {
  fetchData(options);
}, [options]); // options changes every render → infinite loop

const options = { method: 'GET', headers: { Accept: 'application/json' } };

// ✅ FIXED: Move the object outside the component or memoize it
const options = useMemo(() => ({
  method: 'GET',
  headers: { Accept: 'application/json' },
}), []); // stable reference now

Tip: If an effect runs more times than you expect, log the dependency values with console.log on each render to identify which one is changing reference on every call.


Hooks Rules and Best Practices

The Rules of Hooks are enforced by the React linter for a reason — break them and you'll get silent, intermittent bugs that are extremely hard to trace:

  1. Only call hooks at the top level. Never inside loops, conditions, or nested functions.
  2. Only call hooks from React functions. Either a component function or another custom hook.

Beyond the rules, here are production-tested practices:

  • Name custom hooks descriptively. useFetch is fine for demos; useUserProfile(userId) is what your codebase needs at scale.
  • Keep effects focused. One useEffect per concern. Don't combine data fetching and event subscription in one block — split them so cleanup is clear.
  • Prefer useReducer over multiple useState calls when state values change together. It makes state transitions explicit and testable.
  • Avoid storing computed values in state. If a value can be derived from existing state or props synchronously during render, compute it there. Every extra state variable is another source of truth that can get out of sync.
  • Test custom hooks with @testing-library/react-hooks (or the renderHook utility from React Testing Library v13+). Since hooks are plain functions, they're straightforward to test in isolation.
import { renderHook, act } from '@testing-library/react';
import { useLocalStorage } from './useLocalStorage';

beforeEach(() => localStorage.clear());

test('persists value to localStorage', () => {
  const { result } = renderHook(() => useLocalStorage('color', 'blue'));

  act(() => {
    result.current[1]('red');
  });

  expect(result.current[0]).toBe('red');
  expect(localStorage.getItem('color')).toBe('"red"');
});

Thinking in Hooks: The Mental Model Summary

Hooks push you toward a specific way of thinking about components:

  1. State is a snapshot. Each render has its own snapshot of state. Closures capture the snapshot from when they were created — this is why stale closures are a problem, and why functional updates (prev => prev + 1) exist.

  2. Effects synchronize, not execute. Ask: "What external system does this effect keep in sync?" The answer tells you what goes in the dependency array, what the cleanup should do, and whether the effect belongs in a custom hook.

  3. Custom hooks are the unit of reuse. Higher-order components and render props solved logic reuse before Hooks, but both added wrapper components that cluttered the tree. Custom hooks share logic with zero extra nodes in the component tree.

  4. Derive before you store. Before adding a new useState, ask whether the value can be computed from existing state on every render. Derived state is one less thing to synchronize.

  5. Think in dependency graphs, not lifecycles. useEffect doesn't care whether your component is mounting or updating. It only cares whether its dependencies have changed. That shift in thinking eliminates entire categories of bugs.


Conclusion

React Hooks didn't just change syntax — they changed the architecture of React applications. Functional components with hooks are more predictable, easier to test, and far easier to share logic across than class-based equivalents.

The three concepts covered here form the entire foundation:

  • useState gives your component memory that persists across renders and drives UI updates.
  • useEffect synchronizes your component with external systems, with an explicit model for cleanup.
  • Custom hooks extract that logic into reusable, testable, single-responsibility units.

Your immediate next step: look at your existing components and find one useEffect block that's more than 10 lines long. Extract it into a custom hook with a name that describes what it does. You'll find the component becomes dramatically easier to read, and the hook becomes trivially easy to test.


Further Reading