# 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.

```mermaid
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](https://cdn.hashnode.com/res/hashnode/image/upload/v1786274851634/279295cb-8dc0-4635-9b46-1f21c9e70a6b.png)

---

## 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:

```ts
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:

```tsx
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:

```tsx
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.

```tsx
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.

```tsx
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."

```tsx
// 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

```tsx
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](https://cdn.hashnode.com/res/hashnode/image/upload/v1786274900489/02f068fa-86ea-4d27-af38-91c6a9208735.png)

---

## 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:

```tsx
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:

```tsx
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`

```tsx
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:

```tsx
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:

```tsx
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](https://cdn.hashnode.com/res/hashnode/image/upload/v1786274949220/74619126-6445-4a1f-9512-cdec9e3319dd.png)

---

## 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:

```tsx
// ❌ 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.

```tsx
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

- [React Docs: Built-in React Hooks](https://react.dev/reference/react) — The official reference for every hook, including newer additions like `useTransition` and `useDeferredValue`.
- [React Docs: Escape Hatches](https://react.dev/learn/escape-hatches) — The official guide to `useEffect`, effects, and how to think about synchronization correctly.
- [eslint-plugin-react-hooks](https://www.npmjs.com/package/eslint-plugin-react-hooks) — Install this in every React project. The `exhaustive-deps` rule prevents an entire class of stale-closure bugs automatically.
- [React Testing Library: renderHook](https://testing-library.com/docs/react-testing-library/api/#renderhook) — The right way to test custom hooks in isolation.
- [Dan Abramov: A Complete Guide to useEffect](https://overreacted.io/a-complete-guide-to-useeffect/) — The single most thorough explanation of the useEffect mental model ever written.
