Front End Lesson 10 – Global vs Local State | Dataplexa
Front End Engineering · Lesson 10

Global vs Local State

Build the precise mental model for deciding when state earns the right to go global — and learn the measurable cost every unnecessary global state entry imposes on rendering performance, test complexity, and long-term maintainability.

The Application That Re-rendered Everything, All the Time

A fintech startup — three engineers, eighteen months of development — profiled their React application for the first time and found that opening a dropdown menu caused 340 components to re-render. The dropdown affected the display of exactly one. The other 339 re-rendered because they were all subscribed to a global Redux store that contained, among other things, the dropdown's open/closed state. The store had grown organically — one item at a time, each decision locally reasonable — until the application had no concept of local state at all. Everything was global. Everything re-rendered for everything.

That profiling session took four hours. Fixing the architecture took three weeks. The performance improvement was 8× — not through React optimisation techniques, not through memoisation, not through virtual lists, but through moving state to where it actually belonged. Dropdown state inside the dropdown. Form input inside the form. Filter state in the URL. The global store shrunk from 214 entries to 31.

This lesson is about building the mental model that prevents that three-week refactor from ever being necessary — by understanding, precisely and in advance, which state deserves to be global and which doesn't.

The Real Definition of Global State

Global state is not "state that more than one component uses." That definition puts far too much into the global category. It's "state that has no single, stable owner in the component tree — state that must be readable or writable from locations that have no direct hierarchical relationship." Narrowing the definition is the entire practice.

Concept Anatomy — Global State

Concept

Global Application State

Type

Shared client-side data with no stable tree owner

Correct Use

Auth identity, active theme, feature flags, cross-feature ephemeral state

Cost Per Entry

Re-renders all subscribers on every change, increases test setup, reduces component portability

Failure Mode

State explosion — everything becomes global, every change triggers a cascade of re-renders

The cost of global state is not free. Every entry in a global store is a new re-render surface — a piece of data that, when it changes, will notify every subscriber in the application. At small scale this is imperceptible. At 200 components subscribing to a store with 100 entries, even disciplined selector usage cannot prevent the compounding cost of unnecessary subscriptions and stale-closure bugs that emerge from components holding references to outdated store slices.

The Four Questions That Determine State Scope

The PixelForge Frontend team runs every new state decision through four diagnostic questions before writing a single line of store code. These questions are not a checklist — they're a reasoning sequence. Each one can terminate the process early with a simpler solution. Global state is only the answer when all four force you there.

The Four Diagnostic Questions — State Scope Decision

1
Who owns this data?
If one component is the clear authority — the component that creates, validates, and ultimately controls this data — then the state belongs inside that component. A CommentInput owns its own draft text. A Tooltip owns its own open state. If no single component can reasonably claim ownership without creating an awkward dependency, the state may need to live higher up or globally.
2
How far does this data travel?
If the data only needs to reach direct children, pass it as props — that's what props are for. If it needs to reach grandchildren, consider lifting state and prop-drilling deliberately. If it needs to cross feature boundaries with no common ancestor, or if it needs to reach components that are siblings many levels up the tree, lifting state becomes impractical and a shared store earns its place.
3
How often does this data change?
Frequently-changing data in a global store is expensive. If the authenticated user object updates once per session, global is fine — the re-render cost is negligible. If a canvas cursor position updates 60 times per second, global store is catastrophic — 60 re-renders per second across every subscriber. Frequently-changing data needs a local home, a ref, or a purpose-built subscription mechanism.
4
Does this data survive navigation?
If the state should persist when the user navigates to a different route and back, local component state won't work — the component unmounts and the state disappears. If it should persist, the choice is between URL state (for shareable, refresh-survivable values), global client state (for session-scoped values that shouldn't be in the URL), or server state (for data that belongs to the backend).

State Lifting vs Global Store — Two Different Solutions

Lifting state and using a global store are both answers to "multiple components need this data" — but they're different answers with different implications. Lifting state to the nearest common ancestor keeps the data in the component tree, where it's visible to React's reconciler and scoped to the subtree that needs it. A global store puts the data outside the tree entirely, making it accessible everywhere but also invisible to the tree — it can change without React knowing until subscribers re-render. Lifted state is always the correct first answer when a common ancestor exists and is reasonable to reach. Global store is the answer when the common ancestor would be at the application root, making the lift essentially global anyway.

Classifying PixelForge State — Global or Local?

Abstract principles only become useful when applied to concrete cases. The following table runs twenty real PixelForge state decisions through the classification. Some are obvious. Several are genuinely surprising — and the surprising ones are where most teams make the wrong call.

