RORK LABJP
TOOLING — Rork's developer repos keep moving: rork-xcode was updated on July 16, rork-device on July 15, and rork-plist on July 13OPUS46 — Claude Opus 4.6 is live in Rork, and Rork Max is built to assemble apps on top of Claude CodeSIM — A cloud iOS simulator runs in the browser, with one click to install on a device and two clicks to publish to the App StoreMAX — Rork Max emits pure Swift rather than React Native, reaching iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and even iMessageNATIVE — That opens up HealthKit, ARKit and LiDAR, NFC, Dynamic Island, Live Activities, 3D through Metal, and on-device inference with Core MLSEED — Rork raised a $15M seed led by Left Lane Capital, with Peak XV and a16z Speedrun joining the roundTOOLING — Rork's developer repos keep moving: rork-xcode was updated on July 16, rork-device on July 15, and rork-plist on July 13OPUS46 — Claude Opus 4.6 is live in Rork, and Rork Max is built to assemble apps on top of Claude CodeSIM — A cloud iOS simulator runs in the browser, with one click to install on a device and two clicks to publish to the App StoreMAX — Rork Max emits pure Swift rather than React Native, reaching iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and even iMessageNATIVE — That opens up HealthKit, ARKit and LiDAR, NFC, Dynamic Island, Live Activities, 3D through Metal, and on-device inference with Core MLSEED — Rork raised a $15M seed led by Left Lane Capital, with Peak XV and a16z Speedrun joining the round
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.

Rork515Zustand4state management5React Native209performance10indie dev28

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-04-23
State Updated in Rork But UI Won't Re-render? Five Patterns and Fixes
setState is firing in your Rork-generated code but the UI refuses to update. Five common root causes — mutation, stale closures, Zustand selectors, missed dependencies, unmounted updates — each with Before/After code.
Dev Tools2026-07-14
Designing Seams That Survive AI Regeneration in Rork
Every follow-up prompt to Rork can quietly wipe out logic you wrote by hand. Protecting it with prompts is a patch, not a fix. Here is how to separate generated code from code you own, and draw a boundary that regeneration cannot reach, with working Zustand and service-layer examples.
Dev Tools2026-06-29
When a New Architecture Migration Only Janks in Release Builds — Field Notes on Catching Silent Interop-Layer Fallback
A Rork app on the New Architecture scrolled fine in development but stuttered only in release builds on real devices. The cause: a legacy native module quietly falling back to the interop layer. Field notes on measuring it and rolling out a fix without reverting the whole app.
📚RECOMMENDED BOOKS
Build a Large Language Model (From Scratch)
Sebastian Raschka
LLM Dev
Prompt Engineering for LLMs
Berryman & Ziegler
Prompting
AI Engineering
Chip Huyen
AI Eng
* Contains affiliate links
See all →