anila.

React State and Effects that Every Developer Should Know

author avatar of anila website

Introduction

React gives two main tools: state for your data, effects for the outside world. Most bugs come from confusing them or using them incorrectly. That's when you get stale UI, duplicate requests, race conditions, and memory leaks.

This article covers the patterns you might use daily, explains when to reach for each tool, and shows you the anti-patterns to avoid.

The Core Distinction

Before we delve deeper, let's understand the basics that make everything else clear and understandable:

You are managing...The question you're answeringTypical tools
State (ownership)"Who owns this data, and who can read/write it?"useState, useReducer, props, Context, Zustand
Effects (synchronization)"When should this external work start, and when should it stop?"useEffect, useLayoutEffect

State is about where data lives. Effects are about talking to the outside world(synchronizing with systems outside React). The browser APIs, the network, a WebSocket, a timer, subscriptions, anything that isn't React.

When you use the wrong tool, the symptoms are predictable:

  • Stale UI (you're rendering old data because something didn't update)
  • Duplicate work (two components fire the same request)
  • Race conditions (an old response overwrites a new one)
  • Memory leaks (timers or subscriptions keep running after unmount)

State, Data Ownership

Let's start with where your data lives. Most state problems come down to putting data in the wrong place, too high, too low, or duplicated when it shouldn't be.

Local state with useState

The simplest case: one component owns the data, updates it directly. This example shows a controlled input where the component owns the query string:

tsx

import { useState } from "react";

export function SearchBox() {
  const [query, setQuery] = useState("");

  return (
    <input
      value={query}
      onChange={(e) => setQuery(e.target.value)}
      placeholder="Search..."
    />
  );
}

When to use: UI state that belongs to one component (input values, toggles, open/close states).

Lazy initialization

If your initial state requires an expensive computation, pass a function to useState instead of the value directly.

This example avoids re-running the expensive computation on every render:

tsx

import { useState } from "react";

type Todo = { id: string; text: string };

function TodoList() {
  // Bad: runs on every render
  // const [todos, setTodos] = useState(loadTodosFromLocalStorage());

  // Good: runs only once on mount
  const [todos, setTodos] = useState(() => loadTodosFromLocalStorage());

  return (
    <ul>
      {todos.map((todo) => (
        <li key={todo.id}>{todo.text}</li>
      ))}
    </ul>
  );
}

function loadTodosFromLocalStorage() {
  const stored = localStorage.getItem("todos");
  return stored ? (JSON.parse(stored) as Todo[]) : [];
}

When to use: Reading from localStorage, parsing large data, or any computation that shouldn't run on every render.

Derived state (compute during render)

If you can calculate a value from props or state you already have, just... calculate it. Don't store it separately. Don't sync it with an Effect.

The React docs hammer this point in "You Might Not Need an Effect," and for good reason.

Bad practice: storing derived state and syncing with an Effect.

tsx

import { useEffect, useState } from "react";

function Form() {
  const [firstName, setFirstName] = useState("");
  const [lastName, setLastName] = useState("");
  const [fullName, setFullName] = useState("");

  // Bad: extra state + Effect to keep it in sync
  useEffect(() => {
    setFullName(`${firstName} ${lastName}`);
  }, [firstName, lastName]);

  return <p>Hello, {fullName}</p>;
}

Good practice: computing during render.

tsx

import { useState } from "react";

function Form() {
  const [firstName, setFirstName] = useState("");
  const [lastName, setLastName] = useState("");

  // Good: computed during render, always in sync
  const fullName = `${firstName} ${lastName}`;

  return <p>Hello, {fullName}</p>;
}

Why this matters:

  • Fewer state variables to manage
  • No extra re-render from the Effect
  • Impossible to get out of sync

Caching expensive calculations with useMemo

If a derived value is expensive to compute, wrap it in useMemo to skip recalculation when dependencies haven't changed.

This example filters a large list only when the list or filter changes:

tsx

import { useMemo, useState } from "react";

type Todo = { id: string; text: string; completed: boolean };

