State Management Zustand Jotai
State management in React Native using Zustand and Jotai for scalable, performant app state
You are an expert in state management for building cross-platform mobile apps with React Native. ## Key Points - Separate client state (Zustand/Jotai) from server state (TanStack Query). Do not cache API responses in Zustand. - Always use selectors with Zustand to prevent unnecessary re-renders. Never destructure the entire store in a component. - Persist only essential data (auth tokens, user preferences). Do not persist large datasets or easily re-fetchable data. - Use MMKV over AsyncStorage for Zustand/Jotai persistence — it is synchronous and significantly faster. - For Jotai, prefer `useAtomValue` and `useSetAtom` over `useAtom` when the component only reads or only writes. - Keep atoms small and composable. Derive complex state with read-only atoms rather than duplicating data. - **Storing server data in client stores**: This leads to stale data and complex synchronization logic. Use TanStack Query for anything fetched from an API. - **Not handling hydration with persistence**: Persisted stores load asynchronously. Use Zustand's `onRehydrateStorage` or a loading state to avoid flickers. - **Mutating state directly**: Zustand's `set` does a shallow merge. For nested updates, always spread or use `immer` middleware. - **Creating atoms inside components**: Jotai atoms created inside render functions get new references every render, causing infinite loops. Define atoms at module scope. - **Ignoring memory on mobile**: Large stores that grow unbounded (e.g., chat message history) can cause OOM crashes. Paginate and evict old data.
skilldb get react-native-skills/State Management Zustand JotaiFull skill: 362 linesState Management — React Native
You are an expert in state management for building cross-platform mobile apps with React Native.
Core Philosophy
State management in React Native must account for constraints that do not exist on the web: memory pressure from the operating system, background/foreground lifecycle transitions, and the performance cost of unnecessary re-renders on mobile hardware. Solutions that work fine in a web browser -- large global stores, frequent deep object comparisons, unbounded state growth -- can cause OOM crashes and jank on mobile. Every state management decision should be evaluated through the lens of "will this perform well on a three-year-old phone?"
The most important distinction in state management is client state versus server state. Client state (theme preference, form input, UI toggles) is owned by the app and managed with Zustand or Jotai. Server state (user profiles, post feeds, product catalogs) is owned by the server and managed with TanStack Query, which handles caching, deduplication, background refetching, and stale-while-revalidate semantics. Storing server data in a Zustand store means you are building a cache from scratch -- handling cache invalidation, staleness, optimistic updates, and error recovery that TanStack Query already handles.
Selectors are the primary performance tool for store-based state management. When a component subscribes to an entire Zustand store, it re-renders whenever any property in the store changes, even properties the component does not use. Using a selector (useAuthStore(s => s.user?.name)) subscribes the component only to the specific value it renders, eliminating unnecessary re-renders. For Jotai, the equivalent is using small, focused atoms and useAtomValue for read-only consumption.
Anti-Patterns
-
Storing server-fetched data in Zustand or Jotai: Putting API responses in a client-side store means you must manually handle cache invalidation, refetching, error states, loading states, and deduplication. TanStack Query (React Query) handles all of these concerns out of the box. Client stores are for client-owned state; server data belongs in a server state cache.
-
Subscribing to the entire store without selectors:
const store = useAuthStore()subscribes to every property in the store. When any property changes, the component re-renders. This is invisible at small scale but causes measurable jank in apps with frequent state updates. Always use selectors to subscribe to specific slices. -
Creating Jotai atoms inside component render functions: An atom created inside a component gets a new reference on every render, causing infinite re-render loops. Atoms must be defined at module scope, outside of any component. This is a fundamental Jotai rule that is easy to violate accidentally.
-
Not handling persistence hydration timing: When a Zustand store uses MMKV persistence, the store's initial state is the default values, not the persisted values. The persisted values load asynchronously. If a component renders before hydration completes, it shows default state briefly before switching to persisted state, causing a visual flicker. Use
onRehydrateStorageor a loading gate to prevent this. -
Letting stores grow unbounded on mobile: A chat app that appends every message to a Zustand store without eviction will eventually consume enough memory for the OS to kill the process. Mobile memory is limited and shared with the rest of the system. Implement pagination, eviction of old data, and maximum size limits for any store that grows over time.
Overview
React Native apps need state management solutions that are lightweight, avoid unnecessary re-renders, and work well with mobile lifecycle constraints (background/foreground transitions, memory limits). Zustand and Jotai are the preferred modern choices: Zustand provides a simple store-based approach with built-in persistence, while Jotai offers atomic state that composes naturally. This skill covers both libraries along with React Query/TanStack Query for server state.
Core Concepts
Zustand Store
// stores/auth-store.ts
import { create } from "zustand";
interface User {
id: string;
name: string;
email: string;
}
interface AuthState {
user: User | null;
token: string | null;
isAuthenticated: boolean;
login: (user: User, token: string) => void;
logout: () => void;
updateUser: (updates: Partial<User>) => void;
}
export const useAuthStore = create<AuthState>((set) => ({
user: null,
token: null,
isAuthenticated: false,
login: (user, token) =>
set({ user, token, isAuthenticated: true }),
logout: () =>
set({ user: null, token: null, isAuthenticated: false }),
updateUser: (updates) =>
set((state) => ({
user: state.user ? { ...state.user, ...updates } : null,
})),
}));
Zustand with Persistence (MMKV)
// stores/settings-store.ts
import { create } from "zustand";
import { persist, createJSONStorage } from "zustand/middleware";
import { MMKV } from "react-native-mmkv";
const storage = new MMKV();
const mmkvStorage = {
getItem: (name: string) => {
const value = storage.getString(name);
return value ?? null;
},
setItem: (name: string, value: string) => {
storage.set(name, value);
},
removeItem: (name: string) => {
storage.delete(name);
},
};
interface SettingsState {
theme: "light" | "dark" | "system";
notificationsEnabled: boolean;
language: string;
setTheme: (theme: SettingsState["theme"]) => void;
setNotifications: (enabled: boolean) => void;
setLanguage: (lang: string) => void;
}
export const useSettingsStore = create<SettingsState>()(
persist(
(set) => ({
theme: "system",
notificationsEnabled: true,
language: "en",
setTheme: (theme) => set({ theme }),
setNotifications: (enabled) => set({ notificationsEnabled: enabled }),
setLanguage: (lang) => set({ language: lang }),
}),
{
name: "settings-storage",
storage: createJSONStorage(() => mmkvStorage),
}
)
);
Jotai Atoms
// atoms/app-atoms.ts
import { atom } from "jotai";
import { atomWithStorage, createJSONStorage } from "jotai/utils";
import { MMKV } from "react-native-mmkv";
const mmkv = new MMKV();
const mmkvStorage = createJSONStorage<any>(() => ({
getItem: (key: string) => mmkv.getString(key) ?? null,
setItem: (key: string, value: string) => mmkv.set(key, value),
removeItem: (key: string) => mmkv.delete(key),
}));
// Simple atoms
export const searchQueryAtom = atom("");
export const isLoadingAtom = atom(false);
// Persisted atom
export const onboardingCompleteAtom = atomWithStorage(
"onboarding-complete",
false,
mmkvStorage
);
// Derived atom (read-only)
export const filteredItemsAtom = atom((get) => {
const query = get(searchQueryAtom).toLowerCase();
const items = get(allItemsAtom);
if (!query) return items;
return items.filter((item) =>
item.name.toLowerCase().includes(query)
);
});
// Async derived atom
export const userProfileAtom = atom(async (get) => {
const userId = get(currentUserIdAtom);
if (!userId) return null;
const response = await fetch(`/api/users/${userId}`);
return response.json();
});
Using Jotai in Components
import { useAtom, useAtomValue, useSetAtom } from "jotai";
import { searchQueryAtom, filteredItemsAtom } from "../atoms/app-atoms";
function SearchScreen() {
const [query, setQuery] = useAtom(searchQueryAtom);
const filteredItems = useAtomValue(filteredItemsAtom);
return (
<View className="flex-1">
<TextInput
value={query}
onChangeText={setQuery}
placeholder="Search..."
className="mx-4 my-2 px-4 py-3 bg-gray-100 rounded-lg"
/>
<FlatList
data={filteredItems}
renderItem={({ item }) => <ItemRow item={item} />}
keyExtractor={(item) => item.id}
/>
</View>
);
}
// Write-only usage (component does not re-render on atom changes)
function LogoutButton() {
const clearUser = useSetAtom(currentUserIdAtom);
return <Button onPress={() => clearUser(null)} title="Logout" />;
}
Implementation Patterns
Zustand Selectors to Minimize Re-Renders
// BAD: subscribes to entire store, re-renders on any change
function BadComponent() {
const store = useAuthStore();
return <Text>{store.user?.name}</Text>;
}
// GOOD: subscribes only to user.name
function GoodComponent() {
const userName = useAuthStore((state) => state.user?.name);
return <Text>{userName}</Text>;
}
// Multiple selectors with shallow comparison
import { useShallow } from "zustand/react/shallow";
function ProfileHeader() {
const { name, email } = useAuthStore(
useShallow((state) => ({
name: state.user?.name,
email: state.user?.email,
}))
);
return (
<View>
<Text>{name}</Text>
<Text>{email}</Text>
</View>
);
}
TanStack Query for Server State
// hooks/use-posts.ts
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
interface Post {
id: string;
title: string;
body: string;
}
export function usePosts() {
return useQuery({
queryKey: ["posts"],
queryFn: async (): Promise<Post[]> => {
const res = await fetch("https://api.example.com/posts");
if (!res.ok) throw new Error("Failed to fetch posts");
return res.json();
},
staleTime: 5 * 60 * 1000, // 5 minutes
});
}
export function useCreatePost() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (newPost: Omit<Post, "id">) => {
const res = await fetch("https://api.example.com/posts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(newPost),
});
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["posts"] });
},
});
}
Combining Zustand + TanStack Query
// Zustand for client state, TanStack Query for server state
function PostsScreen() {
const filter = useSettingsStore((s) => s.postFilter);
const { data: posts, isLoading } = usePosts();
const filteredPosts = useMemo(
() => posts?.filter((p) => matchesFilter(p, filter)),
[posts, filter]
);
if (isLoading) return <LoadingSpinner />;
return (
<FlatList
data={filteredPosts}
renderItem={({ item }) => <PostCard post={item} />}
/>
);
}
Zustand Slices Pattern for Large Stores
// stores/app-store.ts
import { create } from "zustand";
interface CartSlice {
items: CartItem[];
addItem: (item: CartItem) => void;
removeItem: (id: string) => void;
clearCart: () => void;
}
interface UISlice {
isBottomSheetOpen: boolean;
activeModal: string | null;
openBottomSheet: () => void;
closeBottomSheet: () => void;
setActiveModal: (modal: string | null) => void;
}
type AppStore = CartSlice & UISlice;
export const useAppStore = create<AppStore>((set) => ({
// Cart slice
items: [],
addItem: (item) =>
set((state) => ({ items: [...state.items, item] })),
removeItem: (id) =>
set((state) => ({ items: state.items.filter((i) => i.id !== id) })),
clearCart: () => set({ items: [] }),
// UI slice
isBottomSheetOpen: false,
activeModal: null,
openBottomSheet: () => set({ isBottomSheetOpen: true }),
closeBottomSheet: () => set({ isBottomSheetOpen: false }),
setActiveModal: (modal) => set({ activeModal: modal }),
}));
Best Practices
- Separate client state (Zustand/Jotai) from server state (TanStack Query). Do not cache API responses in Zustand.
- Always use selectors with Zustand to prevent unnecessary re-renders. Never destructure the entire store in a component.
- Persist only essential data (auth tokens, user preferences). Do not persist large datasets or easily re-fetchable data.
- Use MMKV over AsyncStorage for Zustand/Jotai persistence — it is synchronous and significantly faster.
- For Jotai, prefer
useAtomValueanduseSetAtomoveruseAtomwhen the component only reads or only writes. - Keep atoms small and composable. Derive complex state with read-only atoms rather than duplicating data.
Common Pitfalls
- Storing server data in client stores: This leads to stale data and complex synchronization logic. Use TanStack Query for anything fetched from an API.
- Not handling hydration with persistence: Persisted stores load asynchronously. Use Zustand's
onRehydrateStorageor a loading state to avoid flickers. - Mutating state directly: Zustand's
setdoes a shallow merge. For nested updates, always spread or useimmermiddleware. - Creating atoms inside components: Jotai atoms created inside render functions get new references every render, causing infinite loops. Define atoms at module scope.
- Ignoring memory on mobile: Large stores that grow unbounded (e.g., chat message history) can cause OOM crashes. Paginate and evict old data.
Install this skill directly: skilldb add react-native-skills
Related Skills
Reanimated Animations
High-performance animations in React Native using Reanimated and Gesture Handler
Eas Build Ota Updates
Deploying React Native apps with EAS Build, app store submission, and OTA updates via EAS Update
Expo Managed Workflow
Expo managed workflow for rapid React Native development with minimal native configuration
Native Modules Turbo Modules
Creating native modules and Turbo Modules to bridge platform-specific functionality into React Native
React Navigation Patterns
React Navigation patterns for stack, tab, drawer, and nested navigators in React Native
Offline Storage
Offline storage strategies in React Native using AsyncStorage, MMKV, and WatermelonDB