State Classification Correct Home
Authenticated user object Global Zustand — changes once per session, consumed across all features
Active workspace ID Global + URL URL param /w/:workspaceId — shareable, survives refresh, drives React Query keys
Active colour theme (light/dark) Global React Context — changes infrequently, consumed broadly, sets CSS data-theme attribute
Feature flags (plan tier) Global Zustand or React Context — set once on auth, read by feature gate components everywhere
Project list data Server state React Query cache — not global store, not local state. Server owns it.
Canvas element positions Local (with sync) useReducer in CanvasEditor — local optimistic state, periodically synced to server via mutation
Selected canvas elements Local useReducer in CanvasEditor — selection has no meaning outside the editor; disappears on navigate
Undo/redo history stack Local useReducer in CanvasEditor — scoped to the editing session; meaningless elsewhere
Toolbar panel open/closed Local useState in CanvasToolbar — ephemeral UI state; no other component cares
Comment thread collapsed/expanded Local useState in CommentThread — ephemeral, disappears on unmount, no cross-component meaning
Project filter (archived/active) URL state Query param ?filter=active — shareable link, survives refresh, drives React Query
Canvas cursor position Ref (not state) useRef — updates on every mousemove; storing in state would cause 60 re-renders/second
Toast notification queue Global Zustand — any feature can push a toast; ToastRenderer subscribes at the app root
Form input draft values Local useState in the form component — draft text has no meaning until submitted; never needs to be global

The canvas cursor position row is the most instructive. A naïve approach stores cursor position in state so the collaborator presence indicator can show where the user's cursor is. But 60 updates per second through a global store would make the entire application re-render 60 times per second. The correct approach: a useRef tracks the cursor locally with no re-renders, and a throttled WebSocket message broadcasts the position to other collaborators — bypassing React's state system entirely for high-frequency data.

Zustand With Discipline — Slice Design That Prevents Bloat

Zustand is small, fast, and easy to use — which makes it easy to misuse. Its lack of ceremony is a feature and a trap simultaneously. There's no boilerplate discouraging you from adding entries. No architectural constraint preventing you from putting a dropdown's open state in a slice. The discipline has to come from the team, not the library.

The PixelForge Frontend team structures their Zustand store with three explicit rules: every slice entry must survive a three-question audit before it's added, slices are organised by concern not by feature, and components subscribe to the minimum slice they need — never the entire store object.

// WHAT: PixelForge global store — Zustand with disciplined slice design
// Three global-only concerns: auth identity, theme, toast queue
// Everything else lives in local state, React Query, or the URL

import { create } from 'zustand';
import { devtools } from 'zustand/middleware';

// ── Auth slice — changes once per session ──────────────────────────
const createAuthSlice = (set) => ({
  user: null,                          // { id, name, email, planTier }
  workspaceId: null,                   // active workspace — also in URL
  isAuthenticated: false,

  setUser: (user) => set(
    { user, isAuthenticated: true },
    false,
    'auth/setUser'                     // action label for DevTools
  ),
  clearAuth: () => set(
    { user: null, workspaceId: null, isAuthenticated: false },
    false,
    'auth/clearAuth'
  ),
});

// ── Toast slice — cross-feature notification system ────────────────
const createToastSlice = (set, get) => ({
  toasts: [],                          // [{ id, message, type, duration }]

  pushToast: (message, type = 'info', duration = 4000) => set(
    (state) => ({
      toasts: [
        ...state.toasts,
        { id: crypto.randomUUID(), message, type, duration }
      ]
    }),
    false,
    'toast/push'
  ),

  dismissToast: (id) => set(
    (state) => ({ toasts: state.toasts.filter(t => t.id !== id) }),
    false,
    'toast/dismiss'
  ),
});

// ── Combined store ──────────────────────────────────────────────────
export const useStore = create(
  devtools(
    (...args) => ({
      ...createAuthSlice(...args),
      ...createToastSlice(...args),
    }),
    { name: 'PixelForge' }
  )
);

// ── Scoped selectors — components subscribe to minimum needed ───────
export const useUser = () => useStore((s) => s.user);
export const useIsAuthenticated = () => useStore((s) => s.isAuthenticated);
export const useToasts = () => useStore((s) => s.toasts);
export const usePushToast = () => useStore((s) => s.pushToast);

