TL;DR: React hooks are special functions that give components superpowers — the ability to remember data, respond to changes, reference DOM elements, and optimize performance. The Big 5 are useState (store data), useEffect (react to changes), useRef (reference DOM or store non-UI values), useMemo (cache expensive calculations), and useCallback (cache functions). You need to understand all five because AI uses them constantly — and without understanding them you cannot debug the code you receive.

Why AI Coders Need This

Chuck spent 20 years building houses. He knows what every tool on his belt does before he picks it up — because grabbing the wrong tool on a job site costs time, money, and sometimes safety. He started vibe coding two years ago, and the thing that tripped him up most was not JavaScript syntax. It was hooks.

AI tools drop hooks into every React component like they are air. You paste a prompt, you get back a working UI, and somewhere in the middle is:

const [isOpen, setIsOpen] = useState(false);

useEffect(() => {
  fetchProjectData(projectId);
}, [projectId]);

It works. For now. Then your client changes a requirement, something breaks, and you have to modify that code. If you do not understand what useState and useEffect are doing, you cannot change them — you can only hope that re-prompting AI produces something that works again. That is not building. That is gambling.

Hooks are also the number one source of subtle React bugs. AI generates them correctly most of the time, but the failure modes — infinite render loops, stale data, missing dependency arrays — are common enough that every vibe coder needs to recognize them.

This guide builds you that foundation. We will cover every major hook in plain English, show you exactly what AI generates and why, and give you the vocabulary to fix what AI gets wrong. If you need a refresher on React itself first, read What Is React? before continuing.

Real-World Scenario

You are building a job tracking app for a small construction company. You open Cursor and type:

Prompt I Would Type

Build a job tracker for a small construction company. Features:
- List all jobs fetched from a Supabase table called 'jobs'
- Show job name, status (active/completed), and crew lead
- Click a job to expand and see full details
- Add a new job with a form
- Search jobs by name as you type
- Mark a job complete

Use React with hooks. Explain each hook you use with a comment.

Within 30 seconds, Cursor generates about 120 lines of React. In that code you will find useState called five times, useEffect twice, and maybe useCallback on a search handler. Every single one of those hooks is doing a specific job. Let's decode them.

The Big 5 Hooks Explained

1. useState — Store Data That Changes

Plain English: useState is how a component remembers something. Without it, every time React re-renders your component, all your variables reset to their initial values and the UI forgets everything the user did. useState is the component's memory.

The syntax always looks like this:

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

That square-bracket syntax is called array destructuring. useState returns two items in an array — the current value and a function to update it. You name them whatever makes sense for your data.

Here is a realistic example from the job tracker:

import { useState } from 'react';

function JobTracker() {
  // jobs: the current list of jobs. setJobs: the function to update it.
  // Initial value is an empty array — we haven't loaded anything yet.
  const [jobs, setJobs] = useState([]);

  // loading: true while we're fetching, false when done.
  const [loading, setLoading] = useState(true);

  // searchQuery: what the user has typed in the search box.
  const [searchQuery, setSearchQuery] = useState('');

  // selectedJob: the job the user clicked on, or null if none selected.
  const [selectedJob, setSelectedJob] = useState(null);

  // newJobName: the text in the "add job" form input.
  const [newJobName, setNewJobName] = useState('');

  // ...rest of component
}

Five separate pieces of state. Each one tracks one thing. When any of them change, React re-renders the component and the UI updates to reflect the new reality.

The #1 useState Mistake

You cannot modify state by doing jobs.push(newJob). That mutates the existing array and React does not detect the change — your UI will not update. You must always create a new value: setJobs([...jobs, newJob]). Same with objects: setSelectedJob({ ...selectedJob, status: 'completed' }). Always replace, never mutate.

When to use useState:

  • Any data the user sees that can change (a list, a form value, a toggle, a count).
  • Loading and error states (isLoading, error).
  • UI state like "is this modal open?" or "which tab is selected?".

