
React State and Effects that Every Developer Should Know

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 answering | Typical 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; }
- Setup: start the work
- 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
TanStack Query (recommended for client-side fetching)
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.

