RORK LABJP
MAX — Rork Max is built on Claude Code and Claude Opus 4.6, generating native Swift apps directly instead of React NativeAPPLE — Rork Max targets the whole Apple ecosystem: iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and iMessageWORKFLOW — In practice, users settle into letting the AI scaffold while they rewrite the state management and data layer themselvesSEED — Rork raised a $15M seed led by Left Lane Capital in April, with Peak XV, True Ventures, and a16z Speedrun joiningPAPERLINE — Rork acquired app builder Paperline and says it will stay acquisitive to bring in engineering talentREVIEW — Three-month revisit reviews are growing, clarifying where the tool shines and where it doesn'tMAX — Rork Max is built on Claude Code and Claude Opus 4.6, generating native Swift apps directly instead of React NativeAPPLE — Rork Max targets the whole Apple ecosystem: iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and iMessageWORKFLOW — In practice, users settle into letting the AI scaffold while they rewrite the state management and data layer themselvesSEED — Rork raised a $15M seed led by Left Lane Capital in April, with Peak XV, True Ventures, and a16z Speedrun joiningPAPERLINE — Rork acquired app builder Paperline and says it will stay acquisitive to bring in engineering talentREVIEW — Three-month revisit reviews are growing, clarifying where the tool shines and where it doesn't
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.

Rork502Zustand3state management5React Native202performance10indie 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-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.
Dev Tools2026-06-27
When Your App's Launch Got Quietly Slower Release After Release — Field Notes on TTI Instrumentation and a Startup Budget for Rork Apps
Rork apps tend to start fast on day one, then drift slower as you add features and SDKs. These field notes show how to catch cold-start regressions that averages hide, trace them to a release, and stop the drift with a CI startup budget.
📚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 →