function TodoList({ todos }: { todos: Todo[] }) {
  const [filter, setFilter] = useState<"all" | "active" | "completed">("all");
  const [theme, setTheme] = useState<"light" | "dark">("light");

  // Only recalculates when todos or filter change, not when theme changes
  const visibleTodos = useMemo(() => {
    return todos.filter((todo) => {
      if (filter === "active") return !todo.completed;
      if (filter === "completed") return todo.completed;
      return true;
    });
  }, [todos, filter]);

  return (
    <div className={theme}>
      <button
        type="button"
        onClick={() => setTheme(theme === "light" ? "dark" : "light")}
      >
        Toggle theme
      </button>
      <ul>
        {visibleTodos.map((todo) => (
          <li key={todo.id}>{todo.text}</li>
        ))}
      </ul>
    </div>
  );
}

When to use: The calculation takes more than ~1ms (measure with console.time), or you're passing the value to a component wrapped in memo.

React 19 note: React Compiler can automatically memoize values and functions in many cases, reducing the need for manual useMemo. React Compiler is opt-in; if you're using it, you may not need to add useMemo manually.

useReducer for complex state transitions

When state transitions become complex or you want explicit, predictable updates, useReducer gives you a clearer model.

This example shows a form with loading, error, and success states:

tsx

import { useReducer } from "react";

type State =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: string }
  | { status: "error"; error: string };

type Action =
  | { type: "submit" }
  | { type: "success"; data: string }
  | { type: "error"; error: string }
  | { type: "reset" };

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case "submit":
      return { status: "loading" };
    case "success":
      return { status: "success", data: action.data };
    case "error":
      return { status: "error", error: action.error };
    case "reset":
      return { status: "idle" };
  }
}

function SubmitForm() {
  const [state, dispatch] = useReducer(reducer, { status: "idle" });

  async function handleSubmit() {
    dispatch({ type: "submit" });
    try {
      const result = await submitToServer();
      dispatch({ type: "success", data: result });
    } catch (e) {
      dispatch({ type: "error", error: String(e) });
    }
  }

  if (state.status === "loading") return <p>Submitting...</p>;
  if (state.status === "success") return <p>Done: {state.data}</p>;
  if (state.status === "error") return <p>Error: {state.error}</p>;

  return <button onClick={handleSubmit}>Submit</button>;
}

When to use:

  • State has multiple sub-values that update together
  • State transitions follow specific rules (state machines)
  • You want to centralize update logic for debugging

Stable function references with useCallback

When you pass a function to a child component wrapped in memo, the function's identity matters. useCallback keeps the same function reference between renders.

This example prevents ExpensiveList from re-rendering when the parent's theme changes:

tsx

import { memo, useCallback, useState } from "react";

type Item = { id: string; name: string };

function Parent() {
  const [theme, setTheme] = useState("light");
  const [items] = useState<Item[]>([]);

  // Without useCallback: new function on every render, ExpensiveList re-renders
  // With useCallback: same function reference, ExpensiveList skips re-render
  const handleItemClick = useCallback((id: string) => {
    console.log("Clicked:", id);
  }, []);

  return (
    <div className={theme}>
      <button onClick={() => setTheme(theme === "light" ? "dark" : "light")}>
        Toggle theme
      </button>
      <ExpensiveList items={items} onItemClick={handleItemClick} />
    </div>
  );
}

const ExpensiveList = memo(function ExpensiveList({
  items,
  onItemClick,
}: {
  items: Item[];
  onItemClick: (id: string) => void;
}) {
  // Expensive render...
  return (
    <ul>
      {items.map((item) => (
        <li key={item.id} onClick={() => onItemClick(item.id)}>
          {item.name}
        </li>
      ))}
    </ul>
  );
});

When to use:

  • Passing a function to a component wrapped in memo
  • The function is a dependency of another Hook (useEffect, useMemo)

React 19 note: React Compiler can auto-memoize callbacks in many cases.

useRef for values that don't trigger re-render

useRef holds a mutable value that persists across renders but doesn't cause re-renders when changed.

This example stores a timer ID without triggering re-renders:

tsx

import { useRef, useState, useEffect } from "react";

function Stopwatch() {
  const [time, setTime] = useState(0);
  const [isRunning, setIsRunning] = useState(false);
  const intervalRef = useRef<number | null>(null);

  function start() {
    setIsRunning(true);
    intervalRef.current = window.setInterval(() => {
      setTime((t) => t + 1);
    }, 1000);
  }

  function stop() {
    setIsRunning(false);
    if (intervalRef.current !== null) {
      clearInterval(intervalRef.current);
    }
  }

  // Cleanup on unmount
  useEffect(() => {
    return () => {
      if (intervalRef.current !== null) {
        clearInterval(intervalRef.current);
      }
    };
  }, []);

  return (
    <div>
      <p>{time}s</p>
      <button onClick={isRunning ? stop : start}>
        {isRunning ? "Stop" : "Start"}
      </button>
    </div>
  );
}

