cerns.
These findings enable teams to move beyond subjective preferences and make library selections based on quantifiable runtime constraints, team topology, and UI complexity.
Core Solution
Modern React Native state architecture requires a clear separation between UI state and server state. Mixing them creates cache invalidation storms, unnecessary re-renders, and hydration race conditions. The recommended baseline pairs a lightweight UI store with a dedicated data-fetching library. Below is a step-by-step implementation of this pattern, followed by architectural rationale for alternative approaches.
Step 1: Establish a Selector-Driven UI Store
Zustand's functional store pattern eliminates provider wrapping and enables precise subscription control. Instead of exposing the entire store to components, expose typed selectors that isolate state slices.
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import { MMKV } from 'react-native-mmkv';
const storage = new MMKV();
type WorkspaceSlice = {
activeProjectId: string | null;
setProject: (id: string) => void;
clearProject: () => void;
};
type ThemeSlice = {
mode: 'light' | 'dark' | 'system';
toggleMode: () => void;
};
type AppStore = WorkspaceSlice & ThemeSlice;
export const useAppStore = create<AppStore>()(
persist(
(set, get) => ({
activeProjectId: null,
setProject: (id) => set({ activeProjectId: id }),
clearProject: () => set({ activeProjectId: null }),
mode: 'system',
toggleMode: () => {
const current = get().mode;
const next = current === 'light' ? 'dark' : current === 'dark' ? 'system' : 'light';
set({ mode: next });
},
}),
{
name: 'mobile-app-storage',
storage: createJSONStorage(() => ({
getItem: (key) => Promise.resolve(storage.getString(key) ?? null),
setItem: (key, value) => Promise.resolve(storage.set(key, value)),
removeItem: (key) => Promise.resolve(storage.delete(key)),
})),
}
)
);
export const useActiveProject = () => useAppStore((s) => s.activeProjectId);
export const useThemeMode = () => useAppStore((s) => s.mode);
export const useThemeActions = () => ({
toggle: useAppStore.getState().toggleMode,
});
Why this structure: Splitting the store into logical slices prevents accidental cross-slice subscriptions. The custom selector hooks (useActiveProject, useThemeMode) guarantee that components only re-render when their specific slice changes. Persistence is routed through MMKV, which operates synchronously on the native side but is wrapped in Promises to avoid blocking the JS thread during hydration.
Step 2: Decouple Server State with TanStack Query
Server data should never live in the UI store. Query libraries handle deduplication, background refetching, retry logic, and cache expiration automatically.
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useState } from 'react';
export function useQueryClientInstance() {
const [client] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5,
retry: 2,
refetchOnWindowFocus: false,
},
},
})
);
return client;
}
Why this structure: Instantiating the QueryClient inside a hook with useState ensures a single instance per app lifecycle without requiring a top-level provider wrapper in every test or screen. Disabling refetchOnWindowFocus prevents unnecessary network bursts when users switch apps on mobile.
Step 3: Architectural Alternatives
When to use Jotai: If your application features highly independent UI components (data entry forms, canvas editors, multi-step wizards), Jotai's atomic model provides surgical re-render control. Atoms derive values implicitly through getter functions, eliminating manual selector wiring.
import { atom } from 'jotai';
import { atomWithStorage } from 'jotai/utils';
export const draftAtom = atomWithStorage('form-draft', { title: '', content: '' });
export const wordCountAtom = atom((get) => get(draftAtom).content.split(/\s+/).filter(Boolean).length);
When to use Redux Toolkit: Large engineering teams (10+ contributors) or compliance-heavy domains benefit from RTK's rigid structure. The slice/reducer pattern enforces predictable state transitions, and RTK Query provides built-in cache normalization for complex relational data. Time-travel debugging remains unmatched for auditing state mutations in fintech or healthcare applications.
Pitfall Guide
1. Unbounded Store Subscriptions
Explanation: Components that call useAppStore() without a selector subscribe to the entire store. Any state change anywhere in the store triggers a re-render, causing cascading updates and frame drops.
Fix: Always pass a selector function to the store hook. Prefer useShallow from zustand/react/shallow when selecting multiple values to prevent reference inequality from triggering unnecessary renders.
2. Synchronous Hydration Blocking the Main Thread
Explanation: Reading large JSON payloads from AsyncStorage during app initialization stalls the JS thread. The UI remains frozen until hydration completes, creating a poor first impression.
Fix: Use MMKV for sub-millisecond reads. If AsyncStorage is mandatory, hydrate in the background and render a skeleton UI until the store is ready. Validate hydration state with a hasHydrated flag before mounting dependent screens.
3. Immer Proxy Overhead on Hermes
Explanation: Redux Toolkit relies on Immer, which uses JavaScript Proxies to track mutations. While Hermes has improved Proxy performance, deeply nested state trees still trigger measurable overhead during structural sharing.
Fix: Flatten state shapes where possible. Avoid storing deeply nested objects in RTK slices. If deep updates are unavoidable, profile with Flipper and consider switching to immutable update patterns for hot paths.
4. Jotai Atom Proliferation Without Derivation
Explanation: Creating a separate atom for every UI element leads to memory fragmentation and manual dependency tracking. Developers often forget to derive computed values, causing redundant state updates.
Fix: Use derived atoms with getter functions for calculations. Group related atoms in a factory function to maintain namespace clarity. Limit atom creation to truly independent reactive pieces.
5. Mixing Persistence Strategies
Explanation: Combining redux-persist, manual AsyncStorage calls, and custom hydration logic creates race conditions. Components may read stale data or overwrite each other during concurrent writes.
Fix: Standardize on a single persistence layer. Validate write operations with optimistic updates and rollback mechanisms. Use middleware that queues writes to prevent concurrent storage mutations.
6. Server State Leaking into UI Stores
Explanation: Caching API responses in Zustand or Redux forces developers to manually implement deduplication, retry logic, and cache invalidation. This duplicates functionality already provided by query libraries.
Fix: Reserve UI stores for transient interface state (theme, navigation, form drafts). Route all network requests through TanStack Query or RTK Query. Use query invalidation to trigger UI updates instead of manual store mutations.
7. Ignoring Fabric/JSI Layout Synchronization
Explanation: The React Native New Architecture introduces synchronous layout calculations via Fabric. Heavy state libraries with complex update cycles can desynchronize from the UI thread, causing visual jitter.
Fix: Prefer lightweight stores (Zustand, Jotai) that update synchronously without intermediate dispatch cycles. Profile layout passes with the React Native Profiler to identify state updates that block synchronous rendering.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Solo developer or MVP launch | Zustand + TanStack Query | Minimal boilerplate, fast iteration, predictable performance on all devices | Low bundle overhead, reduced dev time |
| 10+ engineer team with strict auditing | Redux Toolkit + RTK Query | Enforces structure, enables time-travel debugging, simplifies code reviews | Higher bundle size, longer onboarding |
| Form-heavy or canvas-based UI | Jotai + Zustand (settings) | Atomic reactivity prevents unnecessary re-renders in complex interfaces | Moderate memory footprint, requires discipline |
| Low-end Android primary audience | Zustand + MMKV | Fastest parse time, smallest bundle, non-blocking persistence | Slightly higher native dependency complexity |
| Enterprise compliance & cache normalization | Redux Toolkit + RTK Query | Built-in cache normalization, predictable state transitions, audit trails | Highest runtime cost, requires strict architecture |
Configuration Template
// store/config.ts
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import { MMKV } from 'react-native-mmkv';
const mmkvStorage = new MMKV();
type SessionState = {
userId: string | null;
authToken: string | null;
setSession: (user: string, token: string) => void;
logout: () => void;
};
export const useSessionStore = create<SessionState>()(
persist(
(set) => ({
userId: null,
authToken: null,
setSession: (userId, authToken) => set({ userId, authToken }),
logout: () => set({ userId: null, authToken: null }),
}),
{
name: 'session-storage',
partialize: (state) => ({ userId: state.userId, authToken: state.authToken }),
storage: createJSONStorage(() => ({
getItem: (key) => Promise.resolve(mmkvStorage.getString(key) ?? null),
setItem: (key, value) => Promise.resolve(mmkvStorage.set(key, value)),
removeItem: (key) => Promise.resolve(mmkvStorage.delete(key)),
})),
}
)
);
// hooks/useSession.ts
export const useIsAuthenticated = () => {
const token = useSessionStore((s) => s.authToken);
return !!token;
};
export const useSessionActions = () => ({
login: useSessionStore.getState().setSession,
logout: useSessionStore.getState().logout,
});
Quick Start Guide
- Install dependencies: Run
npm install zustand @tanstack/react-query react-native-mmkv (or yarn add / pnpm add). Ensure native modules are linked via npx pod-install for iOS.
- Initialize the store: Copy the configuration template into
src/store/session.ts. Adjust the state shape to match your authentication or settings requirements.
- Wire the query client: Create a
QueryClient instance using the pattern in Step 2. Wrap your root component with <QueryClientProvider client={client}>.
- Replace legacy state calls: Search for
useSelector or direct store subscriptions. Replace them with typed selector hooks that isolate specific slices.
- Profile and validate: Launch the app on a low-end Android emulator or physical device. Use React DevTools to verify that components only re-render when their selected state changes. Confirm hydration completes without blocking the splash screen.