State Architecture Patterns & Trade-offs

📖 Concept

State management is the #1 architectural decision that affects long-term maintainability. At scale (50+ screens, 10+ engineers), the wrong choice compounds into tech debt that takes quarters to fix.

State categories (critical mental model):

Type Examples Persistence Reactivity Tool
Server state API data, user profile Cache + API sync Subscribe to changes React Query / SWR
Client state Form inputs, UI toggles Session or none Local re-render useState / Zustand
Global state Auth, theme, feature flags Persistent All consumers Zustand / Redux
Navigation state Current route, params In-memory Navigation events React Navigation
Derived state Filtered list, totals Never (computed) Re-compute on change useMemo / selectors

The mistake 90% of teams make: Putting EVERYTHING in one global store (Redux). Server state (API data) has completely different lifecycle characteristics than UI state. Mixing them creates:

  • Stale data (no automatic refetch)
  • Manual cache invalidation (error-prone)
  • Bloated reducers with loading/error/data for every API call
  • Unnecessary re-renders (updating one API cache triggers all consumers)

Modern state architecture:

Server State → React Query / TanStack Query
  ├── Automatic caching, refetching, invalidation
  ├── Stale-while-revalidate pattern
  └── Optimistic updates with rollback

Client State → Zustand (or useState for local)
  ├── Minimal boilerplate
  ├── Selector-based re-renders
  └── Middleware (persist, devtools, immer)

Global Config → Context (for infrequently-changing data)
  ├── Theme, locale, feature flags
  └── Memoized provider values

💻 Code Example

codeTap to expand ⛶
1// === MODERN STATE ARCHITECTURE ===
2
3// 1. Server State with React Query (TanStack Query)
4import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
5
6// Query keys as constants — prevents typos and enables invalidation
7const queryKeys = {
8 users: ['users'] as const,
9 user: (id: string) => ['users', id] as const,
10 posts: (filters?: PostFilters) => ['posts', filters] as const,
11 post: (id: string) => ['posts', id] as const,
12};
13
14// Custom hook for user data
15function useUser(userId: string) {
16 return useQuery({
17 queryKey: queryKeys.user(userId),
18 queryFn: () => api.getUser(userId),
19 staleTime: 5 * 60 * 1000, // 5 min — don't refetch if fresh
20 gcTime: 30 * 60 * 1000, // 30 min — keep in cache
21 retry: 3, // Auto-retry on failure
22 });
23}
24
25// Mutation with optimistic update
26function useUpdateProfile() {
27 const queryClient = useQueryClient();
28
29 return useMutation({
30 mutationFn: (data: UpdateProfileDTO) => api.updateProfile(data),
31
32 // Optimistic update — show change immediately, rollback on error
33 onMutate: async (newData) => {
34 await queryClient.cancelQueries({ queryKey: queryKeys.user('me') });
35
36 const previousUser = queryClient.getQueryData(queryKeys.user('me'));
37
38 queryClient.setQueryData(queryKeys.user('me'), (old: User) => ({
39 ...old,
40 ...newData,
41 }));
42
43 return { previousUser }; // Context for rollback
44 },
45
46 onError: (_err, _newData, context) => {
47 // Rollback on error
48 queryClient.setQueryData(queryKeys.user('me'), context?.previousUser);
49 },
50
51 onSettled: () => {
52 // Refetch in background to ensure consistency
53 queryClient.invalidateQueries({ queryKey: queryKeys.user('me') });
54 },
55 });
56}
57
58// 2. Client State with Zustand
59import { create } from 'zustand';
60import { persist, createJSONStorage } from 'zustand/middleware';
61import AsyncStorage from '@react-native-async-storage/async-storage';
62
63interface AppState {
64 // UI State
65 isDarkMode: boolean;
66 selectedTab: 'home' | 'search' | 'profile';
67
68 // Session State
69 isOnboarded: boolean;
70 lastViewedProductId: string | null;
71
72 // Actions
73 toggleDarkMode: () => void;
74 setTab: (tab: AppState['selectedTab']) => void;
75 markOnboarded: () => void;
76}
77
78const useAppStore = create<AppState>()(
79 persist(
80 (set) => ({
81 isDarkMode: false,
82 selectedTab: 'home',
83 isOnboarded: false,
84 lastViewedProductId: null,
85
86 toggleDarkMode: () => set((s) => ({ isDarkMode: !s.isDarkMode })),
87 setTab: (tab) => set({ selectedTab: tab }),
88 markOnboarded: () => set({ isOnboarded: true }),
89 }),
90 {
91 name: 'app-state',
92 storage: createJSONStorage(() => AsyncStorage),
93 partialize: (state) => ({
94 // Only persist these fields
95 isDarkMode: state.isDarkMode,
96 isOnboarded: state.isOnboarded,
97 }),
98 }
99 )
100);
101
102// Selector-based subscriptions — only re-renders when selected value changes
103function ThemeToggle() {
104 const isDarkMode = useAppStore(s => s.isDarkMode);
105 const toggle = useAppStore(s => s.toggleDarkMode);
106 // This component ONLY re-renders when isDarkMode changes
107 // Not when selectedTab or anything else changes
108 return <Switch value={isDarkMode} onValueChange={toggle} />;
109}

🏋️ Practice Exercise

State Architecture Exercises:

  1. Audit your current app's state — categorize every piece of state into server/client/global/navigation/derived
  2. Migrate one API-fetching Redux slice to React Query and compare the code reduction
  3. Implement a Zustand store with persistence (AsyncStorage/MMKV) and selective persistence
  4. Build an optimistic update flow: edit a todo → show change immediately → rollback on API error
  5. Create a selector-based Zustand store and verify that only subscribed components re-render
  6. Implement a cache invalidation strategy for interrelated resources (e.g., updating a post should invalidate the feed)

⚠️ Common Mistakes

  • Storing server state in Redux with manual loading/error tracking — React Query handles this automatically with better caching and invalidation

  • Not distinguishing between state categories — leads to a monolithic store that's hard to reason about and causes unnecessary re-renders

  • Using Context for frequently-changing state — Context re-renders ALL consumers on any change, even with useMemo on the value

  • Not normalizing state — storing nested/duplicated data wastes memory and makes updates error-prone

  • Over-centralizing — putting form state in a global store when it should be local to the component

💼 Interview Questions

🎤 Mock Interview

Mock interview is powered by AI for State Architecture Patterns & Trade-offs. Login to unlock this feature.