From frontend-react
React state management patterns — useState, useReducer, useContext, Zustand, and Redux Toolkit with a decision tree for choosing the right solution.
How this skill is triggered — by the user, by Claude, or both
Slash command
/frontend-react:react-stateThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
```
Is the state needed only in one component or its direct children?
YES → Is the update logic simple (toggle, increment, set field)?
YES → useState
NO → useReducer
NO → Is the state shared between 2–3 components in the same subtree?
YES → useContext (keep the context small and co-located)
NO → Is it global app-wide state?
YES → Is the data primarily server-derived with caching needs?
YES → TanStack Query / SWR (see react-data-fetching skill)
NO → Is the team already on Redux?
YES → Redux Toolkit
NO → Zustand
Use for booleans, strings, numbers, and small objects where updates are straightforward.
const [isOpen, setIsOpen] = useState(false);
const [name, setName] = useState("");
const [count, setCount] = useState(0);
// Functional update — always use when new state depends on previous
setCount(prev => prev + 1);
// Lazy initialiser — for expensive initial computation
const [items, setItems] = useState(() => parseStoredItems());
Batching: React 18+ batches multiple state updates automatically, even inside setTimeout and event handlers.
// Both updates are batched into one re-render in React 18
setTimeout(() => {
setCount(c => c + 1);
setFlag(f => !f);
}, 0);
Use when state has multiple sub-values, when the next state depends on the previous in non-trivial ways, or when update logic should be testable in isolation.
type Action =
| { type: "ADD_ITEM"; payload: Item }
| { type: "REMOVE_ITEM"; id: string }
| { type: "TOGGLE_ITEM"; id: string };
interface State {
items: Item[];
selectedId: string | null;
}
function reducer(state: State, action: Action): State {
switch (action.type) {
case "ADD_ITEM":
return { ...state, items: [...state.items, action.payload] };
case "REMOVE_ITEM":
return { ...state, items: state.items.filter(i => i.id !== action.id) };
case "TOGGLE_ITEM":
return { ...state, selectedId: state.selectedId === action.id ? null : action.id };
default:
return state;
}
}
export function ItemList() {
const [state, dispatch] = useReducer(reducer, { items: [], selectedId: null });
return (
<>
{state.items.map(item => (
<Item key={item.id} item={item} onToggle={() => dispatch({ type: "TOGGLE_ITEM", id: item.id })} />
))}
</>
);
}
Keep reducers pure — no side effects, no async, no direct mutations.
Context is suitable when 2–3 closely related components need to share state without prop drilling. It is not a replacement for a state library; it causes all consumers to re-render when the context value changes.
// ThemeContext.tsx
interface ThemeContextValue {
theme: "light" | "dark";
toggleTheme: () => void;
}
const ThemeContext = createContext<ThemeContextValue | null>(null);
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<"light" | "dark">("light");
const toggleTheme = useCallback(() => setTheme(t => t === "light" ? "dark" : "light"), []);
// Memoize value to prevent unnecessary re-renders
const value = useMemo(() => ({ theme, toggleTheme }), [theme, toggleTheme]);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}
export function useTheme() {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error("useTheme must be used within ThemeProvider");
return ctx;
}
Context anti-patterns to avoid:
UserContext vs UserDispatchContext.Zustand is the preferred choice for new projects requiring global client-side state. It is minimal, does not require a provider, and supports fine-grained selectors.
// stores/useCartStore.ts
import { create } from "zustand";
import { devtools, persist } from "zustand/middleware";
interface CartItem {
id: string;
name: string;
quantity: number;
price: number;
}
interface CartState {
items: CartItem[];
addItem: (item: CartItem) => void;
removeItem: (id: string) => void;
updateQuantity: (id: string, quantity: number) => void;
clearCart: () => void;
}
export const useCartStore = create<CartState>()(
devtools(
persist(
(set) => ({
items: [],
addItem: (item) =>
set(state => ({ items: [...state.items, item] }), false, "addItem"),
removeItem: (id) =>
set(state => ({ items: state.items.filter(i => i.id !== id) }), false, "removeItem"),
updateQuantity: (id, quantity) =>
set(
state => ({
items: state.items.map(i => i.id === id ? { ...i, quantity } : i),
}),
false,
"updateQuantity"
),
clearCart: () => set({ items: [] }, false, "clearCart"),
}),
{ name: "cart-storage" }
)
)
);
Always select only the slice of state a component needs to prevent unnecessary re-renders.
// DO: select a specific value
const itemCount = useCartStore(state => state.items.length);
const addItem = useCartStore(state => state.addItem);
// DON'T: select the whole store object
const store = useCartStore(); // any state change re-renders this component
For derived values, compute them inside the selector or use useShallow:
import { useShallow } from "zustand/react/shallow";
const { items, clearCart } = useCartStore(
useShallow(state => ({ items: state.items, clearCart: state.clearCart }))
);
Use Redux Toolkit (RTK) when the team already has Redux infrastructure or requires its ecosystem (RTK Query, Redux DevTools time-travel).
// features/counter/counterSlice.ts
import { createSlice, type PayloadAction } from "@reduxjs/toolkit";
interface CounterState {
value: number;
status: "idle" | "loading" | "failed";
}
const initialState: CounterState = { value: 0, status: "idle" };
export const counterSlice = createSlice({
name: "counter",
initialState,
reducers: {
increment: state => { state.value += 1; }, // Immer proxy — OK to mutate
decrement: state => { state.value -= 1; },
incrementByAmount: (state, action: PayloadAction<number>) => {
state.value += action.payload;
},
},
});
export const { increment, decrement, incrementByAmount } = counterSlice.actions;
export default counterSlice.reducer;
// app/store.ts
import { configureStore } from "@reduxjs/toolkit";
import counterReducer from "@/features/counter/counterSlice";
import { apiSlice } from "@/features/api/apiSlice";
export const store = configureStore({
reducer: {
counter: counterReducer,
[apiSlice.reducerPath]: apiSlice.reducer,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat(apiSlice.middleware),
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
RTK Query is the preferred data-fetching layer when using Redux Toolkit:
// features/api/apiSlice.ts
import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react";
export const apiSlice = createApi({
reducerPath: "api",
baseQuery: fetchBaseQuery({ baseUrl: "/api" }),
tagTypes: ["Post"],
endpoints: (builder) => ({
getPosts: builder.query<Post[], void>({
query: () => "/posts",
providesTags: ["Post"],
}),
addPost: builder.mutation<Post, Partial<Post>>({
query: (body) => ({ url: "/posts", method: "POST", body }),
invalidatesTags: ["Post"],
}),
}),
});
export const { useGetPostsQuery, useAddPostMutation } = apiSlice;
// From URL params (Next.js)
const searchParams = useSearchParams();
const [query, setQuery] = useState(searchParams.get("q") ?? "");
// From localStorage (with hydration safety)
const [value, setValue] = useState<string>(() => {
if (typeof window === "undefined") return "";
return localStorage.getItem("key") ?? "";
});
Compute derived values during render instead of storing them as separate state:
// DO: derive from existing state
const [items, setItems] = useState<Item[]>([]);
const completedItems = items.filter(i => i.done); // derived in render
const totalCount = items.length; // derived in render
// DON'T: duplicate state
const [items, setItems] = useState<Item[]>([]);
const [completedItems, setCompletedItems] = useState<Item[]>([]); // redundant
Use useMemo only when the derivation is expensive (large array transforms, complex computations):
const sortedAndFiltered = useMemo(
() => items.filter(i => i.category === cat).sort((a, b) => a.name.localeCompare(b.name)),
[items, cat]
);
useState calls.// Split context: consumers of UserContext re-render only when user changes;
// consumers of UserDispatchContext never re-render due to dispatch stability.
const UserContext = createContext<User | null>(null);
const UserDispatchContext = createContext<React.Dispatch<UserAction> | null>(null);
| Do | Don't |
|---|---|
| Match state scope to usage (local → global) | Default to global state for everything |
| Use functional updates when new state depends on previous | Rely on stale closure state values |
| Compute derived state during render | Store redundant derived state |
| Select only needed slices from Zustand/Redux | Select the whole store object |
| Use RTK Query or SWR/TanStack for server state | Store server responses in useState manually |
| Keep reducers pure | Add async logic or side effects inside reducers |
npx claudepluginhub gagandeepp/software-agent-teams --plugin frontend-reactGuides completion of development work by verifying tests, detecting environment, and presenting structured options for merge, PR, or cleanup.
Enforces test-driven development: write failing test first, then minimal code to pass. Use when implementing features or bugfixes.
Guides creation and editing of skills using test-driven development with pressure scenarios and subagents to verify agent compliance.