Back to Journal
Building

Zustand vs React Context in React Native: When Each One Wins

2026-04-029 min read

I installed Zustand on day one of building Ocean Drop. It's sitting in my package.json right now — version 5.0.12. I never imported it once.

Not because Zustand is bad. It's excellent. But the architecture I actually needed didn't call for it. And understanding why is more useful than any comparison table.

Most state management articles are written before the app exists. This one is written after shipping a production wellness app with 20+ screens, real-time partner sync, AI chat, gamification, and cycle-based daily plans — all managed through React Context and custom hooks. I'll tell you where that worked, where it strained, and where Zustand would have been the better call.

What Does "State" Actually Mean in a React Native App?

Before choosing a tool, define the problem. In Ocean Drop, state falls into four distinct categories, and each one has different requirements:

Server state — cycle data, calendar events, period logs, daily plans. This lives in Supabase and syncs via real-time subscriptions. The client holds a cache, but the database is the source of truth.

Auth state — session tokens, user profiles, partner connections. Changes rarely, flows top-down, every screen needs it.

Derived state — the current theme (colors shift based on cycle phase and user role), today's action list, whether a notification has fired. Computed from server + auth state, not stored independently.

UI state — which modal is open, which tab is active, animation progress. Ephemeral, screen-local, never persisted.

The first architectural decision: most of my state is server-driven. Supabase real-time channels already handle synchronization. Adding a client-side store on top of that would mean managing two sources of truth for the same data. That's not simplification — it's overhead.

How React Context Works in Practice

Ocean Drop uses three providers, nested intentionally:

<AuthProvider>
  <CycleProvider>
    <ThemeProvider>
      {/* 20+ screens, all with access to all three layers */}
    </ThemeProvider>
  </CycleProvider>
</AuthProvider>

The nesting isn't arbitrary. CycleProvider depends on AuthProvider (it needs the user ID to fetch cycle data). ThemeProvider depends on both (theme changes based on cycle phase and user role). This hierarchy mirrors the data dependency graph exactly.

AuthProvider handles session management with retry logic for race conditions during signup:

const loadProfile = useCallback(async (userId: string) => {
  let retries = 3;
  while (retries > 0) {
    const { data } = await supabase
      .from('profiles')
      .select('*')
      .eq('id', userId)
      .single();
    if (data) { setProfile(data); return; }
    retries--;
    await new Promise(r => setTimeout(r, 500));
  }
}, []);

Three retries with 500ms delay. Supabase auth creates the user before the database trigger creates the profile row. Without this backoff, first-time signups would see a blank screen. This is a production lesson, not a textbook pattern.

CycleProvider is the largest at 650+ lines. It manages cycle state, daily plans, events, period logs, achievements, and partner data sync — all backed by Supabase real-time subscriptions:

const eventsChannel = supabase
  .channel('calendar-events')
  .on('postgres_changes', {
    event: '*',
    schema: 'public',
    table: 'calendar_events',
    filter: `user_id=eq.${user.id}`,
  }, () => refreshEvents())
  .subscribe();

When a partner adds a calendar event, both users see it appear within seconds. The subscription handles the sync. The Context holds the current snapshot. No polling, no manual invalidation.

ThemeProvider derives everything — zero stored state of its own:

const theme = useMemo(() =>
  computeTheme(cycle.tidalSeason, profile?.user_role),
  [cycle.tidalSeason, profile?.user_role]
);

Two inputs, pure computation, wrapped in useMemo. This is the kind of state that doesn't belong in any store because it's not state — it's a function of state.

Where Context Gets Uncomfortable

Honesty matters more than advocacy. Here's where the Context approach strained:

The 650-line provider. CycleProvider grew because it owns too many concerns — cycle computation, daily plans, achievements, partner sync, event management. In a Zustand architecture, these would be separate stores with clear boundaries. In Context, splitting them means either nested providers (more nesting) or a custom composition pattern.

Re-render pressure. When dailyPlan updates, every component consuming useCycle() re-renders — even components that only care about events. Zustand's selector pattern (useStore(state => state.events)) solves this at the API level. In Context, you solve it with useMemo on the consumer side or by splitting into multiple contexts. Both work. Neither is as clean.

Concurrent request prevention required manual refs:

const isGeneratingPlanRef = useRef(false);

const refreshDailyPlan = useCallback(async () => {
  if (isGeneratingPlanRef.current) return;
  isGeneratingPlanRef.current = true;
  try {
    // Edge function call to generate today's plan
    await supabase.functions.invoke('generate-daily-plan', { ... });
  } finally {
    isGeneratingPlanRef.current = false;
  }
}, [/* deps */]);

React effects re-fire when dependencies change. When partner data loads asynchronously, it changes the callback identity, which can trigger duplicate edge function calls. The ref guard is the fix, but it's the kind of defensive code you write because the abstraction doesn't prevent the problem. A Zustand store with middleware could handle this more declaratively.

