TL;DR: State is data your app remembers and can change — like what's in a cart, whether a menu is open, or who's logged in. React gives you useState for local state and the Context API for sharing state across components. Libraries like Zustand add convenience; Redux adds power you rarely need. AI picks the wrong tool constantly — this guide shows you how to catch it.
Why AI Coders Need to Know This
State is the invisible engine behind every interactive app. When you click a button and something changes on screen, that's state updating. When a form remembers what you typed, that's state. When your user logs in and every page shows their name — that's state too.
When you ask Claude or Cursor to build a dashboard, it makes dozens of state decisions on your behalf. Where does each piece of data live? Which component owns it? How does it flow to the parts that need it? These decisions shape whether your app is easy to modify, or a tangled mess that breaks every time you touch it.
The problem is that AI makes these decisions based on pattern matching, not judgment. It might wrap a simple two-screen app in a full Redux store because that's what the training data for "dashboard" looked like. Or it might jam everything into local component state and then hand you 12 layers of props to debug when you need to share data between components.
You do not need a CS degree to understand this. You need one mental model: state is data, and every piece of data has to live somewhere. Everything else is just decisions about where.
This connects directly to how React works — if you have not read that guide, it gives you the component and hook foundation you need before this one will click.
Real Scenario
You open Claude Code and type:
Prompt I Would Type
Build me a project dashboard. It needs:
- A sidebar with the user's name and avatar
- A list of projects the user owns
- A modal to create a new project
- A notification bell that shows unread count
- A settings panel that can be toggled open
Use React with hooks. Show me how to manage the state for all of this.
This is a real dashboard. It has local state (is the modal open?), shared state (the user's data shows up in multiple places), and async state (the project list comes from an API). Claude will generate something that works — but it will make state decisions that might not serve you well long-term.
Let's look at what it generates, then understand every choice it made.
What AI Generated
Claude produced four files. Here's the core of what the state architecture looks like — read the comments carefully, because each one explains a decision.
The Context setup — shared/global state
// AppContext.js
// Context is React's built-in way to share state without prop drilling.
// Think of it as a broadcast channel — any component can tune in.
import { createContext, useContext, useState } from 'react';
// Step 1: Create the context object (just an empty container for now)
const AppContext = createContext(null);
// Step 2: Create a Provider component that wraps your app
// Everything inside the Provider can access this state
export function AppProvider({ children }) {
// user is shared — sidebar, header, settings all need it
const [user, setUser] = useState({
name: 'Chuck',
avatar: '/avatars/chuck.jpg',
unreadCount: 3,
});
// projects is shared — dashboard list and create modal both need it
const [projects, setProjects] = useState([]);
// These functions modify the shared state
function addProject(project) {
setProjects(prev => [...prev, { ...project, id: Date.now() }]);
}
function markAllRead() {
setUser(prev => ({ ...prev, unreadCount: 0 }));
}
// Step 3: Return the value — everything components can access
return (
<AppContext.Provider value={{ user, projects, addProject, markAllRead }}>
{children}
</AppContext.Provider>
);
}
// Step 4: Custom hook so components don't import AppContext directly
// Usage: const { user, projects } = useApp();
export function useApp() {
return useContext(AppContext);
}
The Dashboard component — local state for UI toggles
// Dashboard.jsx
// Local state stays here because only THIS component cares about it.
// The modal being open or closed is nobody else's business.
import { useState } from 'react';
import { useApp } from './AppContext';
import Sidebar from './Sidebar';
import ProjectList from './ProjectList';
import CreateProjectModal from './CreateProjectModal';
import SettingsPanel from './SettingsPanel';
export default function Dashboard() {
// useApp() pulls shared state from Context
const { user, projects, addProject } = useApp();
// These are local state — only Dashboard needs to know about them
const [isModalOpen, setIsModalOpen] = useState(false);
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
return (
<div className="dashboard">
{/* Sidebar gets user from Context, not from props passed down */}
<Sidebar />
<main>
<button onClick={() => setIsModalOpen(true)}>
New Project
</button>
<button onClick={() => setIsSettingsOpen(prev => !prev)}>
Settings
</button>
{/* projects comes from Context, addProject is a shared function */}
<ProjectList projects={projects} />
</main>
{/* Modal is conditionally rendered based on local state */}
{isModalOpen && (
<CreateProjectModal
onClose={() => setIsModalOpen(false)}
onSubmit={(data) => {
addProject(data); // updates shared state
setIsModalOpen(false); // updates local state
}}
/>
)}
{isSettingsOpen && <SettingsPanel />}
</div>
);
}
A child component — reading from Context directly
// Sidebar.jsx
// Notice: no props passed in from Dashboard.
// Sidebar reaches directly into Context to get what it needs.
import { useApp } from './AppContext';
export default function Sidebar() {
const { user, markAllRead } = useApp();
return (
<aside className="sidebar">
<img src={user.avatar} alt={user.name} />
<h2>{user.name}</h2>
<button
className="notification-bell"
onClick={markAllRead}
>
{/* unreadCount shows a badge when greater than zero */}
{user.unreadCount > 0 && (
<span className="badge">{user.unreadCount}</span>
)}
Notifications
</button>
</aside>
);
}
This is a clean state architecture. Claude did a reasonable job here. Let's make sure you understand every decision it made — because AI does not always get it this clean.
Understanding Each Part
What state actually is
State is any data your app needs to remember that can change over time. It is different from a regular variable in one critical way: when state changes, React automatically re-renders the components that depend on it. A regular variable changing does nothing to the screen. State changing updates the UI.
Think of it like a scoreboard at a construction site. The numbers on the board are state. When someone updates them, every worker who can see the board gets the new information automatically. You do not have to tap each person on the shoulder and tell them — the board updates itself.
Local state with useState
useState is the foundation. When you ask Claude to build anything interactive, this is the first hook it reaches for.
const [isOpen, setIsOpen] = useState(false);
// ^^^^^^ ^^^^^^^^ ^^^^^^^^^^^^^^^^^^
// value setter fn initial value
useState returns two things in an array. The first is the current value. The second is the function you call to change it. When you call setIsOpen(true), React updates isOpen to true and re-renders the component.
Use local state when: the data is only relevant to one component. A modal being open or closed, whether a password field is visible, the current value of a search input — none of that matters to the rest of your app.
useReducer — when useState gets complicated
useReducer is like useState with a set of rules. Instead of calling a setter directly, you dispatch named actions and a reducer function decides what the new state should be.
// The reducer: a pure function that takes old state + action, returns new state
function projectReducer(state, action) {
switch (action.type) {
case 'ADD':
return [...state, action.payload];
case 'DELETE':
return state.filter(p => p.id !== action.payload);
case 'TOGGLE_DONE':
return state.map(p =>
p.id === action.payload ? { ...p, done: !p.done } : p
);
default:
return state;
}
}
// In your component:
const [projects, dispatch] = useReducer(projectReducer, []);
// To add a project:
dispatch({ type: 'ADD', payload: { id: 1, name: 'New Site' } });
// To delete one:
dispatch({ type: 'DELETE', payload: 1 });
The advantage: all the logic for how state changes is in one place (the reducer), not scattered across a dozen event handlers. When Claude generates complex state with lots of conditional updates, ask it to use useReducer instead — the result is much easier to trace.
Use useReducer when: you have multiple related pieces of state that change together, the next state depends on the previous state in non-trivial ways, or you want to keep update logic centralized and testable.
The Context API — sharing state without prop drilling
Context solves a specific problem: you have data that many components need, but those components are not directly related. Passing it down through props would mean every component in between has to accept and forward a prop it does not use.
That is called prop drilling, and it looks like this:
// Without Context — App passes user through 3 layers just to reach Avatar
<App user={user}>
<Layout user={user}> {/* Layout doesn't use user */}
<Sidebar user={user}> {/* Sidebar doesn't use user */}
<Avatar user={user} /> {/* Avatar finally uses it */}
</Sidebar>
</Layout>
</App>
// With Context — Avatar reaches in directly, no prop chain
<AppProvider>
<App>
<Layout>
<Sidebar>
<Avatar /> {/* pulls user from context with useApp() */}
</Sidebar>
</Layout>
</App>
</AppProvider>
Context is built into React. It is free to use, requires no extra packages, and handles a large percentage of real global state needs without any added complexity.
Use Context when: the same data (user info, theme, language, auth status) needs to be accessible in multiple places without being explicitly passed around.
When you might actually want Zustand
Zustand is a small state management library (about 1KB) that gives you a global store without the boilerplate of Redux. Claude reaches for it in medium-complexity apps where Context starts to feel cumbersome.
// store.js — the entire Zustand store in one place
import { create } from 'zustand';
export const useProjectStore = create((set) => ({
projects: [],
addProject: (project) =>
set((state) => ({ projects: [...state.projects, project] })),
deleteProject: (id) =>
set((state) => ({ projects: state.projects.filter(p => p.id !== id) })),
}));
// In any component — no Provider needed, just import and use
import { useProjectStore } from './store';
function ProjectList() {
const { projects, deleteProject } = useProjectStore();
// ...
}
Zustand's main selling point over Context: no Provider wrapper needed, and performance is better when state changes frequently (Context re-renders all consumers; Zustand only re-renders components that subscribed to the specific slice that changed).
Use Zustand when: Context is causing performance problems or the Provider nesting is getting out of hand. For most AI-built apps, you will not need it until the app is fairly large.
When Redux makes sense (and when it absolutely does not)
Redux is a powerful, predictable state container. It has been around since 2015 and has a massive ecosystem. It also adds significant complexity: you need to understand actions, reducers, a store, middleware, and selector functions. Redux Toolkit (RTK) modernizes it considerably, but it is still heavier than anything above.
When Claude generates Redux for a small app, that is almost always a mistake. The decision tree is simple:
- One or two components need data? Use
useState+ props. - Several components across your app need the same data? Use Context or Zustand.
- Very large app, complex state interactions, you want time-travel debugging? Then maybe Redux.
If you see Redux in AI-generated code for a small project, it is safe to ask: "Is Redux actually necessary here, or can we use the Context API instead? I'd like to keep the dependencies minimal."
The Rule of Thumb
Start with useState. When you need to share state, try Context. If Context causes performance issues or the app grows complex, consider Zustand. Only reach for Redux if you genuinely need its DevTools or have a team that already knows it well.
What AI Gets Wrong About State Management
AI-generated state has a reliable set of failure modes. Recognizing them saves you hours of debugging.
Mistake 1: Putting everything in global state
This is the most common AI mistake. Claude sees a dashboard prompt and thinks "big app = global state for everything." You end up with a Context or Zustand store holding data that only one component ever uses — like whether a dropdown menu inside the sidebar is open.
// AI might generate this — putting UI-only state in global context
const AppContext = createContext();
export function AppProvider({ children }) {
const [isSidebarDropdownOpen, setIsSidebarDropdownOpen] = useState(false);
// ^ Nobody outside Sidebar cares about this. It should stay local.
// ...
}
// Better — keep UI state local where it belongs
function Sidebar() {
const [isDropdownOpen, setIsDropdownOpen] = useState(false); // lives here
// ...
}
Global state adds overhead. Every component wrapped in the Provider re-renders when any global state changes. Keep UI state (open/closed toggles, hover states, form field values) local unless another component genuinely needs it.
Mistake 2: Prop drilling instead of Context
The opposite problem. AI sometimes skips Context entirely and passes the same prop down through five layers of components. If you see the same prop being passed into every component in the tree — especially when intermediate components never use it — that is prop drilling and it should be refactored.
The fix: tell your AI tool "this prop is being drilled through components that don't use it. Refactor this to use the Context API instead."
Mistake 3: Stale closures in event handlers
This one is subtle and causes bugs that are genuinely hard to find. A closure is a function that "closes over" the variables in its surrounding scope. In React, event handlers close over state values — but if the handler is created once and the state changes, the handler might be holding onto an old (stale) value.
// Stale closure bug — count might be stale inside the interval
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
setCount(count + 1); // 'count' is captured once — stays 0 forever
}, 1000);
return () => clearInterval(interval);
}, []); // empty dependency array means the effect runs once
// count inside the callback is permanently 0
}
// Fix — use the functional updater form, which always gets the latest state
useEffect(() => {
const interval = setInterval(() => {
setCount(prev => prev + 1); // 'prev' is always the current state
}, 1000);
return () => clearInterval(interval);
}, []);
If you see this error: "the counter only goes to 1 and stops" or "the value always shows 0 in my function" — stale closure is likely the culprit. Ask your AI: "Is it possible there's a stale closure here? Should the setter use the functional updater form?"
Mistake 4: Re-initializing state on every render
AI sometimes puts complex initial state calculations inline, causing them to re-run on every render even though they only need to run once.
// Slow — parseLargeJSON runs on every render
const [data, setData] = useState(parseLargeJSON(rawData));
// Fast — lazy initializer runs only once
const [data, setData] = useState(() => parseLargeJSON(rawData));
// ^^ function form = lazy initialization
If initial state is expensive to compute (parsing large JSON, processing a big array), pass a function to useState instead of a value. React calls it once and caches the result.
Mistake 5: Mutating state objects directly
React state must be treated as immutable — you replace it with a new value rather than changing the existing one in place. AI occasionally generates direct mutations, especially for arrays and objects.
// Wrong — mutating state directly, React won't detect the change
function addTag(newTag) {
user.tags.push(newTag); // mutates the existing array
setUser(user); // React sees the same object reference, no re-render
}
// Right — create a new array, React sees a new reference
function addTag(newTag) {
setUser(prev => ({
...prev,
tags: [...prev.tags, newTag], // new array, new object — React re-renders
}));
}
If your state appears to update but the UI does not change, direct mutation is often the cause. The spread operator (...prev) is your fix. This also connects to understanding how objects work in JavaScript — state comparison is by reference, not by value.
Mistake 6: Reaching for Redux on small apps
When you prompt for a "real app" or a "production dashboard," AI models often generate Redux because that's what production dashboard training data uses. Your AI will get this wrong sometimes — here's how to spot it: if you see configureStore, createSlice, and a Provider wrapping your app before you've even built a second screen, push back.
// AI-generated Redux for a 2-screen app — probably overkill
import { configureStore, createSlice } from '@reduxjs/toolkit';
const userSlice = createSlice({
name: 'user',
initialState: { name: '', email: '' },
reducers: {
setUser: (state, action) => { Object.assign(state, action.payload); }
}
});
// You could have done this with 3 lines of useState and Context instead
Counter-prompt: "This seems like a lot of Redux setup for a small app. Can you rewrite the state management using useState and the Context API instead? I want to keep dependencies minimal."
AI Review Checklist for State
After receiving state management code from AI: (1) Check that UI-only state (toggles, modals, form fields) is local. (2) Check that shared data uses Context or a store, not a 3-layer prop chain. (3) Look for any .push(), .splice(), or direct property assignment on state. (4) Check every setter in a timer or interval for stale closure risk. (5) Ask if Redux is actually needed.
How to Debug State Issues with AI
State bugs are some of the hardest to describe, because the symptom ("the count is wrong") is far from the cause (a stale closure three renders ago). Here is how to work through them with the tools you have.
React DevTools first
Install the React DevTools browser extension before you try anything else. It adds a Components panel where you can click any component and see its current state and props. Watch the state values as you interact with the app — you will often see exactly where the breakdown happens. This is non-negotiable for debugging state.
If you see this error: "Cannot read properties of undefined"
In plain English: you tried to access a property on something that has not been set up yet. In state terms, this usually means your component rendered before the async data arrived. The fix is to add a loading state:
// AI might generate this — crashes if user hasn't loaded yet
return <h1>{user.name}</h1>;
// Fix — guard against undefined state
if (!user) return <p>Loading...</p>;
return <h1>{user.name}</h1>;
If you see this error: "Too many re-renders"
In plain English: something is updating state inside the render cycle itself, causing an infinite loop. Common cause: calling a setter directly in the component body instead of inside an event handler or useEffect.
// Wrong — sets state during render, triggers re-render, sets state again...
function Dashboard() {
const [count, setCount] = useState(0);
setCount(count + 1); // called on every render = infinite loop
}
// Right — only update state in response to an event
function Dashboard() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}
Cursor tips for state bugs
- Highlight the component that has wrong state and use inline chat: "Walk me through every place this state can be updated and whether any of them could produce [the wrong behavior I'm seeing]."
- Ask Cursor to add a
console.logat the top of any component that's re-rendering unexpectedly: "Why is this component re-rendering? Log the state and props on every render so I can see what's triggering it." - For Context issues: "Is there a parent re-render that's causing all Context consumers to update? Show me where to memoize."
Windsurf tips for state bugs
- Windsurf's Cascade understands multi-file state flow well. Give it a description of the flow: "The
userobject is set inAppProvider, read inSidebar, and the badge count isn't updating when I callmarkAllRead. Trace the problem." - For prop drilling issues: "Find every component that receives this prop and doesn't use it directly. Refactor to use Context."
Claude Code tips for state bugs
- Claude Code is strong at explaining the why behind state bugs, not just patching them. Paste the component code plus the exact symptom: "The count updates once and then stops. Here's the component — explain what's happening and why." You will get a proper explanation, not just a band-aid fix.
- For stale closure bugs specifically: "Is there a stale closure risk in this component? Look at every function defined inside a useEffect and check whether it closes over any state variables."
- If global state feels over-engineered: "Review this state management setup. Is there anything in the Context store that should be local component state instead? I want the simplest architecture that works."
What to Learn Next
State management builds on several foundational concepts. If anything in this guide felt fuzzy, these are the articles to read next:
- What Is React? — components, props, hooks, and the rendering model state management sits on top of.
- What Is a Variable? — state is just a special kind of variable; understanding the basics helps the distinction land.
- What Are Closures? — stale closures are the most common advanced state bug; this explains how they work.
- What Is a Function? — React components and reducers are all functions; this builds the mental model.
- What Is a Class? — older React code used class-based state; useful for reading legacy code AI might reference.
- What Is JSON? — most state in real apps comes from an API as JSON; understanding the format helps you work with it in state.
- Build a To-Do App with AI — put state management into practice by building a real project that lives and dies by state.
- Build a Dashboard with AI — complex state in action: multiple components sharing data, exactly the scenario described above.
- How to Debug AI-Generated Code — when your AI gets state logic wrong, this guide walks you through finding and fixing the problem.
Next Step
Take the dashboard example above and break it intentionally: move one piece of global state into a component and watch what breaks when another component needs it. Then fix it. There is no faster way to understand why state placement matters than breaking it on purpose.
FAQ
State management is how your app keeps track of data that changes over time — like what a user has typed, whether a menu is open, or which items are in a cart. In React, state lives in components and updates cause the UI to re-render automatically. "Managing" state means deciding where that data lives and how it flows to the parts of your app that need it.
Use useState for simple, independent values — a toggle, an input field, a count. Use useReducer when you have multiple related pieces of state that change together, or when the next state depends on the previous state in complex ways. If you find yourself writing several useState calls that always change at the same time, that's a signal to consolidate with useReducer.
Local state lives inside one component and is only relevant there — like whether a dropdown is open. Global state is data needed by many unrelated components across your app — like the logged-in user's name or a shopping cart. You manage local state with useState. You manage global state with Context, Redux, Zustand, or another library.
Probably not. Redux is powerful but adds significant complexity — actions, reducers, a store, middleware, and a lot of boilerplate. Most apps built with AI tools are well-served by useState for local state and the Context API or Zustand for global state. Use Redux only if your team already knows it, the app has extremely complex state interactions, or you need its powerful DevTools for debugging.
Prop drilling is when you pass data down through multiple layers of components just to get it to a deeply nested child that actually needs it. The middle components do not use the data — they just pass it along. It works but makes code messy and fragile. The fix is to use the Context API so the child can access the data directly, without any middlemen.