When to use:

  • Storing timeout/interval IDs
  • Storing DOM element references
  • Storing previous values for comparison
  • Any mutable value that shouldn't trigger re-render

Rule: Don't read or write ref.current during rendering. Use it in event handlers or Effects.

Resetting state with the key prop

When you need to reset all state in a component, change its key. React treats different keys as different component instances.

This example resets the form when the user changes:

tsx

import { useState } from "react";

function App() {
  const [userId, setUserId] = useState("alice");

  return (
    <div>
      <button onClick={() => setUserId("alice")}>Alice</button>
      <button onClick={() => setUserId("bob")}>Bob</button>
      {/* Form state resets when userId changes */}
      <ProfileForm key={userId} userId={userId} />
    </div>
  );
}

function ProfileForm({ userId }: { userId: string }) {
  const [draft, setDraft] = useState("");
  // This draft is automatically reset when parent changes the key
  return <input value={draft} onChange={(e) => setDraft(e.target.value)} />;
}

When to use: You need to reset all internal state when a prop changes (user switches, item changes, etc.).

Lifting state vs global state

Lift state up when sibling components need the same data. The closest common parent owns the state and passes it down.

Use global state (Context, Zustand) when unrelated parts of the tree need the same data.

tsx

// Lifting state: parent owns filter, passes to both children
import { useState } from "react";

function App() {
  const [filter, setFilter] = useState("all");

  return (
    <>
      <FilterBar filter={filter} onFilterChange={setFilter} />
      <TodoList filter={filter} />
    </>
  );
}

// Global state: when lifting would create prop drilling through many layers
import { create } from "zustand";

const useStore = create<{ theme: "light" | "dark"; toggleTheme: () => void }>(
  (set) => ({
    theme: "light",
    toggleTheme: () =>
      set((state) => ({ theme: state.theme === "light" ? "dark" : "light" })),
  }),
);

function AnywhereInTheTree() {
  const { theme, toggleTheme } = useStore();
  return <button onClick={toggleTheme}>Theme: {theme}</button>;
}

Rules of thumb:

  • Keep state as local as possible
  • Lift only when multiple components need it
  • Use global state when data crosses many unrelated branches

Effect, External Sync

Effects are how you step outside React's world. Subscriptions, timers, browser APIs, network requests, anything that React doesn't control. The key is knowing when to use them (and when not to).

Basic Effect with cleanup

Here's the one rule that prevents most Effect bugs: if you start something, clean it up. This example subscribes to a browser event and unsubscribes on unmount:

tsx

import { useEffect, useState } from "react";

function useWindowWidth() {
  const [width, setWidth] = useState<number | null>(null);

  useEffect(() => {
    // Setup: subscribe to the external system
    const handleResize = () => setWidth(window.innerWidth);
    handleResize(); // Read initial value

    window.addEventListener("resize", handleResize);

    // Cleanup: unsubscribe when component unmounts or effect re-runs
    return () => window.removeEventListener("resize", handleResize);
  }, []);

  return width;
}
  1. Setup: start the work
  2. Return cleanup: stop the work

Dependency array rules

The dependency array tells React when to re-run the Effect.

tsx

useEffect(() => {
  // Runs after every render
});

useEffect(() => {
  // Runs only on mount (empty array = no dependencies)
}, []);

useEffect(() => {
  // Runs on mount AND when roomId changes
  const connection = connectToRoom(roomId);
  return () => connection.disconnect();
}, [roomId]);

Rules:

  • Include every reactive value (props, state, variables) used inside the Effect
  • React's linter will warn you if you're missing dependencies
  • If you want to "skip" a dependency, the fix is usually to restructure your code, not to lie to React

Strict Mode: why Effects run twice

If you've ever wondered why your Effect runs twice in development, that's StrictMode doing its job. React intentionally runs setup → cleanup → setup for every Effect to stress test your code.

