DEV Community

Thesius Code
Thesius Code

Posted on • Originally published at datanest-stores.pages.dev

State Management Patterns

State Management Patterns

A comprehensive pattern library for managing complex state in React applications using Zustand, Jotai, and React Query. Covers the patterns that matter in production: optimistic updates with rollback, multi-tab sync, cache invalidation, real-time WebSocket state, and offline-first persistence. Each pattern includes standalone code, an architecture decision record, and TypeScript types that make impossible states unrepresentable.

Key Features

  • Zustand Store Patterns — Slice architecture, middleware composition, devtools, and persistence
  • Jotai Atomic State — Derived atoms, async atoms, atom families, and atom composition
  • React Query Server State — Query key factories, optimistic mutations, infinite scroll, and prefetching
  • Optimistic Updates — Instant UI feedback with server reconciliation and automatic rollback on failure
  • Real-Time Sync — WebSocket event handlers that merge server pushes into client state without race conditions
  • Offline-First Persistence — IndexedDB-backed state that syncs when connectivity resumes, with conflict resolution
  • Multi-Tab Synchronization — BroadcastChannel API integration so state changes propagate across browser tabs
  • Decision Framework — Flowcharts and comparison tables for choosing between Zustand, Jotai, React Query, and Context

Quick Start

  1. Install your preferred state management library (or combine them):
npm install zustand                  # client state
npm install jotai                    # atomic client state
npm install @tanstack/react-query    # server state
Enter fullscreen mode Exit fullscreen mode
  1. Copy the state/ directory into your project's src/lib/ folder.

  2. Start with the recommended pattern — Zustand for client state + React Query for server state:

// lib/state/stores/cart-store.ts
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
interface CartItem { id: string; name: string; price: number; quantity: number; }

interface CartStore {
  items: CartItem[];
  addItem: (item: Omit<CartItem, 'quantity'>) => void;
  removeItem: (id: string) => void;
  totalPrice: () => number;
}

export const useCartStore = create<CartStore>()(
  devtools(
    persist(
      (set, get) => ({
        items: [],
        addItem: (item) => set((state) => {
          const existing = state.items.find((i) => i.id === item.id);
          if (existing) {
            return { items: state.items.map((i) => i.id === item.id ? { ...i, quantity: i.quantity + 1 } : i) };
          }
          return { items: [...state.items, { ...item, quantity: 1 }] };
        }),
        removeItem: (id) => set((state) => ({ items: state.items.filter((i) => i.id !== id) })),
        totalPrice: () => get().items.reduce((sum, i) => sum + i.price * i.quantity, 0),
      }),
      { name: 'cart-storage' }
    ),
    { name: 'CartStore' }
  )
);
Enter fullscreen mode Exit fullscreen mode

Architecture / How It Works

state-management-patterns/
├── zustand/          # Store, slices, middleware, selectors, broadcast
├── jotai/            # Atoms, async atoms, families, persistence
├── react-query/      # Query keys, mutations, infinite scroll, websocket
├── combined/         # Zustand + Query hybrid, offline-first sync
├── decision-guides/  # When-to-use-what flowchart, Redux migration
└── examples/         # Full todo-app and dashboard examples
Enter fullscreen mode Exit fullscreen mode

Usage Examples

React Query with Optimistic Updates

import { useMutation, useQueryClient } from '@tanstack/react-query';
function useUpdateTodo() {
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: (todo: Todo) =>
      fetch(`/api/todos/${todo.id}`, { method: 'PATCH', body: JSON.stringify(todo) }).then((r) => r.json()),
    onMutate: async (newTodo) => {
      await queryClient.cancelQueries({ queryKey: ['todos'] });
      const previous = queryClient.getQueryData<Todo[]>(['todos']);
      queryClient.setQueryData<Todo[]>(['todos'], (old) =>
        old?.map((t) => (t.id === newTodo.id ? { ...t, ...newTodo } : t))
      );
      return { previous };
    },
    onError: (_err, _todo, ctx) => queryClient.setQueryData(['todos'], ctx?.previous),
    onSettled: () => queryClient.invalidateQueries({ queryKey: ['todos'] }),
  });
}
Enter fullscreen mode Exit fullscreen mode

Jotai Derived Atoms

import { atom } from 'jotai';
const filterAtom = atom<'all' | 'active' | 'completed'>('all');
const todosAtom = atom<Todo[]>([]);
const filteredTodosAtom = atom((get) => {
  const filter = get(filterAtom);
  const todos = get(todosAtom);
  if (filter === 'active') return todos.filter((t) => !t.completed);
  if (filter === 'completed') return todos.filter((t) => t.completed);
  return todos;
});
Enter fullscreen mode Exit fullscreen mode

Multi-Tab Sync with Zustand

const channel = new BroadcastChannel('app-state');
export const useAuthStore = create<AuthStore>((set) => {
  channel.onmessage = (e) => { if (e.data.type === 'LOGOUT') set({ user: null, isAuthenticated: false }); };
  return {
    user: null, isAuthenticated: false,
    logout: () => { set({ user: null, isAuthenticated: false }); channel.postMessage({ type: 'LOGOUT' }); },
  };
});
Enter fullscreen mode Exit fullscreen mode

Configuration

React Query Global Defaults

const queryClient = new QueryClient({
  defaultOptions: {
    queries: { staleTime: 5 * 60_000, gcTime: 30 * 60_000, retry: 2, refetchOnWindowFocus: true },
    mutations: { retry: 1 },
  },
});
Enter fullscreen mode Exit fullscreen mode

Query Key Factory

export const queryKeys = {
  todos: {
    all: ['todos'] as const,
    list: (filters: TodoFilters) => [...queryKeys.todos.all, 'list', filters] as const,
    detail: (id: string) => [...queryKeys.todos.all, 'detail', id] as const,
  },
};
Enter fullscreen mode Exit fullscreen mode

Best Practices

  • Separate client from server state — Zustand/Jotai for UI state, React Query for server data
  • Use selectors to prevent re-rendersuseStore((s) => s.items.length) only re-renders when count changes
  • Query keys are your cache topology — design hierarchically for targeted invalidation
  • Optimistic updates need rollback — always save previous state in onMutate context
  • Persist only what's necessary — cart items yes, modal state no, sensitive data never

Troubleshooting

Issue Cause Fix
Component re-renders on every store update Selecting the entire store object Use a selector: useStore((s) => s.specificField) instead of useStore()
React Query shows stale data after mutation Missing invalidateQueries in onSettled Add queryClient.invalidateQueries({ queryKey: [...] }) after mutation
Zustand persist causes hydration mismatch Server renders default state, client loads persisted state Use skipHydration option or onRehydrateStorage callback
Jotai atoms reset on component unmount Atoms defined inside components Define atoms at module level, outside components

This is 1 of 11 resources in the Frontend Developer Pro toolkit. Get the complete [State Management Patterns] with all files, templates, and documentation for $29.

Get the Full Kit →

Or grab the entire Frontend Developer Pro bundle (11 products) for $129 — save 30%.

Get the Complete Bundle →


Related Articles

Top comments (0)