Skip to main content

πŸͺ State Management with Hooks

Hooks give function components memory and lifecycle. In this lesson you'll learn useState to hold changing data, useEffect to run side effects like data fetching, and useReducer for more complex state β€” plus the rules that keep them working reliably.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Add local state to a component with useState and update it correctly
  • Apply functional updates and understand why state updates are asynchronous and batched
  • Run side effects β€” data fetching, subscriptions, timers β€” with useEffect and its dependency array
  • Follow the Rules of Hooks and explain why they exist
  • Manage related, complex state with useReducer, and know when to choose it over useState

Estimated Time: 40–50 minutes  β€’  Difficulty: Intermediate

Hands-on: Build a live search box that fetches results with useEffect and tracks loading state.

In This Lesson

Why Hooks?

A plain function component is stateless: it runs, returns JSX, and forgets everything. But real UIs need memory β€” a counter's value, a form's inputs, whether a menu is open. Hooks, introduced in React 16.8, are special functions that let a function component "hook into" React features like state and lifecycle.

Before Hooks, only class components could hold state, and reusing stateful logic between components was awkward. Hooks made function components fully capable and are now the recommended way to write React. You can always spot one: a Hook is a function whose name starts with use.

πŸ’‘ Analogy. A function component without Hooks is like a whiteboard wiped clean every time you look away. useState is a sticky note that survives the wipe β€” React keeps it for you between renders and hands it back each time the component runs.

πŸ“– The built-in Hooks you'll use most

useState β€” hold a value that changes over time.
useEffect β€” synchronize with something outside React (fetch data, set a timer, subscribe).
useReducer β€” manage complex or interrelated state with a reducer function.
useContext β€” read shared, app-wide state (the focus of the next lesson).

useState: Local State

useState declares a piece of state. It returns an array with exactly two items β€” the current value and a function to update it β€” which you destructure:

import { useState } from 'react';

function Counter() {
  const [count, setCount] = useState(0); // 0 is the initial value

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

Calling setCount does two things: it stores the new value, and it tells React to re-render the component. On the next render, useState(0) hands back the updated value, not the initial one β€” React remembers it.

The state update cycle A user event calls the state setter, which schedules a re-render, which produces new UI, which the user interacts with again. User event (click, type) setState() store + schedule Re-render run component New UI
Figure 1 β€” The core loop of a React app: an event updates state, state triggers a re-render, and the fresh UI awaits the next event.

State can hold any value β€” a number, string, boolean, array, or object:

const [name, setName] = useState('');
const [isOpen, setIsOpen] = useState(false);
const [items, setItems] = useState([]);
const [user, setUser] = useState({ name: '', email: '' });

Updating State Correctly

State updates have two behaviors that surprise newcomers. Understanding them prevents a whole class of bugs.

State is treated as immutable

Never mutate state directly β€” always pass a new value to the setter. React detects changes by comparing references, so mutating in place won't trigger a re-render:

// ❌ Mutating β€” React won't notice, no re-render
items.push(newItem);
setItems(items);

// βœ… Create a new array
setItems([...items, newItem]);

// βœ… Updating an object β€” spread the old, override the field
setUser({ ...user, email: 'new@site.com' });

Updates are asynchronous and batched

Calling a setter does not change the variable immediately β€” React schedules the update and may batch several together. This code only increments once, because all three calls read the same stale count:

// ⚠️ Reads stale value three times β†’ +1, not +3
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);

When the next value depends on the previous one, use a functional update β€” pass a function that receives the latest state:

// βœ… Each call gets the freshest value β†’ +3
setCount((prev) => prev + 1);
setCount((prev) => prev + 1);
setCount((prev) => prev + 1);

βœ… Rule of thumb

If your new state is computed from the old state, reach for the functional form setX(prev => …). It's always safe, even inside timers, event handlers, and effects where the captured value might be stale.

useEffect: Side Effects

Rendering should be pure β€” it just computes JSX from props and state. Anything that reaches outside React is a side effect: fetching data, setting a timer, subscribing to an event, or manually touching the DOM. useEffect is where those belong.

import { useState, useEffect } from 'react';

function Clock() {
  const [time, setTime] = useState(new Date());

  useEffect(() => {
    const id = setInterval(() => setTime(new Date()), 1000);

    // Cleanup: React runs this before re-running the effect and on unmount
    return () => clearInterval(id);
  }, []); // empty deps β†’ run once after the first render

  return <p>{time.toLocaleTimeString()}</p>;
}

The dependency array controls when it runs

You writeEffect runs…
useEffect(fn) β€” no arrayAfter every render
useEffect(fn, []) β€” emptyOnce, after the first render
useEffect(fn, [query])After the first render and whenever query changes

Fetching data

A classic effect: load data when a value changes. Note the cleanup flag to avoid setting state after the component has moved on (a common source of race-condition warnings):

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

  useEffect(() => {
    let active = true;
    setLoading(true);

    async function load() {
      const res = await fetch(`/api/users/${userId}`);
      const data = await res.json();
      if (active) {
        setUser(data);
        setLoading(false);
      }
    }
    load();

    return () => { active = false; }; // ignore a stale response
  }, [userId]); // re-fetch whenever userId changes

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

⚠️ The dependency array is not optional

List every prop, state value, or variable the effect uses. Leaving one out causes stale data; adding an object or function that's recreated each render causes infinite loops. The ESLint rule react-hooks/exhaustive-deps catches most mistakes β€” don't silence it without understanding why. For data fetching in production apps, a library like TanStack Query or the framework's built-in loader often replaces hand-written fetch effects.

The Rules of Hooks