Custom Hooks as the State Interface

The pattern that made Context scale: custom hooks that compose multiple providers into screen-specific state:

export function useActionsData() {
  const { profile, user } = useAuth();
  const cycle = useCycle();

  const plan = cycle.dailyPlan?.plan_json;
  const planCompleted = cycle.dailyPlan?.actions_completed ?? [];
  const completedActions = plan ? planCompleted : localCompleted;

  const markActionCompleted = useCallback((actionId: string) => {
    if (plan) {
      cycle.markActionCompleted(actionId); // Server-backed
    } else {
      setLocalCompleted(prev => {
        const updated = prev.includes(actionId)
          ? prev.filter(id => id !== actionId)
          : [...prev, actionId];
        AsyncStorage.setItem(todayKey, JSON.stringify(updated)).catch(() => {});
        return updated;
      });
    }
  }, [plan, cycle, todayKey, user]);

  return { actions, completedActions, markActionCompleted, /* ... */ };
}

This hook does something interesting: it implements dual persistence. When an AI-generated plan exists, completions write to Supabase. When there's no plan (offline, or the edge function hasn't run yet), completions fall back to AsyncStorage. The screen that consumes this hook doesn't know or care which persistence layer is active.

This is the real power of the hooks pattern. Each screen gets a curated API — exactly the data it needs, exactly the mutations it can perform. The complexity lives in the hook. The screen stays simple.

Ocean Drop has four of these data hooks: useHomeData, useActionsData, useChatData, and useInsightsData. Each one composes two or three providers into a focused interface. It's not as elegant as Zustand selectors, but it's explicit and easy to trace.

When You Should Choose Zustand Instead

Context was the right call for Ocean Drop. It won't be the right call for every app. Here's when Zustand wins clearly:

Cross-cutting state that doesn't nest hierarchically. If a sidebar, a modal, and a toast notification all need to read and write the same state, and none of them are parent-child, Zustand's flat store model is cleaner than threading Context through the tree.

High-frequency updates. If you're building a drawing app, a real-time dashboard, or anything where state changes 30+ times per second, Context's re-render behavior becomes a performance problem. Zustand's subscription model with selectors avoids unnecessary renders by default.

Multiple independent state domains. Zustand makes it trivial to create five small stores — useCartStore, useUIStore, useFilterStore — without nesting providers or worrying about dependency order. If your state has no hierarchy, a flat model fits better.

Middleware needs. Persist to AsyncStorage, log every state change, undo/redo — Zustand's middleware system handles these with composable functions. In Context, you build these patterns from scratch.

Team scale. On a team of five, Context's implicit rules ("always wrap this in useMemo," "never destructure the full context") create tribal knowledge. Zustand's API is explicit enough that the patterns are self-documenting.

When Context Wins

Server-driven state with real-time sync. If your source of truth lives in a database with change subscriptions (Supabase, Firebase, Convex), adding a client store creates a synchronization problem. Context as a thin cache layer is simpler.

Hierarchical dependencies. When state B depends on state A (theme depends on cycle phase depends on auth), nested providers express that dependency naturally. Zustand stores would need to subscribe to each other, which adds coupling.

Small apps or features. If you have two or three contexts and ten screens, Context is zero-dependency, zero-config, and built into React. Adding Zustand for this is adding a tool you don't need.

Derived state. If most of your "state" is computed from other state, useMemo inside a Context provider is the natural pattern. Zustand can do this too (with get() or subscriptions), but it's not the primitive the library was designed around.

The Architecture Decision Nobody Writes About

The real lesson isn't "use Zustand" or "use Context." It's that the state management decision should come after you know where your data lives.

I see developers install Zustand in the first hour of a project — before they've decided on a backend, before they know whether their app is read-heavy or write-heavy, before they understand their data dependencies. The state layer should be the last architectural choice, not the first.

For Ocean Drop, the decision tree was:

  1. Where does the data live? → Supabase (server)
  2. How does the client learn about changes? → Real-time subscriptions
  3. Do state domains depend on each other? → Yes, hierarchically
  4. How many screens consume shared state? → All of them, through the provider tree
  5. Is there high-frequency UI state? → No (cycle data changes daily, not per-frame)

Every answer pointed to Context. A different app with different answers would point to Zustand — or Jotai, or Redux Toolkit, or a custom solution.

The prompts I use when building define this decision tree. One prompt takes your app context — data model, real-time needs, multi-user features, platform targets — and outputs the full architecture, including which state management approach fits. It's the kind of analysis that takes an experienced developer an afternoon and an AI architect thirty seconds, if the prompt is specific enough.


Want the Full Workflow?

This is one piece of the system I used to ship Ocean Drop in 7 days.

The full playbook has the exact prompts, the pre-build checklist, the security audit workflow — all personalized for your stack via a single Day 0 prompt.

Get the Playbook — $17 →

Transmissions from the workshop

Code, consciousness, and the craft of building with soul. No spam, no filler — just signal.