Skip to content
C Codeloom
React

The Most Important React Hooks: useState and useEffect

A focused beginner's guide to React's two essential hooks — useState for component state and useEffect for side effects like data fetching, subscriptions, and timers.

·10 min read · By Yash Kesharwani
Beginner 12 min read

What you'll learn

  • What hooks are and the rules that govern them
  • useState in depth — initial values, updater functions, lazy init
  • useEffect — when it runs, what the dependency array means, and cleanup
  • How to fetch data inside an effect without leaks
  • The common pitfalls (infinite loops, stale closures) and how to avoid them

Prerequisites

  • A working understanding of props and state — see Props and State
  • Comfort with JavaScript closures and async/await

Hooks are the modern way to add state and side effects to function components. Two of them — useState and useEffect — cover the overwhelming majority of what beginner React code needs. This post is the practical deep dive.

What a hook is

A hook is a special function that lets a component “hook into” React features. Hooks always start with use: useState, useEffect, useRef, useMemo, and so on. React maintains state and effects per component, behind the scenes, by matching hook calls to slots based on the order they appear.

That implementation detail leads to two strict rules.

The rules of hooks

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

Never inside loops, conditions, or nested functions.

// Wrong — conditional hook
function Bad({ user }) {
  if (user) {
    const [name, setName] = useState(user.name);
  }
}

// Right — call the hook unconditionally, branch on the value
function Good({ user }) {
  const [name, setName] = useState(user?.name ?? '');
}

2. Only call hooks from React function components or other hooks.

Not from plain functions, event handlers, or class methods.

The Vite template ships with an ESLint plugin that catches both rule violations. Pay attention to its warnings.

useState in depth

You met useState in the previous post. Here is what is going on more thoroughly.

const [value, setValue] = useState(initialValue);
  • value — the current value for this render
  • setValue — a function that schedules a state update and triggers a re-render
  • initialValue — used only on the first render and ignored on subsequent renders

The initial value

The initial value is set exactly once, the first time the component mounts. Re-renders ignore it.

function Counter({ start }) {
  const [count, setCount] = useState(start);
  // count starts at `start` and stays there until setCount is called
}

If your initial value is expensive to compute, pass a function instead of a value. React will only call it on the first render:

const [items, setItems] = useState(() => loadItemsFromLocalStorage());

This is lazy initialisation. It is the standard pattern for any non-trivial default.

The updater function

When the new state depends on the previous state, use the functional form:

setCount((prev) => prev + 1);

Why this matters: state updates are batched. If you call setCount(count + 1) twice in a row, both calls capture the same count value and the counter only increments by one. The functional form receives the most up-to-date value:

function increment() {
  setCount((c) => c + 1);
  setCount((c) => c + 1);   // count goes up by 2
}

Adopt the functional form by default whenever the new state depends on the previous state.

Multiple useState calls

You can — and should — call useState more than once for unrelated values:

function Form() {
  const [name, setName] = useState('');
  const [email, setEmail] = useState('');
  const [submitting, setSubmitting] = useState(false);
  // ...
}

React identifies each hook by its call order, which is why the rules of hooks demand consistent ordering on every render.

Try it yourself. Write a useCounter hook of your own — a custom hook that wraps useState and returns { count, increment, decrement, reset }. Use it in two different components and confirm each instance keeps its own count. Custom hooks are the standard way to share stateful logic between components.

useEffect: side effects

useState covers anything that lives inside React. But most apps need to do things outside React: fetch data, set up a subscription, start a timer, log to analytics, sync to local storage. These are side effects, and they belong inside useEffect.

The signature:

useEffect(() => {
  // effect: runs after the render is committed
  return () => {
    // optional cleanup: runs before the next effect or on unmount
  };
}, [/* dependencies */]);

Three things to understand: when it runs, what the dependency array does, and what cleanup is for.

When the effect runs

React runs the effect after the component renders and the DOM is updated. This guarantees that anything reading the DOM sees the latest version.

import { useState, useEffect } from 'react';

function PageTitle({ title }) {
  useEffect(() => {
    document.title = title;
  }, [title]);

  return <h1>{title}</h1>;
}

When title changes, React re-renders, then runs the effect, which updates the browser tab.

The dependency array

The second argument to useEffect is a list of dependencies. React re-runs the effect when any value in the list changes between renders.

  • [] — run once after the first render. The cleanup runs on unmount.
  • [a, b] — run after every render where a or b changed.
  • Omitted — run after every render. Almost never what you want.

The two patterns you will use most:

// Run once on mount, clean up on unmount
useEffect(() => {
  const id = setInterval(tick, 1000);
  return () => clearInterval(id);
}, []);

// Run when `userId` changes
useEffect(() => {
  loadUser(userId);
}, [userId]);

