Managing State & Data Flow
Stage 3 · Architecture & System Design · B.U.I.L.D. letter: I
You can vibe-code a beautiful UI in an afternoon. Then you add one feature and the cart total is wrong, the button is disabled when it should be enabled, and refreshing the page "fixes" it — until it doesn't. That's not a CSS problem. That's a state problem. Fix how you think about state and half your bugs evaporate before you write them.
⚠️ The vibe trap
The most common vibe-coding state bug looks like this: you store isLoggedIn in a React component, again in localStorage, again in a cookie, and maybe once more in a URL param. All four copies start equal — and then they drift. A user logs out, two copies update, two don't, and your app confidently shows them their private dashboard while telling them they're logged out. Mutating a shared object makes this worse: one function changes user.cart directly, another function reads it a frame later and sees a half-edited ghost. Duplicated state and mutation are the two roots of almost every mysterious UI bug.
🧠 What "state" actually is
State is any value that can change over time and affects what your app displays or does. The number of items in a cart is state. Whether a modal is open is state. The current user's name is state. The color #ff0000 hardcoded in your CSS is not state — it never changes at runtime.
// NOT state — this never changes while the app runs
const BRAND_COLOR = "#1a73e8";
// State — this changes when the user interacts
let cartItemCount = 0;
// State that lives inside a React component
const [isModalOpen, setIsModalOpen] = useState(false);
Mental model: Think of state as the whiteboard your app reads before drawing anything on screen. If the whiteboard is messy or has contradictions, the drawing will be wrong.
Why it matters: Every render, every API call, every conditional is driven by state. Wrong state = wrong app, full stop.
Common mistake: Treating every variable as state. If a value never changes, it's a constant. If a value is always computable from other state, it's derived (see next section). Only truly independent, changing values belong in state.
🔁 Derived state — don't store what you can compute
If you can calculate a value from existing state, storing it separately is a trap. Now you have two sources of truth that can contradict each other.
// BAD: storing derived state separately
const [cartItems, setCartItems] = useState([]);
const [cartTotal, setCartTotal] = useState(0); // ← derived! don't store this
function addItem(item) {
const newItems = [...cartItems, item];
setCartItems(newItems);
setCartTotal(newItems.reduce((sum, i) => sum + i.price, 0)); // easy to forget
}
// GOOD: compute it on the fly — always in sync, zero extra storage
const [cartItems, setCartItems] = useState([]);
const cartTotal = cartItems.reduce((sum, i) => sum + i.price, 0); // always correct
Mental model: Derived values are like a spreadsheet formula. You don't type the answer into the cell — you write =SUM(A1:A10) and let it stay current automatically.
Why it matters: Every piece of stored derived state is a future sync bug waiting to happen. cartTotal out of sync with cartItems is a support ticket. Compute it and it's impossible to get wrong.
Common mistake: Adding isCartEmpty, hasDiscount, and membershipTier as separate state fields when each is directly computable from cartItems and user. You now have five values that must be kept in lock-step manually.
➡️ One-directional data flow
Tangled two-way bindings — where A changes B which changes A — are the architectural version of circular logic. Data should flow in one direction: an action fires, state updates, the UI re-renders to reflect the new state.
User Action
│
▼
Dispatch / Event Handler
│
▼
State Update (pure function, no side-effects)
│
▼
UI Re-renders from new state
│
▼
(User sees result, can take next action)
A reducer makes this explicit and auditable:
// State lives here — one object, one place
const initialState = { items: [], promoApplied: false };
// Every change goes through this function — no sneaky mutations
function cartReducer(state, action) {
switch (action.type) {
case "ADD_ITEM":
return { ...state, items: [...state.items, action.payload] };
case "REMOVE_ITEM":
return { ...state, items: state.items.filter(i => i.id !== action.payload) };
case "APPLY_PROMO":
return { ...state, promoApplied: true };
default:
return state;
}
}
// Caller never touches state directly — they dispatch an action
dispatch({ type: "ADD_ITEM", payload: { id: 42, name: "T-Shirt", price: 25 } });
Mental model: A reducer is a security checkpoint. All state changes must present ID (the action type) before entering. Nothing sneaks past.
Why it matters: When you can enumerate every possible state change as a named action, debugging becomes reading a log instead of guessing. You can replay actions, write tests per action, and hand the codebase to a stranger who immediately understands every mutation path.
Common mistake: Letting event handlers reach into state objects and mutate them directly (state.items.push(newItem)). This bypasses your reducer, creates hidden side-effects, and makes React (or any reactive system) miss the update entirely.
🗂️ Where state lives — four kinds, four homes
Not all state is the same. Putting it in the wrong place is a common architectural smell.
| Kind | What it is | Right home | Wrong home |
|---|---|---|---|
| Client/UI state | Modal open? Accordion expanded? | Local component state | Global store |
| Server state | Data fetched from your API | React Query / SWR cache | Copied into useState |
| URL state | Current page, active filter, search term | URL search params | Hidden in memory |
| Global app state | Logged-in user, cart, theme | Context / Zustand / Redux | Prop-drilled 8 levels |
// URL state example — filter persists across refresh, shareable via link
const [searchParams, setSearchParams] = useSearchParams();
const category = searchParams.get("category") ?? "all";
function handleCategoryChange(cat) {
setSearchParams({ category: cat }); // state lives in the URL, not in memory
}
Mental model: Ask "who owns this value, and who needs to survive a page refresh?" The answer tells you where it lives.
Why it matters: Server state copied into useState goes stale the moment something else updates the DB. URL state stored only in memory is lost on refresh — users can't bookmark or share the page. Each kind of state has a natural home; fighting that leads to sync bugs.
Common mistake: Fetching an API response, storing it in useState, and manually invalidating it everywhere. Use a dedicated server-state library (React Query, SWR) that handles caching, background refresh, and stale-while-revalidate for you.
🔒 Immutability — why mutation causes spooky bugs
Mutating state directly breaks the contract that allows frameworks, reducers, and memoization to work. When you push() onto an array that React is already watching, React sees the same reference — it thinks nothing changed and skips the re-render.
// MUTATES — React won't re-render; reference is the same object
function addItemBad(item) {
cartItems.push(item); // same array reference
setCartItems(cartItems); // React: "nothing changed here" ← silent bug
}
// IMMUTABLE — new reference, React sees the change and re-renders
function addItemGood(item) {
setCartItems(prev => [...prev, item]); // new array, new reference
}
// Same for objects — spread to create a new one
function updateUserBad(field, value) {
user[field] = value; // mutates the original
setUser(user); // React ignores it
}
function updateUserGood(field, value) {
setUser(prev => ({ ...prev, [field]: value })); // new object
}
Mental model: Imagine state as a photograph. You don't draw on the original — you print a new copy with your change marked on it. The old photo still exists; the new one shows what changed.
Why it matters: Mutation produces "spooky action at a distance" — a value changes somewhere you didn't touch, because two parts of your code held a reference to the same object. Immutability makes that physically impossible.
Common mistake: Mutating nested objects: state.user.address.city = "Denver". Even one layer deep, this silently breaks everything. Always spread at every level, or use a utility like Immer.
🔼 Lift state up / keep it close
State should live at the lowest common ancestor of the components that need it — no higher, no lower.
If only one component reads a value, keep it local. If two sibling components need the same value, lift it to their parent. If half the app needs it, put it in a global store. Never prop-drill six levels just to avoid a context — that creates a different kind of coupling.
// Accordion open/closed — keep it LOCAL, nobody else cares
function AccordionItem({ title, children }) {
const [isOpen, setIsOpen] = useState(false); // lives here, dies here
return (
<div>
<button onClick={() => setIsOpen(o => !o)}>{title}</button>
{isOpen && <div>{children}</div>}
</div>
);
}
// Cart count shown in Header AND CartPage — lift to a shared ancestor or global store
// Don't duplicate it in both components
function App() {
const [cartItems, setCartItems] = useState([]); // lifted here
return (
<>
<Header itemCount={cartItems.length} />
<CartPage items={cartItems} onRemove={...} />
</>
);
}
Mental model: State is like a shared document. Keep it in the hands of whoever needs to edit it. If two people need to read and write it, put it somewhere both can reach — but not somewhere everyone can reach unless they all genuinely need it.
Common mistake: Putting everything in a global store "just in case." This turns your app into a hidden web of dependencies. Local state is cheaper, easier to test, and perfectly correct for local concerns.
🛠️ Your mission
Open your current project and do a state audit:
-
Find your duplicated state. Search for the same value stored in more than one place (e.g.,
isLoggedInin localStorage AND in component state). Pick one source of truth and delete the duplicates. -
Find your derived state. List every state variable. For each one, ask: "Can I compute this from other state?" If yes, delete it as stored state and compute it inline.
-
Audit your mutation points. Search for
.push(,.pop(, direct property assignment on state objects. Convert each to an immutable update pattern. -
Label each remaining state variable with its kind: UI state, server state, URL state, or global app state. Confirm it lives in the right home.
Document your findings in a short comment block at the top of your main state file. This is the kind of clarity the Code Review Rubric looks for under "State Management."
✅ You're done when…
- You can open your Code Review Rubric and check off every item under "State Management" for at least one feature in your app
- Zero state variables in your project are derived from other state — they are either computed inline or removed
- Every state mutation in your codebase uses an immutable update pattern (spread,
map,filter, or a dedicated library — no direct.push()or property assignment on state) - Each state variable has a documented home (comment or README section) explaining which of the four kinds it is and why it lives where it does
- A peer reading your reducer or state module can enumerate every possible state transition without running the code
➡️ Next: Designing for Change. Build It Right, Or Don't Build It At All. 🏛️