Skip to main content

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.
FactorRedux ToolkitZustand
BoilerplateModerate (slices, store, Provider)Minimal (single create call)
Bundle size~11 KB (RTK) + ~5 KB (react-redux)~1 KB
DevToolsExcellent (Redux DevTools)Good (via middleware)
MiddlewareBuilt-in (thunks, listeners)Plugin-based
Learning curveSteeper (actions, reducers, selectors)Gentle (just functions and objects)
Best forLarge teams, complex business logicMost 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.

Redux Toolkit

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

npm install zustand

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 }),
    }
  )
);

Selective Subscriptions (Performance)

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

  1. 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.
  2. 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).
  3. 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.
  4. 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.