Every value from the component scope that you use inside the effect should be listed in the dependency array. The ESLint plugin eslint-plugin-react-hooks enforces this. Trust it — silencing the warning is almost always a bug waiting to happen.

Cleanup

The function you return from an effect is its cleanup. React runs the cleanup before the next effect and when the component unmounts. This is how you avoid memory leaks, duplicate subscriptions, and stale timers.

useEffect(() => {
  function handleResize() {
    console.log(window.innerWidth);
  }
  window.addEventListener('resize', handleResize);
  return () => window.removeEventListener('resize', handleResize);
}, []);

If you add a listener without removing it, every mount adds another one and they pile up. Always pair addEventListener with removeEventListener, setInterval with clearInterval, and so on.

Fetching data inside useEffect

The most common real use of useEffect is loading data when a component mounts. The standard pattern:

import { useState, useEffect } from 'react';

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

  useEffect(() => {
    const controller = new AbortController();

    async function load() {
      try {
        setLoading(true);
        const res = await fetch(`/api/users/${userId}`, {
          signal: controller.signal,
        });
        if (!res.ok) throw new Error('Request failed');
        const data = await res.json();
        setUser(data);
        setError(null);
      } catch (err) {
        if (err.name !== 'AbortError') setError(err);
      } finally {
        setLoading(false);
      }
    }

    load();

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

  if (loading) return <p>Loading...</p>;
  if (error) return <p>Error: {error.message}</p>;
  return <h1>{user.name}</h1>;
}

A few details worth noticing.

  • The async function is declared inside the effect and called immediately. You cannot make the effect callback itself async — React expects a cleanup function or nothing as the return value.
  • The AbortController cancels an in-flight request when the component unmounts or userId changes. Without it, a stale response can overwrite newer state.
  • loading, error, and user are three separate pieces of state. A small price for clear, predictable UI.

For real applications, libraries like TanStack Query or SWR wrap this pattern with caching and retries. For a beginner, the manual version is what you need to understand first.

Try it yourself. Use the public API at https://jsonplaceholder.typicode.com/users to render a list of names. Show a loading state, handle errors, and confirm there are no React warnings in the console when the component unmounts mid-fetch.

Common pitfalls

A few mistakes show up so often they are worth naming.

Infinite loops

If you update state inside an effect and forget the dependency array, you cause an infinite loop:

// Re-renders forever
useEffect(() => {
  setCount(count + 1);
});

Every render runs the effect, every effect calls setCount, every setCount triggers a render. Always supply a dependency array and only depend on values that genuinely should trigger the effect.

Missing dependencies

If you reference userId inside the effect but leave it out of the dependency array, the effect captures the first userId it saw and never updates. The ESLint warning catches this. Listen to it.

Stale closures

Each render’s effect captures the values from that render. If your effect runs once ([]) but reads state inside a timer, the timer keeps reading the first state value forever:

useEffect(() => {
  const id = setInterval(() => {
    setCount(count + 1);   // `count` is captured from the first render
  }, 1000);
  return () => clearInterval(id);
}, []);

Fix with the functional setter, which reads the latest value:

setCount((c) => c + 1);

Doing too much in one effect

If a single useEffect is doing two unrelated things, split it. One concern per effect makes dependencies smaller, cleanup clearer, and bugs rarer.

A small worked example

A live search box that fetches matching results from an API, with debouncing and cleanup:

import { useState, useEffect } from 'react';

function Search() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);

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

    const controller = new AbortController();
    const handle = setTimeout(async () => {
      try {
        const res = await fetch(
          `https://api.example.com/search?q=${encodeURIComponent(query)}`,
          { signal: controller.signal }
        );
        const data = await res.json();
        setResults(data.items);
      } catch (err) {
        if (err.name !== 'AbortError') console.error(err);
      }
    }, 300);

    return () => {
      clearTimeout(handle);
      controller.abort();
    };
  }, [query]);

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

export default Search;

Every concept in this post appears: useState for the input and results, useEffect triggered by query, a dependency array, a cleanup that cancels both the timer and the fetch.

Recap

You now know:

  • A hook is a function that adds React features to a component
  • Always call hooks at the top level, never conditionally
  • useState returns [value, setter] — pass a function for lazy initial values, use the functional setter for updates that depend on the previous state
  • useEffect runs after render, re-runs when a dependency changes, and can return a cleanup
  • [] runs once on mount, a populated array tracks specific values, no array runs after every render
  • Inside data-fetching effects use an AbortController to avoid stale updates
  • Watch out for infinite loops, missing dependencies, and stale closures

Next steps

You now have the working vocabulary of modern React — components, JSX, props, state, and the two essential hooks. The natural next step is to start building real UIs: forms with validation, routing between pages with React Router, and persisting data to a backend. The next series builds on these foundations to ship a complete small application.

→ Next: Forms in React: Controlled Inputs and Validation

Questions or feedback? Email codeloomdevv@gmail.com.