It's catching bugs before you ship them. If your Effect breaks under Strict Mode:

  • You probably forgot to return cleanup
  • Your cleanup doesn't actually undo what setup did

Don't try to make it run once. Fix your cleanup so setup → cleanup → setup works correctly. In production, only runs setup once.

useLayoutEffect for DOM measurement

useLayoutEffect runs synchronously after DOM mutations but before the browser paints. Use it when you need to measure or mutate the DOM before the user sees anything.

This example measures an element's dimensions before paint:

tsx

import { useLayoutEffect, useRef, useState, type ReactNode } from "react";

function Tooltip({ children }: { children: ReactNode }) {
  const ref = useRef<HTMLDivElement>(null);
  const [height, setHeight] = useState(0);

  useLayoutEffect(() => {
    if (ref.current) {
      // Measure before browser paints
      setHeight(ref.current.getBoundingClientRect().height);
    }
  }, []);

  return (
    <div ref={ref} style={{ marginTop: -height }}>
      {children}
    </div>
  );
}

When to use:

  • Measuring DOM elements before paint
  • Preventing visual flicker from layout changes

Warning: useLayoutEffect blocks painting. Use useEffect by default; only switch to useLayoutEffect if you see flicker.

What Effects are NOT for

Many things that feel like Effects are better handled differently, but they're actually an escape hatch, not the main event. Here are the traps to avoid.

Don't use Effects for derived state

Bad practice:

tsx

const [fullName, setFullName] = useState("");
useEffect(() => {
  setFullName(`${first} ${last}`);
}, [first, last]);

Good practice:

tsx

const fullName = `${first} ${last}`;

Don't use Effects for event-specific logic

Bad practice:

tsx

useEffect(() => {
  if (productAddedToCart) {
    showNotification("Added to cart!");
  }
}, [productAddedToCart]);

Good practice:

tsx

function handleAddToCart() {
  addToCart(product);
  showNotification("Added to cart!");
}

Don't use Effects for POST requests triggered by user actions

Bad practice:

tsx

useEffect(() => {
  if (shouldSubmit) {
    fetch("/api/submit", { method: "POST", body: data });
  }
}, [shouldSubmit, data]);

Good practice:

tsx

function handleSubmit() {
  fetch("/api/submit", { method: "POST", body: data });
}

Don't use Effects to notify parent of state changes

Bad practice:

tsx

useEffect(() => {
  onChange(value);
}, [value, onChange]);

Good practice:

tsx

function handleChange(newValue) {
  setValue(newValue);
  onChange(newValue); // Call in the same event
}

Server Data

Why server data is different