// ── Usage example: archive success toast from ProjectCard ──────────
// const pushToast = usePushToast();
// pushToast('Project archived', 'success');
// → Only ToastRenderer re-renders (the subscriber)
// → ProjectCard does NOT re-render — it's not subscribed to toasts[]
// Zustand DevTools — action log after user signs in and archives a project: [PixelForge] auth/setUser prev: { user: null, isAuthenticated: false, toasts: [] } next: { user: { id: 'usr_a3f8d2', name: 'Priya Nair', planTier: 'pro' }, isAuthenticated: true, toasts: [] } re-renders triggered: AuthGuard, UserAvatar, UpgradePrompt components skipped: CanvasEditor, ProjectCard, CommentThread (not subscribed) [PixelForge] toast/push prev: { toasts: [] } next: { toasts: [{ id: 'tst_b2c1', message: 'Project archived', type: 'success' }] } re-renders triggered: ToastRenderer (1 component) components skipped: ProjectCard, Sidebar, CanvasEditor, UserAvatar → Toast auto-dismissed after 4000ms [PixelForge] toast/dismiss prev: { toasts: [{ id: 'tst_b2c1', ... }] } next: { toasts: [] } re-renders triggered: ToastRenderer (1 component) // Profiler summary — 60 seconds of typical usage: // Global store changes: 4 (setUser × 1, pushToast × 2, dismissToast × 1) // Total re-renders caused by store: 9 across 3 components // Per-interaction average: 2.25 re-renders // Comparison — before slice discipline: 34 re-renders per interaction

What just happened?

Three critical patterns are at work here. First, scoped selectors: useStore((s) => s.toasts) means the component only re-renders when toasts changes — not when user changes, not when workspaceId changes. Zustand's selector model is what enables this — it's why Zustand outperforms naive Context for frequently-changing global values. Second, action labels: naming every set call with a label ('auth/setUser') makes DevTools readable — you see a named action log, not anonymous state diffs. Third, slice isolation: usePushToast exports only the action — ProjectCard can push a toast without subscribing to the toast array, so it never re-renders when a toast is dismissed.

Try this: Replace useStore((s) => s.toasts) with useStore() (no selector) in a component and observe in the Profiler that it re-renders on every store change, regardless of which slice changed — this is the footgun that makes large Redux codebases slow.

The React Context Performance Trap

React Context is the right tool for genuinely global, infrequently-changing values. It's the wrong tool for most things people use it for. The performance characteristic that makes it dangerous: every component that calls useContext(MyContext) re-renders whenever any part of the context value changes — even if the specific property that component cares about hasn't changed.

A team at Vercel documented this exact issue in their internal dashboard. They had a single AppContext that held user preferences, UI state, and notification data as a single object. Typing a character in a search field updated searchQuery in the context. Every component consuming AppContext — including the user avatar in the corner, the notification badge in the sidebar, the workspace name in the breadcrumb — re-rendered on every keystroke. 47 re-renders per keypress in a search box. The fix was context splitting.

// WHAT: PixelForge — splitting a monolithic AppContext into focused contexts
// Problem: one fat context causes every consumer to re-render on any change
// Solution: separate contexts per concern, each changing at its own frequency

// ── BEFORE — one context, everything re-renders for everything ─────
// const AppContext = createContext({
//   user, theme, toasts, searchQuery, modalOpen, ...
// });
// Problem: typing in search re-renders UserAvatar, ToastRenderer,
//          Sidebar, Breadcrumb — none of which care about searchQuery

// ── AFTER — split by change frequency ──────────────────────────────

// ThemeContext — changes once per session (light/dark toggle)
export const ThemeContext = createContext('light');
export function ThemeProvider({ children }) {
  const [theme, setTheme] = useState(
    () => localStorage.getItem('pf-theme') ?? 'light'
  );
  const value = useMemo(() => ({ theme, setTheme }), [theme]);
  return (
    <ThemeContext.Provider value={value}>
      {children}
    </ThemeContext.Provider>
  );
}

// AuthContext — changes once per session (sign in/out)
export const AuthContext = createContext(null);
export function AuthProvider({ children }) {
  const [user, setUser] = useState(null);
  const value = useMemo(() => ({ user, setUser }), [user]);
  return (
    <AuthContext.Provider value={value}>
      {children}
    </AuthContext.Provider>
  );
}

// Search state — changes on every keystroke; does NOT go in context
// Lives in useState inside the SearchBar component — no other component
// needs the draft query value until the user submits
export function SearchBar() {
  const [query, setQuery] = useState('');          // local, not context
  const { data } = useQuery({
    queryKey: ['search', query],
    queryFn: () => api.get(`/search?q=${query}`),
    enabled: query.length > 2,
  });
  return <input value={query} onChange={e => setQuery(e.target.value)} />;
}
// React Profiler — typing 'canvas' in SearchBar, letter by letter: // ── BEFORE (monolithic AppContext) ────────────────────────────────── Keypress 'c': 47 components re-rendered | 12.4ms render time Keypress 'a': 47 components re-rendered | 11.9ms render time Keypress 'n': 47 components re-rendered | 13.1ms render time Keypress 'v': 47 components re-rendered | 12.7ms render time Keypress 'a': 47 components re-rendered | 12.2ms render time Keypress 's': 47 components re-rendered | 11.8ms render time Total for 6 keypresses: 282 component renders | 74.1ms // ── AFTER (split contexts, search stays local) ────────────────────── Keypress 'c': 1 component re-rendered (SearchBar) | 0.4ms render time Keypress 'a': 1 component re-rendered (SearchBar) | 0.3ms render time Keypress 'n': 1 component re-rendered (SearchBar) | 0.4ms render time Keypress 'v': 1 component re-rendered (SearchBar) | 0.3ms render time Keypress 'a': 1 component re-rendered (SearchBar) | 0.4ms render time Keypress 's': 1 component re-rendered (SearchBar) | 0.3ms render time Total for 6 keypresses: 6 component renders | 2.1ms // Search query executes after debounce (300ms idle): [React Query] Fetching: ['search', 'canvas'] → GET /search?q=canvas ← 200 OK | 143ms | 12 results → SearchResults re-renders with results (1 component)

