State management isn't about picking one tool. It's about putting each kind of state in the right place. Robust apps blend global state, server state, and local state, each with clear responsibilities.
This guide shows practical patterns that scale, remain testable, and stay readable over time.
The Three Pillars
1) Global State — app-wide, cross-cutting data
Examples: authentication, feature flags, theme, navigation-affecting UI state. Use a single source of truth and predictable updates.
2) Server State — remote data with caching & sync
Examples: lists, profiles, feeds, search results. Prefer a data-fetching library that handles caching, deduping, revalidation, retries, and pagination.
3) Local State — component or small subtree
Examples: form inputs, modal visibility, transient UI controls. Keep it close to where it’s used.
Global State with Redux Toolkit
A thin service around the store centralizes setup and exposes typed hooks. Keep middleware minimal and avoid persisting volatile UI state.
// store.ts - See architecture guide for full setup
export const store = configureStore({
reducer: { auth, settings, ui },
devTools: process.env.NODE_ENV !== "production",
});
Slice example (keep reducers small and serializable):
// slices/auth.ts
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
type AuthState = { token: string | null; userId: string | null };
const initialState: AuthState = { token: null, userId: null };
const auth = createSlice({
name: "auth",
initialState,
reducers: {
setAuth(state, { payload }: PayloadAction<AuthState>) {
state.token = payload.token;
state.userId = payload.userId;
},
signOut(state) {
state.token = null;
state.userId = null;
},
},
});
export const { setAuth, signOut } = auth.actions;
export default auth.reducer;
Persistence & Storage Boundaries
Persist only what must survive app restarts (e.g., auth, user preferences). Keep sensitive items in secure storage.
- Secure: Expo SecureStore / Keychain / Keystore
- Non-sensitive: AsyncStorage
References: Redux Toolkit • AsyncStorage • Expo SecureStore
Server State with TanStack Query (React Query)
Use React Query (or SWR) to own fetching, caching, background updates, retries, and pagination. Keep server state out of Redux.
// queryClient.ts
import { QueryClient } from "@tanstack/react-query";
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60_000,
retry: (failureCount, error) => failureCount < 2,
refetchOnWindowFocus: false,
},
},
});
Query hooks should be small and typed:
// api/events.ts - Using structured API services
import { ApiService } from "../services/ApiService";
// hooks/useEvent.ts
import { useQuery, useInfiniteQuery } from "@tanstack/react-query";
export const useEvent = (id?: string) =>
useQuery({
queryKey: ["event", id],
queryFn: async () => await ApiService.event.getEvent(id),
enabled: !!id,
});
export const useFeed = (params: { q?: string }) =>
useInfiniteQuery({
queryKey: ["feed", params],
queryFn: async ({ pageParam = 0 }) =>
await ApiService.event.getFeed({ page: pageParam, q: params.q }),
getNextPageParam: (last, pages) =>
last.hasNextPage ? pages.length : undefined,
});
Mutations with optimistic updates:
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { ApiService } from "../services/ApiService";
export const useLikePost = () => {
const qc = useQueryClient();
return useMutation({
mutationFn: async (postId: string) =>
await ApiService.post.likePost(postId),
onMutate: async (postId) => {
await qc.cancelQueries({ queryKey: ["post", postId] });
const prev = qc.getQueryData<any>(["post", postId]);
qc.setQueryData(["post", postId], (old: any) => ({
...old,
isLiked: true,
likesCount: (old?.likesCount ?? 0) + 1,
}));
return { prev };
},
onError: (_e, postId, ctx) => {
qc.setQueryData(["post", postId], ctx?.prev);
},
onSettled: (_d, _e, postId) => {
qc.invalidateQueries({ queryKey: ["post", postId] });
},
});
};
Integrating with API Services Architecture
For robust server state management, combine structured API services with React Query. This approach provides:
- Centralized API logic: All HTTP requests go through your service layer
- Consistent error handling: Authentication, retries, and logging handled uniformly
- Type safety: End-to-end TypeScript support from API to UI
- Testability: Mock services easily for unit tests
// services/ApiService.ts - Centralized API access
export class ApiService {
static inject = ["AxiosService", "AppConfigService"] as const;
constructor(private readonly axios: IAxiosService) {
this.event = new EventApiService(this.axios);
this.post = new PostApiService(this.axios);
this.user = new UserApiService(this.axios);
}
readonly event: EventApiService;
readonly post: PostApiService;
readonly user: UserApiService;
}
// services/EventApiService.ts - Domain-specific API methods
export class EventApiService {
constructor(private readonly axios: IAxiosService) {}
getEvent(id: string) {
return this.axios.request({ method: "GET", url: `/events/${id}` });
}
getFeed(params: { page?: number; q?: string }) {
return this.axios.request({
method: "GET",
url: "/events/feed",
params,
});
}
}
This pattern keeps your React Query hooks clean while leveraging the full power of your API service architecture.
References: TanStack Query • SWR • API Services Architecture Guide
Local State & Custom Hooks
Keep transient UI close to components. Extract complex logic into custom hooks for reuse and testability.
// useFormState.ts
import { useEffect, useMemo, useState } from "react";
export function useFormState<T>(defaults: T, incoming?: Partial<T>) {
const [values, setValues] = useState<T>(defaults);
useEffect(() => {
if (incoming) setValues((v) => ({ ...v, ...incoming }));
}, [incoming]);
const set = <K extends keyof T>(k: K, val: T[K]) =>
setValues((v) => ({ ...v, [k]: val }));
const isValid = useMemo(() => true, [values]); // plug in real validation
return { values, set, isValid, reset: () => setValues(defaults) };
}
Migration & Versioning
When state shapes change, version persisted slices and migrate intentionally.
// simple example
type SettingsV1 = { theme: "light" | "dark" };
type SettingsV2 = { theme: "light" | "dark"; reduceMotion: boolean };
export function migrateSettings(state: any): SettingsV2 {
if (!state) return { theme: "light", reduceMotion: false };
if (state.version === 1) {
return { ...state, reduceMotion: false, version: 2 };
}
return state as SettingsV2;
}
Reference: Redux Persist migrations
Performance: Put Data in the Right Place
- Don’t mirror server data in Redux; keep it in React Query.
- Memoize expensive derived data with selectors.
- Split slices to reduce unnecessary re-renders.
- Use
enabled
and stablequeryKey
s to avoid accidental fetches.
// selectors.ts
import { createSelector } from "@reduxjs/toolkit";
import type { RootState } from "../store";
export const selectUser = (s: RootState) => s.auth.userId;
export const selectDisplayName = createSelector(selectUser, (id) =>
id ? `User #${id}` : ""
);
References: Reselect • React Performance
Decision Guide
- Local state: only this component cares, short-lived, purely presentational.
- Server state: remote, cacheable, needs revalidation/pagination/retries.
- Global state: cross-screen, drives app behavior, minimal and serializable.
Keep each kind of state where it naturally belongs. Your codebase will stay simpler, faster, and easier to change.