◉RORK LABJP
●GPT-6.1 SOL — Added to Rork's model menu (09-29) with Low, Medium and High effort; included on Pro and Max●SONNET 5.5 — Claude Sonnet 5.5 is selectable: strong at UI design and faster than Sonnet 5 (09-28)●SDK 58 — Beta since 09/15 with no stable date yet; running projects in Expo Go has required login since 09/03●WIDGET — A write-up traces an iOS widget that renders black only in production builds●OBSERVE — EAS Observe now records native crashes too (SDK 57.0.21 or later, 10/07)●NEW — My App Open Ad Only Showed When the App…●GPT-6.1 SOL — Added to Rork's model menu (09-29) with Low, Medium and High effort; included on Pro and Max●SONNET 5.5 — Claude Sonnet 5.5 is selectable: strong at UI design and faster than Sonnet 5 (09-28)●SDK 58 — Beta since 09/15 with no stable date yet; running projects in Expo Go has required login since 09/03●WIDGET — A write-up traces an iOS widget that renders black only in production builds●OBSERVE — EAS Observe now records native crashes too (SDK 57.0.21 or later, 10/07)●NEW — My App Open Ad Only Showed When the App…
Articles/Dev Tools
⬡ Dev Tools/2026-05-13Intermediate

Switching from Context API to Zustand v5 in Rork Apps: What Changed and Why It Worked

Context API caused cascading re-renders in a growing Rork app. Here's how migrating to Zustand v5 solved it — with practical patterns for auth state and async logic in React Native.

Rork585Zustand4state management5React Native239performance11indie dev30

When I first started building apps with Rork, I kept state simple: useContext plus useState. For a two- or three-screen app, that's perfectly fine. No over-engineering needed.

But around the eight-screen mark, things started feeling off. Updating a user's profile in one screen triggered re-renders in completely unrelated screens. Tracing through the component tree, I found the culprit: a single Context Provider wrapping everything, causing all its descendants to re-render on every state change — even when the changed value had nothing to do with those components.

I've been building and shipping indie apps since 2014, accumulating over 50 million downloads across a portfolio of apps. One pattern I've learned the hard way: architectural shortcuts that feel fine at 5 screens become painful at 15. This was one of them.

Migrating to Zustand v5 took about a week. The unnecessary re-render issue is largely gone. Here's what I learned.

Why Context API Falls Short at Scale

The classic Context-based state pattern looks like this:

// ❌ Common problem pattern
const UserContext = createContext(null);
 
export function UserProvider({ children }) {
  const [user, setUser] = useState(null);
  const [settings, setSettings] = useState({});
  const [notifications, setNotifications] = useState([]);
 
  return (
    <UserContext.Provider value={{ user, setUser, settings, setSettings, notifications, setNotifications }}>
      {children}
    </UserContext.Provider>
  );
}

The issue: whenever any of user, settings, or notifications changes, every component consuming this Context re-renders — even if it only cares about user. A new notification badge updates, and your profile screen redraws unnecessarily.

In small apps this is invisible. In larger ones, it creates that subtle "something feels sluggish" perception that's hard to pin down but real.

Why Zustand v5 Works Well with Rork Apps

Zustand (German for "state") is a lightweight global state management library for React. Version 5 (released October 2024) internally uses useSyncExternalStore, making it properly compatible with React 18's concurrent mode.

What makes it well-suited for React Native / Rork development:

  • Selective subscriptions: Components only re-render when the specific state slice they subscribed to changes
  • No Provider required: Any component can access store state without being wrapped in a Provider
  • Async logic is first-class: You can write async actions directly in the store without Redux-style middleware
  • TypeScript-friendly: Type inference works naturally

Installation and Basic Usage

npx expo install zustand

A basic Zustand v5 store:

// store/useUserStore.ts
import { create } from 'zustand';
 
type User = {
  id: string;
  name: string;
  email: string;
};
 
type UserStore = {
  user: User | null;
  isLoading: boolean;
  setUser: (user: User | null) => void;
  fetchUser: (userId: string) => Promise<void>;
};
 
export const useUserStore = create<UserStore>((set) => ({
  user: null,
  isLoading: false,
 
  setUser: (user) => set({ user }),
 
  fetchUser: async (userId) => {
    set({ isLoading: true });
    try {
      const response = await fetch(`https://api.example.com/users/${userId}`);
      const data = await response.json();
      set({ user: data, isLoading: false });
    } catch (error) {
      set({ isLoading: false });
      console.error('fetchUser failed:', error);
    }
  },
}));

Using it in a component:

// screens/ProfileScreen.tsx
import { useUserStore } from '../store/useUserStore';
 
export default function ProfileScreen() {
  // ⭐ Only subscribes to 'user' — won't re-render when isLoading changes
  const user = useUserStore((state) => state.user);
  const fetchUser = useUserStore((state) => state.fetchUser);
 
  // ...
}