Server data looks like state because you render it. But it has time-based rules that client state doesn't:

  • Cache results so you don't re-fetch on every mount
  • Deduplicate requests (two components shouldn't double-fetch)
  • Refetch when data becomes stale
  • Cancel in-flight requests on unmount
  • Retry transient failures

If you implement all of this manually in useEffect, you'll repeat the same code everywhere.

Manual fetch

You can absolutely do this yourself. It works. But look at how much code you need just to handle the basics:

tsx

import { useEffect, useState } from "react";

type User = { id: string; name: string };

function UserProfile({ userId }: { userId: string }) {
  const [user, setUser] = useState<User | null>(null);
  const [error, setError] = useState<string | null>(null);
  const [isLoading, setIsLoading] = useState(true);

  useEffect(() => {
    const controller = new AbortController();
    setIsLoading(true);
    setError(null);

    fetch(`/api/users/${userId}`, { signal: controller.signal })
      .then((r) => {
        if (!r.ok) throw new Error(`HTTP ${r.status}`);
        return r.json() as Promise<User>;
      })
      .then((data) => {
        setUser(data);
        setIsLoading(false);
      })
      .catch((err) => {
        if (controller.signal.aborted) return; // Ignore abort
        setError(String(err));
        setIsLoading(false);
      });

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

  if (isLoading) return <p>Loading...</p>;
  if (error) return <p>Error: {error}</p>;
  return <p>Hello, {user?.name}</p>;
}

Problems with this approach:

  • No caching (re-fetches on every mount)
  • No deduplication (two components = two requests)
  • No automatic refetch on staleness
  • Lots of boilerplate

This is where life gets easier. TanStack Query handles caching, deduplication, retries, and cancellation, so you can focus on your UI.

Install:

bash

pnpm add @tanstack/react-query

Provider setup (once, in your app root):

tsx

"use client";

import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState, type ReactNode } from "react";

export function QueryProvider({ children }: { children: ReactNode }) {
  // Create client once, not on every render
  const [client] = useState(() => new QueryClient());
  return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
}

Usage:

tsx

import { useQuery } from "@tanstack/react-query";

type User = { id: string; name: string };

function UserProfile({ userId }: { userId: string }) {
  const { data, isLoading, error } = useQuery({
    queryKey: ["user", userId],
    queryFn: async ({ signal }) => {
      const r = await fetch(`/api/users/${userId}`, { signal });
      if (!r.ok) throw new Error(`HTTP ${r.status}`);
      return (await r.json()) as User;
    },
  });

  if (isLoading) return <p>Loading...</p>;
  if (error) return <p>Error: {String(error)}</p>;
  return <p>Hello, {data?.name}</p>;
}

What you get:

  • Automatic caching
  • Request deduplication
  • Automatic retries
  • Cancellation via signal
  • Loading/error states
  • Background refetching

Server Components

If your framework supports Server Components (Next.js App Router, etc.), you can fetch data on the server before it even reaches the client.

Note: Server Components are stable in React 19, but you need a framework/bundler that supports React Server Components, this isn't something you can turn on in plain React.

tsx

// This file does NOT have "use client"
// It runs on the server

type User = { id: string; name: string };

async function getUser(userId: string): Promise<User> {
  const r = await fetch(`https://api.example.com/users/${userId}`);
  if (!r.ok) throw new Error("Failed to fetch user");
  return r.json();
}

export default async function UserPage({
  params,
}: {
  params: { userId: string };
}) {
  const user = await getUser(params.userId);
  return <p>Hello, {user.name}</p>;
}

Benefits:

  • No client-side loading state for initial render
  • No client-side Effect for "fetch on mount"
  • Data is already there when the page loads

When to use TanStack Query vs Server Components:

  • Server Components: initial page data, data that doesn't change often
  • TanStack Query: data that changes frequently, user-triggered refetches, optimistic updates

Common Pitfalls

A quick reference for the mistakes that bite most often. If something feels off in your component, scan this list.

1. Effects for derived state

Bad: Storing computed values in state and syncing with Effects

Good: Compute during render (or use useMemo if expensive)

2. Forgetting cleanup

Bad: useEffect(() => { window.addEventListener(...) }, [])

Good: useEffect(() => { window.addEventListener(...); return () => window.removeEventListener(...) }, [])

3. Object, array dependencies that change every render

Bad: useEffect(() => { ... }, [{ id: 1 }]), new object every render

Good: Move object creation inside Effect, or memoize it, or depend on primitive values

4. Fetching in Effects without cancellation

Bad: Ignoring race conditions when userId changes rapidly

Good: Use AbortController or TanStack Query

5. Over globalizing state

Bad: Putting everything in Context or Zustand "just in case"

Good: Keep state as local as possible; lift only when proven necessary

6. Using refs during render

Bad: Reading ref.current in the render body

Good: Read refs in event handlers or Effects

7. Event specific logic in Effects

Bad: Tracking wasButtonClicked state and reacting in an Effect

Good: Put the logic in the event handler directly

Conclusion

Here's what I wish someone had told me earlier: most React bugs aren't about React. They're about mixing up two different questions.

State is about ownership. Who's in charge of this data? Keep it close to where it's used. Start local, lift only when you actually need to, go global only when necessary.

Effects are about the outside world. Timers, subscriptions, the DOM, network requests, anything that isn't React. The rule is simple: if you start something, clean it up. That's it.

The trap most of us fall into? Reaching for useEffect when we don't need it. Before you write one, pause and ask: can I just calculate this value? Can I do this in the event handler? Nine times out of ten, you can.

For fetching data, save yourself the headache. Use TanStack Query or Server Components. You'll spend less time debugging race conditions and more time building features.

These patterns aren't complicated once you see them. The hard part is unlearning the habits that got us here. But once it clicks, React becomes predictable.

contact
contact icon
contact iconcontact iconcontact iconcontact iconcontact icon

ぜひお気軽にフォローやご連絡してください。 お仕事のご相談もお待ちしています。

Copyright © anila. All rights reserved.