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 zustandA 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:
- Use React DevTools (or Flipper via Rork Companion) to identify which Context causes the most unnecessary re-renders
- Pick the worst offender and migrate that single Context to Zustand
- 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.