The selector function (state) => state.user is the key difference from Context. This component only re-renders when user changes — not when isLoading flips. That's the core performance win.

What Changed in v5 (From v4)

If you've used Zustand v4 before, here's what to watch for:

① The set replace parameter is now explicit

// v4 style (still works but deprecated)
set({ user: null }, true);
 
// v5 recommended
set({ user: null }, { replace: true });

② subscribe now supports selectors natively

In v4, you needed the subscribeWithSelector middleware to subscribe to a specific state slice. In v5, subscribe accepts a selector directly:

// v5: selector-based subscription
const unsubscribe = useUserStore.subscribe(
  (state) => state.user?.id,
  (userId) => {
    console.log('User ID changed:', userId);
  }
);

③ Better TypeScript inference

The v4 create<UserStore>()() double-parenthesis pattern (required for middleware inference) is now handled more cleanly. Type annotations are simpler in most cases.

Practical Pattern: Auth State with Persistence

Here's the auth state pattern I use in production Rork apps:

// store/useAuthStore.ts
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import AsyncStorage from '@react-native-async-storage/async-storage';
 
type AuthState = {
  token: string | null;
  isAuthenticated: boolean;
  signIn: (token: string) => void;
  signOut: () => void;
};
 
// persist middleware keeps auth state across app restarts
export const useAuthStore = create<AuthState>()(
  persist(
    (set) => ({
      token: null,
      isAuthenticated: false,
 
      signIn: (token) => set({ token, isAuthenticated: true }),
 
      signOut: () => set({ token: null, isAuthenticated: false }),
    }),
    {
      name: 'auth-storage',
      storage: createJSONStorage(() => AsyncStorage),
    }
  )
);

The persist middleware serializes state to AsyncStorage automatically. On app restart, the store rehydrates from storage — no extra useEffect needed.

At the app root with Expo Router:

// app/_layout.tsx
import { useAuthStore } from '../store/useAuthStore';
import { Redirect } from 'expo-router';
 
export default function RootLayout() {
  const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
 
  if (!isAuthenticated) {
    return <Redirect href="/login" />;
  }
 
  return <Slot />;
}

No Provider wrapping required. This pairs cleanly with advanced Expo Router navigation patterns since stores are globally accessible by default.

Zustand vs Context: When to Use Which

Migrating everything to Zustand isn't the goal. My current rule of thumb:

Keep Context API for:

  • Near-static configuration (theme, locale, library Providers)
  • State scoped to a specific component subtree
  • Third-party library Provider patterns (you don't control the shape)

Use Zustand for:

  • Auth, cart, or user profile — global state that changes frequently
  • State accessed or mutated from multiple unrelated screens
  • Any state that involves async API calls

I pair Zustand with TanStack Query for server state: TanStack Query handles server-synced data (caching, refetching, deduplication), and Zustand handles pure client UI state. The two don't compete — they complement each other.

Migrate Incrementally

If you have an existing app with Context API, don't rewrite everything at once. Here's the incremental approach:

  1. Use React DevTools (or Flipper via Rork Companion) to identify which Context causes the most unnecessary re-renders
  2. Pick the worst offender and migrate that single Context to Zustand
  3. Verify behavior, then move to the next

That's how I did it. One store at a time, starting with auth state since it's both heavily referenced and rarely needs co-location with a specific component tree.

Zustand v5 hits a balance between simplicity and scalability that makes sense for indie developers building and maintaining apps solo. Starting with authentication state gives you a concrete improvement you'll feel immediately.

Share

Thank You for Reading

Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • ✦Copy-paste ready implementation code
  • ✦New advanced guides published daily
  • ✦$5/mo or $10 for lifetime access
View Membership →

If you found this article helpful, a small tip ($1.50) would mean a lot to us. Your support helps keep this site ad-free and covers server and hosting costs.

Related Articles

⬡ Dev Tools2026-09-04
EAS secret visibility does not keep a value out of your app — deciding prefix and visibility separately
The EXPO_PUBLIC_ prefix decides what ships inside your app; EAS visibility decides who can read it. Why stacking them blanks a value on OTA updates, and how to check your build.
⬡ Dev Tools2026-08-22
Every bulk replace exited zero. The damage was in the lines I did not delete
Run a bulk replace over generated code and the breakage lands on the neighbouring lines, not the matched ones. Here is what broke in a live project, and a dependency-free guard that checks the invariants a replace must preserve.
⬡ Dev Tools2026-08-14
Find the native edits expo prebuild will erase before you upgrade to SDK 57
Expo SDK 57 makes expo prebuild clear and regenerate ios and android by default. Here is how to audit your hand edits first, move them into config plugins, and why 57.0.9 matters for Reanimated apps.