
React State Management
- 55 installs
- 50 repo stars
- Updated June 18, 2026
- josiahsiegel/claude-plugin-marketplace
Helps with frontend development tasks.
About
react-state-management is a Claude Code skill for frontend development. It helps solo builders move faster with AI-assisted development.
- react-state-management
- Frontend Development
- AI-coding skill
React State Management by the numbers
- 55 all-time installs (skills.sh)
- +4 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #1,259 of 2,245 Frontend Development skills by installs in the Skillselion catalog
- Data as of Aug 3, 2026 (Skillselion catalog sync)
npx skills add https://github.com/josiahsiegel/claude-plugin-marketplace --skill react-state-managementAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 55 |
|---|---|
| repo stars | ★ 50 |
| Last updated | June 18, 2026 |
| Repository | josiahsiegel/claude-plugin-marketplace ↗ |
What it does
Helps with frontend development tasks.
Files
Quick Reference
| Library | Best For | Install |
|---|---|---|
| Context | Small apps, themes | Built-in |
| Zustand | Simple global state | npm i zustand |
| Jotai | Atomic/granular state | npm i jotai |
| TanStack Query | Server state/caching | npm i @tanstack/react-query |
| SWR | Data fetching | npm i swr |
| Scenario | Recommended |
|---|---|
| Simple local state | useState |
| Complex local state | useReducer |
| Shared state (small app) | Context + useReducer |
| Shared state (large app) | Zustand or Jotai |
| Server state | TanStack Query or SWR |
When to Use This Skill
Use for state management decisions:
- Choosing between state management solutions
- Setting up Zustand, Jotai, or Context stores
- Configuring TanStack Query for server state
- Implementing optimistic updates
- Normalizing complex state structures
- Avoiding unnecessary re-renders
For React hooks basics: see react-hooks-complete
---
React State Management
Built-in State Management
Component State with useState
'use client';
import { useState } from 'react';
function ShoppingCart() {
const [items, setItems] = useState<CartItem[]>([]);
const [isOpen, setIsOpen] = useState(false);
const addItem = (product: Product) => {
setItems((prev) => {
const existing = prev.find((item) => item.id === product.id);
if (existing) {
return prev.map((item) =>
item.id === product.id
? { ...item, quantity: item.quantity + 1 }
: item
);
}
return [...prev, { ...product, quantity: 1 }];
});
};
const total = items.reduce(
(sum, item) => sum + item.price * item.quantity,
0
);
return (
<div>
<button onClick={() => setIsOpen(!isOpen)}>
Cart ({items.length}) - ${total.toFixed(2)}
</button>
{isOpen && <CartDropdown items={items} />}
</div>
);
}Complex State with useReducer
'use client';
import { useReducer, Dispatch, createContext, useContext } from 'react';
// Types
interface CartState {
items: CartItem[];
isLoading: boolean;
error: string | null;
}
type CartAction =
| { type: 'ADD_ITEM'; payload: Product }
| { type: 'REMOVE_ITEM'; payload: string }
| { type: 'UPDATE_QUANTITY'; payload: { id: string; quantity: number } }
| { type: 'CLEAR_CART' }
| { type: 'SET_LOADING'; payload: boolean }
| { type: 'SET_ERROR'; payload: string };
// Reducer
function cartReducer(state: CartState, action: CartAction): CartState {
switch (action.type) {
case 'ADD_ITEM': {
const existing = state.items.find(
(item) => item.id === action.payload.id
);
if (existing) {
return {
...state,
items: state.items.map((item) =>
item.id === action.payload.id
? { ...item, quantity: item.quantity + 1 }
: item
),
};
}
return {
...state,
items: [...state.items, { ...action.payload, quantity: 1 }],
};
}
case 'REMOVE_ITEM':
return {
...state,
items: state.items.filter((item) => item.id !== action.payload),
};
case 'UPDATE_QUANTITY':
return {
...state,
items: state.items.map((item) =>
item.id === action.payload.id
? { ...item, quantity: action.payload.quantity }
: item
),
};
case 'CLEAR_CART':
return { ...state, items: [] };
case 'SET_LOADING':
return { ...state, isLoading: action.payload };
case 'SET_ERROR':
return { ...state, error: action.payload };
default:
return state;
}
}
// Context
const CartContext = createContext<{
state: CartState;
dispatch: Dispatch<CartAction>;
} | null>(null);
// Provider
export function CartProvider({ children }: { children: React.ReactNode }) {
const [state, dispatch] = useReducer(cartReducer, {
items: [],
isLoading: false,
error: null,
});
return (
<CartContext.Provider value={{ state, dispatch }}>
{children}
</CartContext.Provider>
);
}
// Hook
export function useCart() {
const context = useContext(CartContext);
if (!context) {
throw new Error('useCart must be used within CartProvider');
}
return context;
}Context API
Creating and Using Context
import { createContext, useContext, useState, ReactNode } from 'react';
// Theme context
interface Theme {
colors: { primary: string; secondary: string; background: string };
spacing: { sm: number; md: number; lg: number };
}
interface ThemeContextType {
theme: Theme;
setTheme: (theme: Theme) => void;
toggleDarkMode: () => void;
isDark: boolean;
}
const ThemeContext = createContext<ThemeContextType | null>(null);
const lightTheme: Theme = {
colors: { primary: '#3b82f6', secondary: '#8b5cf6', background: '#ffffff' },
spacing: { sm: 8, md: 16, lg: 24 },
};
const darkTheme: Theme = {
colors: { primary: '#60a5fa', secondary: '#a78bfa', background: '#1f2937' },
spacing: { sm: 8, md: 16, lg: 24 },
};
export function ThemeProvider({ children }: { children: ReactNode }) {
const [isDark, setIsDark] = useState(false);
const [theme, setTheme] = useState<Theme>(lightTheme);
const toggleDarkMode = () => {
setIsDark((prev) => !prev);
setTheme(isDark ? lightTheme : darkTheme);
};
return (
<ThemeContext.Provider value={{ theme, setTheme, toggleDarkMode, isDark }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within ThemeProvider');
}
return context;
}Optimizing Context Performance
import { createContext, useContext, useMemo, useCallback, useState } from 'react';
// Split context to prevent unnecessary re-renders
const UserContext = createContext<User | null>(null);
const UserActionsContext = createContext<{
login: (email: string, password: string) => Promise<void>;
logout: () => void;
updateProfile: (data: Partial<User>) => Promise<void>;
} | null>(null);
export function UserProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const login = useCallback(async (email: string, password: string) => {
const response = await fetch('/api/login', {
method: 'POST',
body: JSON.stringify({ email, password }),
});
const userData = await response.json();
setUser(userData);
}, []);
const logout = useCallback(() => {
setUser(null);
}, []);
const updateProfile = useCallback(async (data: Partial<User>) => {
const response = await fetch('/api/profile', {
method: 'PATCH',
body: JSON.stringify(data),
});
const updated = await response.json();
setUser(updated);
}, []);
// Memoize actions object
const actions = useMemo(
() => ({ login, logout, updateProfile }),
[login, logout, updateProfile]
);
return (
<UserContext.Provider value={user}>
<UserActionsContext.Provider value={actions}>
{children}
</UserActionsContext.Provider>
</UserContext.Provider>
);
}
// Separate hooks for data and actions
export function useUser() {
return useContext(UserContext);
}
export function useUserActions() {
const context = useContext(UserActionsContext);
if (!context) {
throw new Error('useUserActions must be used within UserProvider');
}
return context;
}Zustand
Basic Zustand Store
import { create } from 'zustand';
import { persist, devtools } from 'zustand/middleware';
interface CartStore {
items: CartItem[];
addItem: (product: Product) => void;
removeItem: (id: string) => void;
updateQuantity: (id: string, quantity: number) => void;
clearCart: () => void;
total: () => number;
}
export const useCartStore = create<CartStore>()(
devtools(
persist(
(set, get) => ({
items: [],
addItem: (product) =>
set((state) => {
const existing = state.items.find((item) => item.id === product.id);
if (existing) {
return {
items: state.items.map((item) =>
item.id === product.id
? { ...item, quantity: item.quantity + 1 }
: item
),
};
}
return { items: [...state.items, { ...product, quantity: 1 }] };
}),
removeItem: (id) =>
set((state) => ({
items: state.items.filter((item) => item.id !== id),
})),
updateQuantity: (id, quantity) =>
set((state) => ({
items: state.items.map((item) =>
item.id === id ? { ...item, quantity } : item
),
})),
clearCart: () => set({ items: [] }),
total: () =>
get().items.reduce(
(sum, item) => sum + item.price * item.quantity,
0
),
}),
{ name: 'cart-storage' }
)
)
);
// Usage in component
function CartButton() {
const items = useCartStore((state) => state.items);
const total = useCartStore((state) => state.total());
return (
<button>
Cart ({items.length}) - ${total.toFixed(2)}
</button>
);
}Zustand with Immer
import { create } from 'zustand';
import { immer } from 'zustand/middleware/immer';
interface TodoStore {
todos: Todo[];
addTodo: (text: string) => void;
toggleTodo: (id: string) => void;
deleteTodo: (id: string) => void;
}
export const useTodoStore = create<TodoStore>()(
immer((set) => ({
todos: [],
addTodo: (text) =>
set((state) => {
state.todos.push({
id: crypto.randomUUID(),
text,
completed: false,
});
}),
toggleTodo: (id) =>
set((state) => {
const todo = state.todos.find((t) => t.id === id);
if (todo) {
todo.completed = !todo.completed;
}
}),
deleteTodo: (id) =>
set((state) => {
const index = state.todos.findIndex((t) => t.id === id);
if (index !== -1) {
state.todos.splice(index, 1);
}
}),
}))
);Jotai and TanStack Query
Detailed patterns for Jotai atom-based state and TanStack Query server-state management — atoms, derived atoms, async atoms, query clients, mutations, invalidation, optimistic updates, and cache tuning — live in references/jotai-and-tanstack-query.md. Load that reference when choosing atom composition or production-grade server-state caching.
SWR
Basic SWR Usage
import useSWR, { SWRConfig } from 'swr';
const fetcher = (url: string) => fetch(url).then((res) => res.json());
function App() {
return (
<SWRConfig
value={{
fetcher,
refreshInterval: 0,
revalidateOnFocus: true,
dedupingInterval: 2000,
}}
>
<Dashboard />
</SWRConfig>
);
}
function Dashboard() {
const { data, error, isLoading, mutate } = useSWR('/api/dashboard');
if (error) return <div>Failed to load</div>;
if (isLoading) return <div>Loading...</div>;
return (
<div>
<h1>Dashboard</h1>
<p>Total Users: {data.totalUsers}</p>
<button onClick={() => mutate()}>Refresh</button>
</div>
);
}SWR Mutation
import useSWRMutation from 'swr/mutation';
async function createUser(url: string, { arg }: { arg: CreateUserInput }) {
const res = await fetch(url, {
method: 'POST',
body: JSON.stringify(arg),
});
return res.json();
}
function CreateUserForm() {
const { trigger, isMutating } = useSWRMutation('/api/users', createUser);
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
await trigger({
name: formData.get('name') as string,
email: formData.get('email') as string,
});
};
return (
<form onSubmit={handleSubmit}>
<input name="name" required />
<input name="email" type="email" required />
<button disabled={isMutating}>
{isMutating ? 'Creating...' : 'Create'}
</button>
</form>
);
}Best Practices
1. Choose the Right Tool
| Scenario | Recommended |
|---|---|
| Simple local state | useState |
| Complex local state | useReducer |
| Shared state (small app) | Context + useReducer |
| Shared state (large app) | Zustand or Jotai |
| Server state | TanStack Query or SWR |
2. Avoid Prop Drilling
// Instead of passing props through many levels
<Parent user={user}>
<Child user={user}>
<GrandChild user={user} />
</Child>
</Parent>
// Use context or state management
<UserProvider>
<Parent>
<Child>
<GrandChild /> {/* Access user via useUser() */}
</Child>
</Parent>
</UserProvider>3. Normalize Complex State
// Instead of nested objects
const badState = {
posts: [
{ id: 1, title: 'Post 1', author: { id: 1, name: 'Alice' } },
{ id: 2, title: 'Post 2', author: { id: 1, name: 'Alice' } },
],
};
// Use normalized structure
const goodState = {
posts: {
byId: { 1: { id: 1, title: 'Post 1', authorId: 1 } },
allIds: [1, 2],
},
authors: {
byId: { 1: { id: 1, name: 'Alice' } },
allIds: [1],
},
};Additional References
For detailed patterns and advanced use cases, see:
references/zustand-patterns.md- Advanced Zustand patterns including slices, middleware, and testing
React State Management: Jotai and TanStack Query
Detailed patterns for Jotai atom-based state management and TanStack Query (React Query) server-state management: atoms, derived atoms, async atoms, query clients, mutations, invalidation, optimistic updates, and cache tuning. SKILL.md keeps built-in state, Context API, Zustand, SWR, and best-practice selection guidance.
Jotai
Basic Jotai Atoms
import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai';
import { atomWithStorage } from 'jotai/utils';
// Primitive atoms
const countAtom = atom(0);
const textAtom = atom('');
// Derived atom (computed value)
const doubleCountAtom = atom((get) => get(countAtom) * 2);
// Writable derived atom
const uppercaseTextAtom = atom(
(get) => get(textAtom).toUpperCase(),
(get, set, newValue: string) => set(textAtom, newValue.toLowerCase())
);
// Async atom
const userAtom = atom(async () => {
const response = await fetch('/api/user');
return response.json();
});
// Persisted atom
const themeAtom = atomWithStorage<'light' | 'dark'>('theme', 'light');
// Usage
function Counter() {
const [count, setCount] = useAtom(countAtom);
const doubleCount = useAtomValue(doubleCountAtom);
return (
<div>
<p>Count: {count}</p>
<p>Double: {doubleCount}</p>
<button onClick={() => setCount((c) => c + 1)}>Increment</button>
</div>
);
}Jotai with Async Actions
import { atom, useAtom } from 'jotai';
import { atomWithQuery, atomWithMutation } from 'jotai-tanstack-query';
// Query atom
const postsAtom = atomWithQuery(() => ({
queryKey: ['posts'],
queryFn: async () => {
const res = await fetch('/api/posts');
return res.json();
},
}));
// Mutation atom
const createPostAtom = atomWithMutation(() => ({
mutationFn: async (newPost: { title: string; content: string }) => {
const res = await fetch('/api/posts', {
method: 'POST',
body: JSON.stringify(newPost),
});
return res.json();
},
}));
function Posts() {
const [{ data: posts, isLoading }] = useAtom(postsAtom);
const [{ mutate: createPost, isPending }] = useAtom(createPostAtom);
if (isLoading) return <p>Loading...</p>;
return (
<div>
{posts.map((post) => (
<article key={post.id}>{post.title}</article>
))}
<button onClick={() => createPost({ title: 'New', content: 'Content' })}>
{isPending ? 'Creating...' : 'Add Post'}
</button>
</div>
);
}TanStack Query (React Query)
Basic Queries
import { useQuery, useMutation, useQueryClient, QueryClient, QueryClientProvider } from '@tanstack/react-query';
// Query client setup
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 30, // 30 minutes
retry: 3,
refetchOnWindowFocus: true,
},
},
});
function App() {
return (
<QueryClientProvider client={queryClient}>
<Posts />
</QueryClientProvider>
);
}
// Fetching data
function Posts() {
const { data, isLoading, error, refetch } = useQuery({
queryKey: ['posts'],
queryFn: async () => {
const res = await fetch('/api/posts');
if (!res.ok) throw new Error('Failed to fetch');
return res.json();
},
});
if (isLoading) return <Spinner />;
if (error) return <ErrorMessage error={error} />;
return (
<div>
{data.map((post) => (
<PostCard key={post.id} post={post} />
))}
<button onClick={() => refetch()}>Refresh</button>
</div>
);
}Mutations with Optimistic Updates
function useCreatePost() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (newPost: CreatePostInput) => {
const res = await fetch('/api/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newPost),
});
if (!res.ok) throw new Error('Failed to create post');
return res.json();
},
onMutate: async (newPost) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: ['posts'] });
// Snapshot previous value
const previousPosts = queryClient.getQueryData(['posts']);
// Optimistically update
queryClient.setQueryData(['posts'], (old: Post[]) => [
{ ...newPost, id: 'temp-id', createdAt: new Date() },
...old,
]);
return { previousPosts };
},
onError: (err, newPost, context) => {
// Rollback on error
queryClient.setQueryData(['posts'], context?.previousPosts);
},
onSettled: () => {
// Refetch after mutation
queryClient.invalidateQueries({ queryKey: ['posts'] });
},
});
}
function CreatePostForm() {
const createPost = useCreatePost();
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
createPost.mutate({
title: formData.get('title') as string,
content: formData.get('content') as string,
});
};
return (
<form onSubmit={handleSubmit}>
<input name="title" required />
<textarea name="content" required />
<button type="submit" disabled={createPost.isPending}>
{createPost.isPending ? 'Creating...' : 'Create Post'}
</button>
</form>
);
}Infinite Queries
import { useInfiniteQuery } from '@tanstack/react-query';
function InfinitePosts() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading,
} = useInfiniteQuery({
queryKey: ['posts', 'infinite'],
queryFn: async ({ pageParam = 0 }) => {
const res = await fetch(`/api/posts?cursor=${pageParam}&limit=10`);
return res.json();
},
getNextPageParam: (lastPage) => lastPage.nextCursor,
initialPageParam: 0,
});
if (isLoading) return <Spinner />;
return (
<div>
{data?.pages.map((page, i) => (
<Fragment key={i}>
{page.posts.map((post) => (
<PostCard key={post.id} post={post} />
))}
</Fragment>
))}
<button
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
>
{isFetchingNextPage
? 'Loading more...'
: hasNextPage
? 'Load More'
: 'No more posts'}
</button>
</div>
);
}Zustand Patterns Guide
Advanced patterns and best practices for Zustand state management.
Core Concepts
Basic Store Setup
import { create } from 'zustand';
interface BearStore {
bears: number;
increase: () => void;
decrease: () => void;
reset: () => void;
}
const useBearStore = create<BearStore>((set) => ({
bears: 0,
increase: () => set((state) => ({ bears: state.bears + 1 })),
decrease: () => set((state) => ({ bears: Math.max(0, state.bears - 1) })),
reset: () => set({ bears: 0 }),
}));
// Usage
function BearCounter() {
const bears = useBearStore((state) => state.bears);
const increase = useBearStore((state) => state.increase);
return (
<div>
<span>{bears} bears</span>
<button onClick={increase}>Add bear</button>
</div>
);
}Using get() for Current State
const useStore = create<Store>((set, get) => ({
items: [],
selectedId: null,
// Use get() to access current state in actions
getSelectedItem: () => {
const { items, selectedId } = get();
return items.find((item) => item.id === selectedId) ?? null;
},
// Use get() for conditional logic
addItem: (item) => {
const { items } = get();
if (items.length >= 100) {
console.warn('Item limit reached');
return;
}
set({ items: [...items, item] });
},
}));Selectors and Performance
Selecting Primitive Values
// GOOD - Only re-renders when count changes
const count = useStore((state) => state.count);
// BAD - Re-renders on ANY state change
const { count, items, users } = useStore();Selecting Derived Data
// GOOD - Shallow comparison with useMemo-like behavior
const expensiveItems = useStore(
(state) => state.items.filter((item) => item.price > 100),
// Custom equality function
(a, b) => a.length === b.length && a.every((item, i) => item.id === b[i].id)
);
// Using shallow from zustand
import { shallow } from 'zustand/shallow';
const { count, total } = useStore(
(state) => ({ count: state.count, total: state.total }),
shallow
);Creating Reusable Selectors
// selectors.ts
export const selectUser = (state: StoreState) => state.user;
export const selectIsAuthenticated = (state: StoreState) => state.user !== null;
export const selectCartTotal = (state: StoreState) =>
state.cart.reduce((sum, item) => sum + item.price * item.quantity, 0);
// Component
function Cart() {
const total = useStore(selectCartTotal);
return <span>Total: ${total}</span>;
}Using useShallow for Object Selection
import { useShallow } from 'zustand/react/shallow';
// Prevents re-renders when unrelated state changes
function UserProfile() {
const { name, email, avatar } = useStore(
useShallow((state) => ({
name: state.user.name,
email: state.user.email,
avatar: state.user.avatar,
}))
);
return (
<div>
<img src={avatar} alt={name} />
<h1>{name}</h1>
<p>{email}</p>
</div>
);
}Middleware Patterns
Persist Middleware - Local Storage
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
interface CartStore {
items: CartItem[];
addItem: (item: CartItem) => void;
clearCart: () => void;
}
const useCartStore = create<CartStore>()(
persist(
(set) => ({
items: [],
addItem: (item) =>
set((state) => ({
items: [...state.items, item],
})),
clearCart: () => set({ items: [] }),
}),
{
name: 'cart-storage', // localStorage key
storage: createJSONStorage(() => localStorage),
// Only persist certain fields
partialize: (state) => ({ items: state.items }),
}
)
);Persist with SessionStorage
const useSessionStore = create<Store>()(
persist(
(set) => ({
// ...
}),
{
name: 'session-data',
storage: createJSONStorage(() => sessionStorage),
}
)
);Persist with Async Storage (React Native)
import AsyncStorage from '@react-native-async-storage/async-storage';
const useStore = create<Store>()(
persist(
(set) => ({
// ...
}),
{
name: 'app-storage',
storage: createJSONStorage(() => AsyncStorage),
}
)
);DevTools Middleware
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
const useStore = create<Store>()(
devtools(
(set) => ({
count: 0,
increment: () =>
set(
(state) => ({ count: state.count + 1 }),
false, // replace state?
'increment' // action name for devtools
),
}),
{ name: 'MyStore' } // Store name in devtools
)
);Combining Middlewares
import { create } from 'zustand';
import { devtools, persist, subscribeWithSelector } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';
// Order matters! devtools should be outer, persist inner
const useStore = create<Store>()(
devtools(
subscribeWithSelector(
persist(
immer((set) => ({
// Store definition
})),
{ name: 'storage-key' }
)
),
{ name: 'StoreName' }
)
);Immer Middleware - Mutable Updates
import { create } from 'zustand';
import { immer } from 'zustand/middleware/immer';
interface TodoStore {
todos: Todo[];
addTodo: (text: string) => void;
toggleTodo: (id: string) => void;
updateTodo: (id: string, text: string) => void;
}
const useTodoStore = create<TodoStore>()(
immer((set) => ({
todos: [],
addTodo: (text) =>
set((state) => {
// Mutate directly - Immer handles immutability
state.todos.push({
id: crypto.randomUUID(),
text,
completed: false,
});
}),
toggleTodo: (id) =>
set((state) => {
const todo = state.todos.find((t) => t.id === id);
if (todo) {
todo.completed = !todo.completed;
}
}),
updateTodo: (id, text) =>
set((state) => {
const todo = state.todos.find((t) => t.id === id);
if (todo) {
todo.text = text;
}
}),
}))
);Store Organization Patterns
Slice Pattern - Split Large Stores
// slices/userSlice.ts
export interface UserSlice {
user: User | null;
setUser: (user: User | null) => void;
logout: () => void;
}
export const createUserSlice: StateCreator<
UserSlice & CartSlice, // Full store type
[],
[],
UserSlice
> = (set) => ({
user: null,
setUser: (user) => set({ user }),
logout: () => set({ user: null }),
});
// slices/cartSlice.ts
export interface CartSlice {
items: CartItem[];
addItem: (item: CartItem) => void;
clearCart: () => void;
}
export const createCartSlice: StateCreator<
UserSlice & CartSlice,
[],
[],
CartSlice
> = (set) => ({
items: [],
addItem: (item) => set((state) => ({ items: [...state.items, item] })),
clearCart: () => set({ items: [] }),
});
// store.ts
import { create, StateCreator } from 'zustand';
type StoreState = UserSlice & CartSlice;
const useStore = create<StoreState>()((...args) => ({
...createUserSlice(...args),
...createCartSlice(...args),
}));Domain-Specific Stores
// stores/authStore.ts
export const useAuthStore = create<AuthStore>((set) => ({
user: null,
token: null,
login: async (credentials) => {
const { user, token } = await api.login(credentials);
set({ user, token });
},
logout: () => set({ user: null, token: null }),
}));
// stores/uiStore.ts
export const useUIStore = create<UIStore>((set) => ({
sidebarOpen: false,
theme: 'light',
toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })),
setTheme: (theme) => set({ theme }),
}));
// stores/cartStore.ts
export const useCartStore = create<CartStore>((set) => ({
// Cart specific logic
}));Async Actions
Basic Async Pattern
interface DataStore {
data: Item[] | null;
loading: boolean;
error: Error | null;
fetchData: () => Promise<void>;
}
const useDataStore = create<DataStore>((set) => ({
data: null,
loading: false,
error: null,
fetchData: async () => {
set({ loading: true, error: null });
try {
const response = await fetch('/api/data');
const data = await response.json();
set({ data, loading: false });
} catch (error) {
set({
error: error instanceof Error ? error : new Error('Unknown error'),
loading: false,
});
}
},
}));Async with Abort Controller
interface SearchStore {
query: string;
results: SearchResult[];
loading: boolean;
setQuery: (query: string) => void;
search: () => Promise<void>;
}
const useSearchStore = create<SearchStore>((set, get) => {
let abortController: AbortController | null = null;
return {
query: '',
results: [],
loading: false,
setQuery: (query) => set({ query }),
search: async () => {
// Cancel previous request
abortController?.abort();
abortController = new AbortController();
const { query } = get();
if (!query.trim()) {
set({ results: [] });
return;
}
set({ loading: true });
try {
const response = await fetch(`/api/search?q=${query}`, {
signal: abortController.signal,
});
const results = await response.json();
set({ results, loading: false });
} catch (error) {
if (error instanceof Error && error.name !== 'AbortError') {
set({ loading: false });
}
}
},
};
});Subscriptions and Side Effects
Subscribe to State Changes
import { subscribeWithSelector } from 'zustand/middleware';
const useStore = create<Store>()(
subscribeWithSelector((set) => ({
count: 0,
increment: () => set((s) => ({ count: s.count + 1 })),
}))
);
// Subscribe to specific state changes
const unsubscribe = useStore.subscribe(
(state) => state.count,
(count, prevCount) => {
console.log(`Count changed from ${prevCount} to ${count}`);
}
);
// With options
useStore.subscribe(
(state) => state.count,
(count) => {
console.log('Count is now:', count);
},
{
equalityFn: (a, b) => a === b, // Custom equality
fireImmediately: true, // Fire on subscribe
}
);React Integration with useEffect
function Analytics() {
const count = useStore((s) => s.count);
useEffect(() => {
// Track analytics when count changes
analytics.track('count_changed', { count });
}, [count]);
return null;
}Computed Values
Using get() for Computed Properties
interface CartStore {
items: CartItem[];
getTotal: () => number;
getItemCount: () => number;
}
const useCartStore = create<CartStore>((set, get) => ({
items: [],
getTotal: () => {
const { items } = get();
return items.reduce((sum, item) => sum + item.price * item.quantity, 0);
},
getItemCount: () => {
const { items } = get();
return items.reduce((sum, item) => sum + item.quantity, 0);
},
}));
// Usage
function CartSummary() {
const getTotal = useCartStore((s) => s.getTotal);
const getItemCount = useCartStore((s) => s.getItemCount);
// Note: These are functions, call them to get values
return (
<div>
<span>{getItemCount()} items</span>
<span>${getTotal()}</span>
</div>
);
}Selector-Based Computed Values
// More efficient - computed at selection time
function CartSummary() {
const total = useCartStore((s) =>
s.items.reduce((sum, item) => sum + item.price * item.quantity, 0)
);
return <span>${total}</span>;
}Store Outside React
Accessing Store Outside Components
// Access state directly
const currentState = useStore.getState();
console.log(currentState.count);
// Call actions directly
useStore.getState().increment();
// Set state directly
useStore.setState({ count: 100 });
// Subscribe outside React
const unsubscribe = useStore.subscribe(console.log);Use in API Interceptors
// api/client.ts
import axios from 'axios';
import { useAuthStore } from '@/stores/authStore';
const client = axios.create({ baseURL: '/api' });
client.interceptors.request.use((config) => {
const token = useAuthStore.getState().token;
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
client.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
useAuthStore.getState().logout();
}
return Promise.reject(error);
}
);Testing Patterns
Testing Store Actions
import { beforeEach, describe, expect, it } from 'vitest';
import { useCartStore } from './cartStore';
describe('cartStore', () => {
beforeEach(() => {
// Reset store before each test
useCartStore.setState({ items: [] });
});
it('adds item to cart', () => {
const item = { id: '1', name: 'Product', price: 10, quantity: 1 };
useCartStore.getState().addItem(item);
expect(useCartStore.getState().items).toHaveLength(1);
expect(useCartStore.getState().items[0]).toEqual(item);
});
it('calculates total correctly', () => {
useCartStore.setState({
items: [
{ id: '1', name: 'A', price: 10, quantity: 2 },
{ id: '2', name: 'B', price: 5, quantity: 3 },
],
});
expect(useCartStore.getState().getTotal()).toBe(35);
});
});Testing Components with Store
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useCartStore } from './cartStore';
import { Cart } from './Cart';
beforeEach(() => {
useCartStore.setState({ items: [] });
});
it('displays cart items', () => {
useCartStore.setState({
items: [{ id: '1', name: 'Test Product', price: 10, quantity: 1 }],
});
render(<Cart />);
expect(screen.getByText('Test Product')).toBeInTheDocument();
});
it('removes item when delete is clicked', async () => {
const user = userEvent.setup();
useCartStore.setState({
items: [{ id: '1', name: 'Test Product', price: 10, quantity: 1 }],
});
render(<Cart />);
await user.click(screen.getByRole('button', { name: /remove/i }));
expect(useCartStore.getState().items).toHaveLength(0);
});