Skip to main content

Command Palette

Search for a command to run...

State Management Isn't a Preference. It's Architecture.

Updated
8 min read
K
Full Stack Developer with 3+ years of experience building scalable SaaS web applications using the MERN stack (MongoDB, Express.js, React.js, Node.js). Skilled in developing responsive frontends with React/Next.js and TypeScript, designing RESTful APIs, and optimizing database performance. Experienced in CI/CD pipelines, Docker, and agile practices.

Day 5 — Frontend System Design Series · 8 min read

Why "it depends" is the wrong answer — and how to reason about state in a system design interview like you've built things that broke.


Here's what happens in a lot of frontend interviews. The interviewer asks: "How would you manage state in this application?" And the candidate says: "It depends on the scale. We could use local state, or Context, or Redux, or Zustand..."

The interviewer nods. Then moves on. And the candidate doesn't get the job.

Not because they were wrong. But because they gave a menu instead of a decision. The ability to name tools isn't the same as the ability to reason about tradeoffs. And that gap is exactly what a system design round is trying to expose.

Today I want to walk through a framework for actually making the call — not just listing the options.


The real question isn't "which tool" — it's "what kind of state"

Before you pick a library, you need to categorize the state you're dealing with. Every piece of state in a frontend app belongs to one of these buckets:

Type Examples Typical Home
UI / Ephemeral Modal open, input focus, hover state useState
Shared UI Theme, locale, sidebar collapsed Context
Server cache API responses, paginated lists React Query / RTK Query
Global app state Auth user, cart, notification queue Redux / Zustand
URL / Navigation Filters, tab, selected ID Router (search params)

Most over-engineering happens when people put server cache in Redux and ephemeral UI state in Zustand. The category determines the tool — not the other way around.

The number one state management mistake isn't choosing the wrong library. It's choosing a library before you've categorized what you're managing.


Local state: it goes further than you think

useState is genuinely underrated in senior engineering circles. People reach for global stores because they've been burned by prop drilling, not because the problem actually requires it. But local state has real advantages: it's colocated with the component that owns the behavior, it gets cleaned up automatically when the component unmounts, and there's zero ceremony to using it.

The right question is: who needs to know about this? If the answer is "just this component," local state is the answer. If the answer expands to "this component and its children," consider whether you can restructure through composition before you add a store.

function FilterPanel() {
  const [isOpen, setIsOpen] = useState(false);
  const [query, setQuery] = useState('');

  // These never need to leave this component
  return (
    <div>
      <button onClick={() => setIsOpen(p => !p)}>Filters</button>
      {isOpen && (
        <input value={query} onChange={e => setQuery(e.target.value)} />
      )}
    </div>
  );
}

The moment the parent needs to know whether the filter panel is open — say, to hide a button — you promote it. But not to Redux. You lift it one level first, or move it to URL state if it affects routing.


Context: powerful, but not a state manager

Context is frequently misunderstood. It's not a state management solution — it's a dependency injection mechanism. It lets you broadcast a value to a subtree without prop drilling. That's it.

The problem is that Context re-renders every consumer when its value changes. If you store a large object in Context and update one field, every component that reads from that Context re-renders. In a small app this is fine. In a dashboard with 40+ components, it becomes a performance problem fast.

When Context makes sense: Use it for values that are read often and written rarely — authenticated user, locale, color theme, feature flags. Don't use it for frequently mutating state like form data, real-time updates, or anything with high write frequency.

A common pattern that works well: combine Context with useReducer for small-to-medium shared state trees. You get predictable state transitions without pulling in a full library.

const ThemeContext = createContext(null);

function ThemeProvider({ children }) {
  const [state, dispatch] = useReducer(themeReducer, {
    mode: 'light',
    accentColor: 'blue',
  });

  return (
    <ThemeContext.Provider value={{ state, dispatch }}>
      {children}
    </ThemeContext.Provider>
  );
}

// Consumers call dispatch({ type: 'SET_MODE', payload: 'dark' })
// — no external library needed

Redux: when and why it's still the right call