Hooks rely on being called in the same order on every render β€” that's how React matches each useState call to its stored value. Two rules keep that guarantee:

  1. Only call Hooks at the top level. Never inside loops, conditions, or nested functions. If a Hook is sometimes skipped, React loses track of which state is which.
  2. Only call Hooks from React functions. That means function components or your own custom Hooks β€” not regular JavaScript functions.
// ❌ Conditional Hook β€” breaks the call order
function Bad({ show }) {
  if (show) {
    const [value, setValue] = useState(0); // β›” not top-level
  }
}

// βœ… Hook at the top level; put the condition inside
function Good({ show }) {
  const [value, setValue] = useState(0);
  if (!show) return null;
  return <p>{value}</p>;
}

πŸ“– Custom Hooks

You can extract stateful logic into your own Hook β€” just a function starting with use that calls other Hooks. For example, useLocalStorage(key, initial) could wrap useState and useEffect to persist a value. Custom Hooks are how React teams share behavior without copy-pasting. You'll build them as you grow more comfortable.

useReducer: Complex State

When several pieces of state change together, or the next state depends on the previous in intricate ways, many useState calls get unwieldy. useReducer centralizes that logic in a single reducer function β€” the same idea Redux popularized, built right into React.

A reducer takes the current state and an action, and returns the next state. You trigger changes by dispatching actions:

import { useReducer } from 'react';

const initialState = { count: 0, step: 1 };

function reducer(state, action) {
  switch (action.type) {
    case 'increment':
      return { ...state, count: state.count + state.step };
    case 'decrement':
      return { ...state, count: state.count - state.step };
    case 'setStep':
      return { ...state, step: action.value };
    case 'reset':
      return initialState;
    default:
      throw new Error(`Unknown action: ${action.type}`);
  }
}

function Counter() {
  const [state, dispatch] = useReducer(reducer, initialState);

  return (
    <div>
      <p>Count: {state.count}</p>
      <button onClick={() => dispatch({ type: 'increment' })}>+{state.step}</button>
      <button onClick={() => dispatch({ type: 'decrement' })}>-{state.step}</button>
      <button onClick={() => dispatch({ type: 'reset' })}>Reset</button>
    </div>
  );
}

useState or useReducer?

Choose useState when…Choose useReducer when…
State is a single, independent valueSeveral values change together
Updates are simple (set, toggle)Next state depends on complex logic
A toggle, an input, a flagA form wizard, a cart, an editor

Both are valid; useReducer shines when update logic is worth naming and testing in one place. It also pairs beautifully with Context to manage app-wide state, which is exactly where the next lesson heads.

Hands-on Exercise

πŸ‹οΈ Build a Live Search Box

Objective: Combine useState and useEffect to fetch and display results as the user types.

Requirements:

  1. Track the search query in state, bound to a controlled <input>.
  2. Track results and a loading flag in state.
  3. In a useEffect that depends on query, fetch matches and store them. Skip the fetch when the query is empty.
  4. Show "Searching…" while loading and render the results as a list with keys.
  5. Bonus: debounce the query so you don't fetch on every keystroke.
πŸ’‘ Hint

Use the cleanup flag pattern from the data-fetching example so a slow response for an old query can't overwrite a newer one. For the debounce bonus, set a setTimeout inside the effect and clear it in the cleanup function.

βœ… Sample solution
import { useState, useEffect } from 'react';

function LiveSearch() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    if (query.trim() === '') {
      setResults([]);
      return;
    }

    let active = true;
    setLoading(true);

    // Debounce: wait 300ms after the last keystroke
    const timer = setTimeout(async () => {
      const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
      const data = await res.json();
      if (active) {
        setResults(data);
        setLoading(false);
      }
    }, 300);

    return () => {
      active = false;
      clearTimeout(timer);
    };
  }, [query]);

  return (
    <div>
      <input
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="Search…"
      />
      {loading && <p>Searching…</p>}
      <ul>
        {results.map((r) => (
          <li key={r.id}>{r.title}</li>
        ))}
      </ul>
    </div>
  );
}

Best Practices

βœ… Do

  • Treat state as immutable β€” always pass a new array/object to the setter.
  • Use the functional update form when the next value depends on the previous.
  • List all dependencies a useEffect uses, and return a cleanup when you subscribe or start a timer.
  • Keep state minimal β€” derive values during render instead of storing them redundantly.
  • Reach for useReducer once related state and update logic get tangled.

⚠️ Don't

  • Don't call Hooks inside conditions, loops, or nested functions.
  • Don't mutate state directly (state.push, state.x = …).
  • Don't assume a setter updates the variable immediately β€” it schedules a re-render.
  • Don't overuse useEffect for things you can compute during render; effects are for syncing with the outside world.
  • Don't store in state what you can derive from existing state or props.

Summary & Quiz

πŸŽ‰ Key Takeaways

  • Hooks (functions starting with use) give function components state and lifecycle.
  • useState returns [value, setter]; updating triggers a re-render.
  • Treat state as immutable; use functional updates when the next value depends on the last.
  • useEffect runs side effects; its dependency array controls when, and cleanup tears them down.
  • Follow the Rules of Hooks; graduate to useReducer for complex, interrelated state.

🎯 Quick Quiz

Question 1: What does useState return?

Question 2: When should you pass a function to a state setter, e.g. setCount(prev => prev + 1)?

Question 3: An empty dependency array β€” useEffect(fn, []) β€” causes the effect to run…

πŸ“š Further Reading

πŸš€ What's Next?

Local state works well within a component, but passing it through many layers gets tedious. Next we'll solve app-wide sharing with the Context API and global state β€” including how it pairs with useReducer.

πŸŽ‰ You made it interactive!

State and effects are the beating heart of every React app. Everything from here builds on them.