From react-master
Guides React state management with useState, useReducer, Context, Zustand, Jotai, TanStack Query, SWR. Covers store setup, optimization, server caching, optimistic updates, normalization.
How this skill is triggered — by the user, by Claude, or both
Slash command
/react-master:react-state-managementThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
| Library | Best For | Install |
| 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 |
Use for state management decisions:
For React hooks basics: see react-hooks-complete
'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>
);
}
'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;
}
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;
}
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;
}
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>
);
}
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);
}
}),
}))
);
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>
);
}
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>
);
}
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>
);
}
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>
);
}
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>
);
}
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>
);
}
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>
);
}
| 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 |
// 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>
// 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],
},
};
For detailed patterns and advanced use cases, see:
references/zustand-patterns.md - Advanced Zustand patterns including slices, middleware, and testingnpx claudepluginhub josiahsiegel/claude-plugin-marketplace --plugin react-masterGuides React state management with Redux Toolkit, Zustand, Jotai, and React Query. Use when setting up global state, managing server state, or choosing between solutions.
Guides React state management patterns with Redux Toolkit, Zustand, Jotai, React Query for local, global, server state, and library selection.
Guides frontend state management in React: local/global decisions, Zustand/Redux Toolkit/Jotai/MobX/Context, TanStack Query/SWR for server state, optimistic updates, XState machines. Use for store setup, migrations, re-render fixes.