When not to use useState:

  • Data that does not affect what the user sees (use useRef instead).
  • Data that can be calculated from other state (just calculate it inline — no hook needed).
  • Data shared across many components (look at React Context or a state manager).

2. useEffect — React to Changes

Plain English: useEffect lets you run code after the component renders — and optionally re-run it when specific values change. It is how you fetch data, set up subscriptions, start timers, and talk to things outside React's world.

The name "effect" refers to side effects — things a component does beyond just returning UI. Fetching from Supabase, updating the browser tab title, subscribing to a WebSocket — all side effects.

The structure:

useEffect(() => {
  // Your side effect code goes here
  // This runs AFTER the component renders

  return () => {
    // Optional cleanup — runs before the next effect or when component unmounts
  };
}, [dependency1, dependency2]); // dependency array — controls when this runs

The dependency array is the most important part to understand:

Dependency Array When the Effect Runs Common Use
useEffect(() => {...}) Every render (dangerous) Rarely intentional
useEffect(() => {...}, []) Once, on mount Initial data fetch
useEffect(() => {...}, [id]) When id changes Fetch based on selected item

From the job tracker, fetching jobs when the component first loads:

import { useState, useEffect } from 'react';
import { supabase } from './supabaseClient';

function JobTracker() {
  const [jobs, setJobs] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  // This runs once when JobTracker first appears on screen.
  // The empty [] means: "run this effect once and never again."
  useEffect(() => {
    async function loadJobs() {
      try {
        const { data, error } = await supabase
          .from('jobs')
          .select('*')
          .order('created_at', { ascending: false });

        if (error) throw error;
        setJobs(data);
      } catch (err) {
        setError(err.message);
      } finally {
        setLoading(false); // always stop showing spinner, even if it failed
      }
    }

    loadJobs();
  }, []); // <-- empty array: only run on mount

  if (loading) return <p>Loading jobs...</p>;
  if (error) return <p>Error: {error}</p>;

  return (
    <ul>
      {jobs.map(job => (
        <li key={job.id}>{job.name} — {job.status}</li>
      ))}
    </ul>
  );
}

Now imagine the user can switch between "active" and "completed" jobs, and you want to re-fetch when that filter changes:

const [statusFilter, setStatusFilter] = useState('active');

// Re-fetches every time statusFilter changes
useEffect(() => {
  async function loadJobs() {
    const { data } = await supabase
      .from('jobs')
      .select('*')
      .eq('status', statusFilter);
    setJobs(data ?? []);
  }

  loadJobs();
}, [statusFilter]); // <-- re-run when statusFilter changes

The #1 useEffect Mistake: No Dependency Array

If you write useEffect(() => { fetchData(); }) with no second argument at all, the effect runs after every single render. If fetchData calls setJobs, that triggers a re-render, which triggers the effect again — infinite loop. Always include a dependency array.

The cleanup function — when you need it:

useEffect(() => {
  // Start a polling interval to check for new jobs every 30 seconds
  const intervalId = setInterval(() => {
    fetchJobs();
  }, 30000);

  // Cleanup: cancel the interval when the component unmounts
  // Without this, the interval keeps running even after the component is gone
  return () => clearInterval(intervalId);
}, []);

The cleanup function prevents memory leaks. If a component that set up a timer or a WebSocket subscription gets removed from the screen, the cleanup runs automatically and cancels those resources. AI often omits cleanup. Always check for it when you see subscriptions, timers, or event listeners inside a useEffect.

For deeper async patterns inside effects, read What Is Async/Await? — you will see that pattern constantly in AI-generated React.

3. useRef — Reference DOM Elements or Store Non-UI Values

Plain English: useRef stores a value that persists between renders but does not cause a re-render when it changes. It is like a sticky note you can write on without React noticing.

It has two main uses:

Use 1: Point directly at a DOM element.

import { useRef } from 'react';