Redux has a reputation for boilerplate, and honestly it earned it — before Redux Toolkit. But the reason Redux exists is still valid: when state is complex, global, and needs to be debugged in production, you want a predictable, inspectable, serializable store with a clear update contract.

The cases where Redux earns its place:

  • Multiple features need to read and write the same state

  • You need time-travel debugging, state replay, or logging middleware

  • State transitions are complex enough to warrant reducers with explicit action types

  • The team is large and consistency across contributors matters more than flexibility

One thing I see go wrong constantly: putting server cache in Redux. Things like API responses, paginated lists, loading/error states for network calls — these belong in React Query or RTK Query, not in a hand-rolled slice. Server cache has its own invalidation, deduplication, and background refresh logic that a general-purpose store wasn't designed for.

const cartSlice = createSlice({
  name: 'cart',
  initialState: { items: [], total: 0 },
  reducers: {
    addItem: (state, action) => {
      state.items.push(action.payload);       // Immer under the hood
      state.total += action.payload.price;
    },
    clearCart: (state) => {
      state.items = [];
      state.total = 0;
    },
  },
});

export const { addItem, clearCart } = cartSlice.actions;

Zustand: the pragmatic middle ground

Zustand is what you reach for when you want global state without Redux's ceremony. It's a small library — the core is around 1KB — and it gets out of your way. The mental model is simpler: a store is just a function that returns state and updaters.

Where Zustand shines is in mid-size apps or when you're prototyping features that may or may not become permanent. The store setup takes 10 lines, it supports slices and middleware, and unlike Context, it only re-renders subscribers of the specific slice they read.

const useNotificationStore = create((set) => ({
  notifications: [],
  addNotification: (msg) =>
    set((s) => ({
      notifications: [...s.notifications, { id: Date.now(), msg }],
    })),
  dismiss: (id) =>
    set((s) => ({
      notifications: s.notifications.filter((n) => n.id !== id),
    })),
}));

// In any component — no Provider needed:
const { notifications, addNotification } = useNotificationStore();

The real difference between Redux and Zustand isn't technical capability — it's philosophy. Redux enforces structure through its action/reducer contract. Zustand trusts you to impose your own. For a solo developer or small team moving fast, Zustand's flexibility is a feature. For a team of 15 across 3 squads, it can become chaos without discipline.


The framework for interviews (and real decisions)

When someone asks you to design state management for a system, walk through these questions in order. The answer at each step will often make the next ones easy:

  1. What kind of state is this? Server cache, UI state, auth state, real-time events — categorize before you architect. Server cache almost always belongs in a data-fetching library, not your app store.

  2. Who owns it and who reads it? If only one component, keep it local. If a subtree of components, consider Context or lifted state. If app-wide, you need a store.

  3. How often does it change? High-frequency mutations (typing, real-time prices, cursor positions) are poor fits for Context. For those, prefer component state or a store with fine-grained subscriptions.

  4. Do you need a predictable audit trail? If so, Redux's action log model earns its weight. If you're just syncing UI to server state, it's likely overkill.

  5. What's the team/scale context? A startup building fast needs different defaults than a fintech platform with compliance requirements. Say this out loud in the interview — it shows architectural awareness.

💡 The interview framing that actually works: Don't open with a library. Open with: "Before picking a tool, I want to understand what kind of state we're dealing with — whether it's server cache, global app state, or ephemeral UI state, because each has a different right answer." Then walk the framework. That one sentence signals senior-level thinking immediately.


One last thing: don't mix concerns

The single biggest anti-pattern I've seen in production codebases — and in interview answers — is stores that try to do everything. You end up with a Redux slice that holds the authenticated user, the currently hovered table row, three different API responses, and a boolean for whether a modal is open. This makes the store hard to reason about, hard to test, and impossible to optimize.

The rule I try to follow: each tool should own exactly one concern.

  • Auth state → Zustand or Redux slice

  • Server responses → React Query / RTK Query

  • Theme → Context

  • Transient UI → component state

  • URL-driven filters → router (search params)

When each layer owns its lane, the whole system becomes predictable — and that predictability is what scales.

8 views