Documentation Index
Fetch the complete documentation index at: https://resources.devweekends.com/llms.txt
Use this file to discover all available pages before exploring further.
Learning Objectives
By the end of this module, you’ll understand:
- When to choose Redux Toolkit vs. Zustand (and why Zustand often wins for mobile)
- Redux Toolkit setup with typed slices
- Zustand for lightweight, performant state
- State persistence patterns for mobile apps
- Common pitfalls with global state on React Native
Choosing a State Library
The React Native ecosystem offers many global state libraries, but two dominate production apps: Redux Toolkit and Zustand. Think of them as a pickup truck vs. a sports car — both get you there, but they are optimized for different jobs.
| Factor | Redux Toolkit | Zustand |
|---|
| Boilerplate | Moderate (slices, store, Provider) | Minimal (single create call) |
| Bundle size | ~11 KB (RTK) + ~5 KB (react-redux) | ~1 KB |
| DevTools | Excellent (Redux DevTools) | Good (via middleware) |
| Middleware | Built-in (thunks, listeners) | Plugin-based |
| Learning curve | Steeper (actions, reducers, selectors) | Gentle (just functions and objects) |
| Best for | Large teams, complex business logic | Most mobile apps, rapid development |
The practical recommendation: Start with Zustand for new React Native projects. Its tiny footprint matters on mobile, and its API is simpler to onboard teammates with. Reach for Redux Toolkit when you need its middleware ecosystem (RTK Query, listener middleware) or when your team already has Redux expertise.
Installation
npm install @reduxjs/toolkit react-redux
Store Configuration
// src/stores/store.ts
import { configureStore } from '@reduxjs/toolkit';
import { authReducer } from './slices/authSlice';
import { projectReducer } from './slices/projectSlice';
export const store = configureStore({
reducer: {
auth: authReducer,
projects: projectReducer,
},
// Redux Toolkit enables Redux DevTools and thunk middleware by default.
// On React Native, the Flipper Redux plugin connects automatically.
});
// Infer types from the store itself so they stay in sync with your slices.
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
Typed Slice Example
// src/stores/slices/authSlice.ts
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
interface User {
id: string;
name: string;
email: string;
}
interface AuthState {
user: User | null;
token: string | null;
status: 'idle' | 'loading' | 'succeeded' | 'failed';
error: string | null;
}
const initialState: AuthState = {
user: null,
token: null,
status: 'idle',
error: null,
};
// createAsyncThunk handles the pending/fulfilled/rejected lifecycle for you.
// This replaces the manual dispatch({ type: 'FETCH_START' }) pattern.
export const login = createAsyncThunk(
'auth/login',
async (credentials: { email: string; password: string }) => {
const response = await fetch('/api/login', {
method: 'POST',
body: JSON.stringify(credentials),
});
if (!response.ok) throw new Error('Invalid credentials');
return response.json(); // { user, token }
}
);
const authSlice = createSlice({
name: 'auth',
initialState,
reducers: {
// Synchronous actions -- RTK uses Immer internally, so you can
// "mutate" state directly. It produces an immutable update behind the scenes.
logout(state) {
state.user = null;
state.token = null;
state.status = 'idle';
},
updateProfile(state, action: PayloadAction<Partial<User>>) {
if (state.user) {
Object.assign(state.user, action.payload);
}
},
},
extraReducers: (builder) => {
builder
.addCase(login.pending, (state) => {
state.status = 'loading';
state.error = null;
})
.addCase(login.fulfilled, (state, action) => {
state.status = 'succeeded';
state.user = action.payload.user;
state.token = action.payload.token;
})
.addCase(login.rejected, (state, action) => {
state.status = 'failed';
state.error = action.error.message ?? 'Login failed';
});
},
});
export const { logout, updateProfile } = authSlice.actions;
export const authReducer = authSlice.reducer;
Typed Hooks
// src/stores/hooks.ts
import { useDispatch, useSelector, TypedUseSelectorHook } from 'react-redux';
import type { RootState, AppDispatch } from './store';
// Always use these typed hooks instead of plain useDispatch/useSelector.
// They give you autocomplete on state shape and dispatch types.
export const useAppDispatch = () => useDispatch<AppDispatch>();
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
Mobile pitfall with Redux: Every dispatched action triggers the root reducer, and useSelector runs on every store update. In a busy mobile app with real-time data, this can cause performance issues. Always use specific selectors that return the narrowest slice of state possible, and use createSelector from RTK for memoized derived data.
Zustand
Installation
Basic Store
Zustand stores are plain functions — no providers, no reducers, no action types. You call create, define your state and actions in one place, and consume it with a hook.
// src/stores/useCounterStore.ts
import { create } from 'zustand';
interface CounterStore {
count: number;
increment: () => void;
decrement: () => void;
reset: () => void;
}
export const useCounterStore = create<CounterStore>((set) => ({
count: 0,
// set() merges the partial state update, just like React's setState.
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
reset: () => set({ count: 0 }),
}));
Real-World Store with Async Actions
// src/stores/useProjectStore.ts
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import AsyncStorage from '@react-native-async-storage/async-storage';
interface Project {
id: string;
name: string;
description: string;
}
interface ProjectStore {
projects: Project[];
isLoading: boolean;
error: string | null;
fetchProjects: () => Promise<void>;
addProject: (project: Project) => void;
removeProject: (id: string) => void;
}
export const useProjectStore = create<ProjectStore>()(
// The persist middleware automatically saves and restores state to AsyncStorage,
// giving your app offline resilience with zero extra effort.
persist(
(set, get) => ({
projects: [],
isLoading: false,
error: null,
fetchProjects: async () => {
set({ isLoading: true, error: null });
try {
const response = await fetch('/api/projects');
const projects = await response.json();
set({ projects, isLoading: false });
} catch (err) {
set({ error: (err as Error).message, isLoading: false });
}
},
addProject: (project) =>
set((state) => ({ projects: [...state.projects, project] })),
removeProject: (id) =>
set((state) => ({
projects: state.projects.filter((p) => p.id !== id),
})),
}),
{
name: 'project-storage',
storage: createJSONStorage(() => AsyncStorage),
// Only persist the project list, not loading/error transient state
partialize: (state) => ({ projects: state.projects }),
}
)
);
One of Zustand’s biggest advantages on mobile is that components only re-render when the specific value they subscribe to changes.
function ProjectCount() {
// This component ONLY re-renders when the length of projects changes,
// not when isLoading or error changes. No memoized selectors needed.
const count = useProjectStore((state) => state.projects.length);
return <Text>{count} projects</Text>;
}
function ProjectList() {
// This subscribes to the full projects array
const projects = useProjectStore((state) => state.projects);
const fetchProjects = useProjectStore((state) => state.fetchProjects);
useEffect(() => {
fetchProjects();
}, []);
return (
<FlatList
data={projects}
keyExtractor={(item) => item.id}
renderItem={({ item }) => <Text>{item.name}</Text>}
/>
);
}
Mobile tip: For MMKV-based persistence (faster than AsyncStorage by 30x), replace createJSONStorage(() => AsyncStorage) with a custom MMKV storage adapter. This makes a noticeable difference on app startup when restoring large persisted stores.
Best Practices
- One store per domain, not one giant store — Create separate Zustand stores for auth, projects, UI preferences, etc. This keeps each store focused and prevents unrelated re-renders.
- Never put server data in global state — Use React Query for data from your API. Global stores are for client state (UI preferences, auth tokens, local-only data).
- Persist selectively — Only persist what the user expects to survive an app restart. Transient state like loading flags and error messages should not be persisted.
- Use the functional updater — Always use
set((state) => ...) instead of set({ ... }) when the new state depends on the previous state. This avoids race conditions from rapid state updates.