function SearchBar() {
  // inputRef.current will point to the actual <input> DOM node
  const inputRef = useRef(null);

  function handleAddJobClick() {
    // Focus the search box programmatically when the user clicks "Add Job"
    inputRef.current.focus();
  }

  return (
    <div>
      {/* Attach the ref to the DOM element with the ref prop */}
      <input ref={inputRef} type="text" placeholder="Search jobs..." />
      <button onClick={handleAddJobClick}>Go to Search</button>
    </div>
  );
}

You cannot do document.getElementById('search') cleanly in React — refs are the right way to touch the actual DOM element. Common uses: auto-focusing an input, reading a scroll position, integrating with a third-party library that needs a DOM node.

Use 2: Store a value without triggering a re-render.

function JobTracker() {
  // Store the interval ID so we can cancel it later
  // We don't want React to re-render when this changes — it's not UI data
  const intervalRef = useRef(null);

  useEffect(() => {
    intervalRef.current = setInterval(fetchJobs, 30000);

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

  // ...
}

The key distinction: if changing a value should update what the user sees, use useState. If the value is purely internal bookkeeping, use useRef.

4. useMemo — Cache an Expensive Calculation

Plain English: useMemo remembers the result of a calculation and only recalculates it when the inputs change. It is for performance — preventing React from redoing work it already did.

import { useMemo } from 'react';

function JobTracker({ jobs, searchQuery }) {
  // Without useMemo: this filtering runs on EVERY render, even if jobs and
  // searchQuery haven't changed. For 10 jobs that's fine. For 10,000 it's slow.

  // With useMemo: the filtered list is recalculated only when jobs or
  // searchQuery actually change.
  const filteredJobs = useMemo(() => {
    if (!searchQuery.trim()) return jobs;
    const query = searchQuery.toLowerCase();
    return jobs.filter(job =>
      job.name.toLowerCase().includes(query) ||
      job.crewLead.toLowerCase().includes(query)
    );
  }, [jobs, searchQuery]); // only recalculate when these change

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

When to Actually Use useMemo

AI adds useMemo everywhere as a precaution. In most real apps it is premature optimization. Only add it when: you have measured a performance problem, the calculation involves significant work (sorting thousands of items, complex transformations), and the component re-renders frequently. For a search filter on 50 jobs, plain JavaScript is fast enough — no memo needed.

5. useCallback — Cache a Function

Plain English: useCallback is like useMemo but for functions instead of values. It returns the same function reference between renders so that child components that receive it as a prop do not unnecessarily re-render.

import { useCallback } from 'react';

function JobTracker({ jobs, setJobs }) {
  // Without useCallback: a NEW handleComplete function is created on every render.
  // If JobRow receives this as a prop, it re-renders on every parent render
  // even when the function logic hasn't changed.

  // With useCallback: the same function reference is reused as long as
  // setJobs doesn't change (it never does — React's setters are stable).
  const handleComplete = useCallback((jobId) => {
    setJobs(prev => prev.map(job =>
      job.id === jobId ? { ...job, status: 'completed' } : job
    ));
  }, [setJobs]);

  return (
    <div>
      {jobs.map(job => (
        <JobRow
          key={job.id}
          job={job}
          onComplete={handleComplete} {/* stable reference means JobRow won't re-render unnecessarily */}
        />
      ))}
    </div>
  );
}

useCallback is almost always paired with React.memo — a wrapper that tells React "only re-render this component if its props actually changed." Without React.memo on the child, useCallback on the parent does nothing useful.

The same advice as useMemo applies: AI adds this everywhere. Most small-to-medium React apps do not need it. Add it when you have a real performance problem to solve, not as a default.

Rules of Hooks

React enforces two hard rules about hooks. Break them and you get errors that can be genuinely confusing to debug.

Rule 1: Only call hooks at the top level of your component.

Never call a hook inside an if statement, a loop, or a nested function. React relies on hooks being called in the exact same order every render. Conditionals or loops break that order.

// WRONG — hook is inside an if statement
function JobTracker({ isAdmin }) {
  if (isAdmin) {
    const [adminData, setAdminData] = useState(null); // ERROR
  }
}

// RIGHT — hook is at the top level, condition is inside the handler
function JobTracker({ isAdmin }) {
  const [adminData, setAdminData] = useState(null);

  useEffect(() => {
    if (isAdmin) {
      fetchAdminData().then(setAdminData);
    }
  }, [isAdmin]);
}

Rule 2: Only call hooks inside React function components (or custom hooks).

Hooks cannot be called in regular JavaScript functions, class components, or outside of React entirely. They are React-specific. If you see a function that starts with use — like useLocalStorage or useDebounce — it is a custom hook and follows the same rules.

How to Remember the Rules

Think of hooks as instructions on a checklist. React runs through the checklist in the same order every time. If you skip a line (by putting a hook in a conditional that sometimes skips) or add extra lines (by putting hooks in loops), the checklist gets out of sync and React loses track of which data belongs to which hook. Always call every hook, in the same order, every render.

What AI Gets Wrong With Hooks

AI generates hooks correctly most of the time. But there are five failure patterns that show up repeatedly.

Missing or wrong dependency array on useEffect

This is the most common hook bug in AI-generated code. AI sometimes omits the dependency array entirely (infinite loop risk) or fills it wrong (effect doesn't re-run when it should).

// WRONG — runs on every render, likely causes infinite loop
useEffect(() => {
  fetchJobs();
});

// WRONG — runs once but never re-fetches when userId changes
useEffect(() => {
  fetchJobsByUser(userId);
}, []); // userId is missing from the dependency array

// RIGHT — re-fetches whenever userId changes
useEffect(() => {
  fetchJobsByUser(userId);
}, [userId]);

Calling setState on an unmounted component

If a user navigates away while a fetch is in progress, the component unmounts. But the async fetch finishes and calls setJobs on a component that no longer exists. This causes a React warning and potential memory leaks.

useEffect(() => {
  let cancelled = false; // track whether this effect has been cleaned up

  async function load() {
    const data = await fetchJobs();
    if (!cancelled) setJobs(data); // only update state if still mounted
  }

  load();

  return () => { cancelled = true; }; // cleanup: mark as cancelled on unmount
}, []);

Using stale state inside useEffect

If you reference a state variable inside useEffect but do not include it in the dependency array, the effect captures the initial value and never sees updates. This is called a stale closure.

// WRONG — count inside the effect is always 0 (stale)
const [count, setCount] = useState(0);
useEffect(() => {
  const id = setInterval(() => {
    console.log(count); // always prints 0 — stale closure
  }, 1000);
  return () => clearInterval(id);
}, []); // count is missing

// RIGHT — use the functional form of setState to always get the latest value
useEffect(() => {
  const id = setInterval(() => {
    setCount(prev => prev + 1); // prev is always the latest value
  }, 1000);
  return () => clearInterval(id);
}, []);

Overusing useMemo and useCallback

AI adds these hooks preemptively on almost every function and derived value. In reality they add cognitive overhead and can actually slow down very simple components (the memoization itself has a cost). If you are debugging AI-generated code that feels over-engineered, try removing useMemo and useCallback wrapping. If nothing breaks, the optimization was premature.

Updating state during render

Calling a state setter directly during the component render (not in an event handler or effect) triggers an immediate re-render and causes an infinite loop.

// WRONG — setCount is called during render, infinite loop
function Counter() {
  const [count, setCount] = useState(0);
  setCount(count + 1); // DO NOT DO THIS
  return <p>{count}</p>;
}

// RIGHT — update state in response to an event
function Counter() {
  const [count, setCount] = useState(0);
  return <button onClick={() => setCount(count + 1)}>{count}</button>;
}

How to Debug Hook Issues

Hook bugs have distinct signatures. Once you can recognize them, you can fix them fast.

Symptom: Infinite re-render / browser freezes

Cause: A state update is triggering a re-render that triggers another state update. Most common pattern: useEffect with no dependency array calls a function that calls setState.

Fix: Add a dependency array to the useEffect. If you truly need it to run only once, use []. Check that nothing inside the effect creates a new render cycle.

Symptom: UI doesn't update when data changes

Cause: State was mutated directly instead of replaced. array.push(), object.key = value — React does not see these as changes.

Fix: Always return a new array or object. [...old, newItem] for arrays. { ...old, key: newValue } for objects.

Symptom: Data is one step behind

Cause: Reading state immediately after calling a setter. State updates in React are asynchronous — the new value is not available until the next render.

// This will log the OLD value, not the new one
setCount(count + 1);
console.log(count); // still the old count

// If you need to work with the new value, use the functional form
setCount(prev => {
  const newCount = prev + 1;
  console.log(newCount); // correct new value
  return newCount;
});

Symptom: "Rendered more hooks than previous render" error

Cause: A hook is inside a conditional — sometimes it runs, sometimes it does not. React enforces that hooks are always called in the same order.

Fix: Move all hook calls to the top of your component, unconditionally. Put the condition inside the hook's logic, not around the hook call.

Using React DevTools

Install the React DevTools browser extension. Click on any component in the Components panel and you can see all its current state and the values stored in each useRef in real time. This is the fastest way to verify whether state is what you think it is. Without it, you are guessing.

Debugging Prompt That Works

When a hook bug stumps you, paste the full component into your AI tool with this framing: "This component has a bug where [describe exact symptom]. Walk me through what each hook is doing, identify what's causing the symptom, and explain the fix so I understand it — don't just patch it." Asking for the explanation forces the AI to be accurate rather than just plausible.

FAQ

useState is a React hook that lets a component remember a value between renders. You give it an initial value and it returns the current value plus a setter function. When you call the setter with a new value, React re-renders the component with that new value displayed. It is the most fundamental hook — every interactive React component uses it.

useEffect runs code after the component renders — things like fetching data from an API, setting up a timer, or syncing with an external system. The second argument (the dependency array) controls when it runs: an empty array [] means run once on mount, a filled array means run whenever those specific values change. No dependency array at all means run on every render, which is almost always a bug.

Both store values inside a component, but useState triggers a re-render when it changes and useRef does not. Use useState when a value change should update the UI. Use useRef when you need to store something — like a timer ID or a DOM element reference — without causing the component to re-render. A good rule of thumb: if the user needs to see the change, use useState. If it's just internal bookkeeping, use useRef.

AI tools often add useMemo and useCallback as a precaution — they prevent expensive calculations and functions from being recreated on every render. In many cases this is premature optimization that adds complexity without a real performance benefit. Only use them when you have an actual performance problem to solve. For most small-to-medium apps, you can safely remove them without any noticeable impact.

Two rules: First, only call hooks at the top level of a React component — never inside loops, conditions, or nested functions. Second, only call hooks inside React function components (or your own custom hooks) — never in regular JavaScript functions. Breaking either rule causes errors that can be hard to diagnose. React's ESLint plugin (eslint-plugin-react-hooks) catches these automatically — AI-generated setups usually include it.

What to Learn Next

Hooks make most sense in context. These are the articles that will give you that context:

Foundation

What Is React?

Components, JSX, props, and the mental model that makes hooks make sense.

Next Step

What Is State Management?

When useState isn't enough — Zustand, Redux, and when to reach for each.

Related

What Is React Context?

Share data across components without prop drilling — the useContext hook explained.

Advanced

What Is React Suspense?

The modern way to handle loading states — replaces the isLoading pattern.

Prerequisite

What Is Async/Await?

Essential for data fetching inside useEffect — understand it before you write your first fetch call.

Build It to Own It

Prompt an AI tool to build a simple counter that uses all five hooks: useState for the count, useEffect to log to the console when it changes, useRef to track how many times the button has been clicked (without displaying it), useMemo to compute whether the count is even or odd, and useCallback on the increment function. Then open React DevTools and watch every hook's value in real time as you click. Nothing teaches hooks faster.