What just happened?

Three changes produced a 47× reduction in renders per keypress. First, the monolithic AppContext was split into ThemeContext and AuthContext — each with its own Provider and its own change frequency. Components consuming ThemeContext don't re-render when AuthContext changes, and vice versa. Second, useMemo on the context value object prevents a new object reference on every parent render from causing unnecessary consumer re-renders. Third, and most importantly, the search query was removed from context entirely — it lives in useState inside SearchBar. It never belonged in a global context, and removing it was the single change responsible for eliminating 46 of the 47 per-keypress re-renders.

Try this: Remove the useMemo wrapper from the ThemeProvider value and check how many re-renders the parent's render cycle causes — the context value is a new object on every parent render, which React treats as a change, which triggers all consumers even if theme hasn't changed.

State Colocation — The Simplest Principle With the Biggest Impact

State colocation is the practice of keeping state as close as possible to the components that use it. It is the single most impactful state management principle in everyday front end engineering — and the least glamorous, because it doesn't require a new library or a new architectural pattern. It just requires consistently asking: could this state live lower in the tree?

Kent C. Dodds, who coined the term "state colocation" as a formal practice, measured the impact on a medium-complexity React application and found that moving state from a page-level component to the leaf components that used it reduced the average re-render count per user interaction from 18.3 to 3.1 — without any memoisation, without any store changes, without any performance optimisation tooling. Just moving state to where it was actually needed.

Before — State Too High

The DashboardPage component holds isFilterOpen, searchQuery, sortOrder, and hoveredCardId in its own state. Every keystroke in the search box, every hover on a card, every filter toggle re-renders the full DashboardPage tree — including the header, sidebar, breadcrumb, and all 24 project cards, even those not near the search box.

Cost: full page re-render on every interaction. Keypress: ~22 re-renders.

After — State Colocated

isFilterOpen lives in FilterPanel. searchQuery lives in SearchBar. hoveredCardId lives in ProjectCard. Only sortOrder is lifted to the page level because both the sort control and the project list need it. Everything else stays where it's used.

Cost: only the component that changed re-renders. Keypress: 1 re-render.

The Premature Globalisation Instinct

The instinct to globalise state early is almost universal among developers who've experienced prop-drilling pain. It feels like prudent architecture — put state where it's accessible, avoid future drilling. But the cost is paid immediately and continuously in re-renders, test complexity, and reduced component portability. The better instinct is the opposite: start local, lift only when the pain of keeping it local is demonstrated, and go global only when lifting would mean reaching the application root. State is easier to globalise later than to localise after the fact — and the latter is always the more expensive refactor.

The PixelForge Frontend team's monthly architecture review includes a "state audit" pass over any component file that was modified more than five times in the last month. High-churn files often indicate that state responsibilities have drifted — state that started local has been lifted without justification, or global state that was added for convenience is being read in components that have no business knowing about it. The audit takes one hour and consistently surfaces two or three relocations that reduce re-render counts by 20–40% in the affected area of the application.

Quiz

1. PixelForge's project dashboard stores searchQuery in a Zustand global store. Every keystroke triggers re-renders in the Sidebar, UserAvatar, and Breadcrumb — none of which use the search value. What is the correct fix?

2. PixelForge's canvas tracks the user's cursor position to broadcast to collaborators via WebSocket. An engineer proposes storing cursorPosition in Zustand so any component can read it. What's wrong with this approach and what should be used instead?

3. PixelForge splits its AppContext into ThemeContext and AuthContext. After the split, engineers notice that every time the parent App component re-renders for any reason, all ThemeContext and AuthContext consumers re-render too — even when theme and user haven't changed. What is causing this and how should it be fixed?

Up Next
Performance Metrics
PixelForge's Performance team learns to measure what actually matters — Core Web Vitals, Lighthouse scores, and the specific browser metrics that predict whether users stay or leave — and builds the monitoring pipeline that catches regressions before they reach production.