
React Core State
- 11 installs
- 6 repo stars
- Updated July 8, 2026
- openaec-foundation/react-claude-skill-package
Helps with frontend development tasks.
About
react-core-state is a Claude Code skill for frontend development. It helps solo builders move faster with AI-assisted development.
- react-core-state
- Frontend Development
- AI-coding skill
React Core State by the numbers
- 11 all-time installs (skills.sh)
- Ranked #1,658 of 2,245 Frontend Development skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/openaec-foundation/react-claude-skill-package --skill react-core-stateAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 11 |
|---|---|
| repo stars | ★ 6 |
| Last updated | July 8, 2026 |
| Repository | openaec-foundation/react-claude-skill-package ↗ |
What it does
Helps with frontend development tasks.
Files
react-core-state
Quick Reference
State Tool Selection
| Scenario | Tool | Why |
|---|---|---|
| Single primitive or simple object | useState | Minimal boilerplate, direct updates |
| Complex object with multiple sub-values | useReducer | Centralized transitions, predictable logic |
| State needed by distant descendants | Context + useState/useReducer | Avoids prop drilling |
| High-frequency updates read by many components | External store (Zustand/Jotai) | Bypasses Context re-render cascade |
| Server data (fetched, cached, synced) | TanStack Query / SWR | Handles caching, revalidation, deduplication |
| Optimistic UI during async mutation | useOptimistic (React 19) | Instant feedback, automatic rollback |
| Form submission state | useActionState (React 19) | Tracks pending state, works with Server Actions |
| Subscribing to non-React store | useSyncExternalStore | Tear-free reads, SSR-safe |
State Placement Decision
| Question | If YES | If NO |
|---|---|---|
| Only this component needs it? | Local useState | Keep reading |
| Parent and siblings need it? | Lift to closest common ancestor | Keep reading |
| Many distant components need it? | Context or external store | Keep reading |
| Updates are frequent (>60fps)? | External store with selectors | Context is fine |
| Data comes from the server? | TanStack Query / server state lib | Client state |
---
Critical Warnings
NEVER mutate state directly. state.items.push(item) does NOT trigger a re-render. ALWAYS create a new reference: setState(prev => [...prev.items, item]).
NEVER store derived values in state. If fullName can be computed from firstName and lastName, compute it during render. Storing it creates sync bugs.
NEVER call useState or useReducer inside loops, conditions, or nested functions. React relies on call order to track state identity.
NEVER read state immediately after calling setState and expect the new value. State updates apply on the NEXT render.
NEVER use Context for high-frequency updates (mouse position, animations). Every consumer re-renders when the context value changes. Use an external store with selectors instead.
NEVER create a new context value object on every render without useMemo. This defeats React.memo on all consumers.
ALWAYS use the updater form setState(prev => prev + 1) when the next state depends on the previous state. Direct setState(count + 1) causes stale closures in batched updates.
ALWAYS use an initializer function for expensive initial state: useState(() => computeExpensive()), NOT useState(computeExpensive()).
---
Decision Tree
Need to manage data in a React component?
|
+-- Is it server data (API responses, DB records)?
| YES --> Use TanStack Query or SWR (server state library)
| NO |
| v
+-- Is it a single value or simple object?
| YES --> useState
| NO |
| v
+-- Does it have complex transitions (multiple fields change together)?
| YES --> useReducer
| NO --> useState with object spread
|
After choosing the hook:
|
+-- Does only THIS component need it?
| YES --> Keep it local
| NO |
| v
+-- Do a parent and a few siblings need it?
| YES --> Lift state to closest common ancestor
| NO |
| v
+-- Do many distant components need it?
|
+-- Are updates infrequent (theme, locale, auth)?
| YES --> Context API
| NO |
| v
+-- Are updates frequent or need fine-grained subscriptions?
YES --> External store (Zustand, Jotai)---
State Patterns
useState -- Simple Local State
const [count, setCount] = useState<number>(0);
const [user, setUser] = useState<User | null>(null);
// Updater form -- ALWAYS use when depending on previous state
setCount(prev => prev + 1);
// Lazy initializer -- ALWAYS use for expensive computations
const [data, setData] = useState<ExpensiveData>(() => parseExpensiveData(raw));useReducer -- Complex State Logic
Use useReducer when: multiple state fields change in response to a single event, or state transitions follow business rules.
type State = { items: Item[]; status: "idle" | "loading" | "error" };
type Action =
| { type: "FETCH_START" }
| { type: "FETCH_SUCCESS"; items: Item[] }
| { type: "FETCH_ERROR" };
function reducer(state: State, action: Action): State {
switch (action.type) {
case "FETCH_START":
return { ...state, status: "loading" };
case "FETCH_SUCCESS":
return { items: action.items, status: "idle" };
case "FETCH_ERROR":
return { ...state, status: "error" };
}
}
const [state, dispatch] = useReducer(reducer, { items: [], status: "idle" });Context API -- Cross-Cutting State
See references/patterns.md for full Context patterns with useMemo optimization.
// 1. Create context with typed default
const ThemeContext = createContext<Theme>("light");
// 2. Provider with memoized value
function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<Theme>("light");
const value = useMemo(() => ({ theme, setTheme }), [theme]);
return <ThemeContext value={value}>{children}</ThemeContext>;
}
// 3. Consumer hook
function useTheme() {
return useContext(ThemeContext);
}External Stores -- High-Performance Global State
Use when Context re-renders too many components. See references/patterns.md for Zustand/Jotai examples.
// Zustand -- minimal API, selector-based subscriptions
import { create } from "zustand";
const useStore = create<StoreState>((set) => ({
count: 0,
increment: () => set((s) => ({ count: s.count + 1 })),
}));
// Component only re-renders when count changes
function Counter() {
const count = useStore((s) => s.count);
return <span>{count}</span>;
}useSyncExternalStore -- Non-React Store Subscription
function useOnlineStatus(): boolean {
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}
function subscribe(callback: () => void) {
window.addEventListener("online", callback);
window.addEventListener("offline", callback);
return () => {
window.removeEventListener("online", callback);
window.removeEventListener("offline", callback);
};
}
function getSnapshot(): boolean { return navigator.onLine; }
function getServerSnapshot(): boolean { return true; }---
Anti-Patterns
See references/anti-patterns.md for complete anti-pattern catalog with fixes.
| Anti-Pattern | Problem | Fix |
|---|---|---|
| Direct mutation | No re-render | Create new reference |
| Storing derived state | Sync bugs | Compute during render |
| Props in state (mirror) | Stale data | Use props directly or key reset |
| Context for frequent updates | Re-render cascade | External store with selectors |
| Missing updater form | Stale closures | setState(prev => ...) |
| Overly broad context | Unrelated re-renders | Split into focused contexts |
---
Version Notes (React 18 vs 19)
React 18
useState,useReducer,useContext,useSyncExternalStoreavailable- Automatic batching in event handlers, timeouts, promises (new in 18)
startTransitionfor non-urgent updates
React 19 Additions
- `useActionState`: Replaces
useFormState. Returns[state, action, isPending]. Supports async reducers with side effects. Works with Server Actions and<form action={...}>. - `useOptimistic`: Immediate UI updates during async actions. Automatically rolls back on failure. Must be called inside a Transition or Action.
- Context as provider: Use
<Context value={...}>directly instead of<Context.Provider value={...}>(Provider syntax still works but is deprecated). - Server Components: Server Components have NO state. They render once on the server. ALWAYS place stateful logic in Client Components (
"use client").
---
Reference Links
- references/examples.md -- State management code examples with TypeScript
- references/patterns.md -- State architecture patterns (Context, external stores, server state)
- references/anti-patterns.md -- State management mistakes and fixes
Official Sources
- https://react.dev/learn/managing-state
- https://react.dev/reference/react/useState
- https://react.dev/reference/react/useReducer
- https://react.dev/reference/react/useContext
- https://react.dev/reference/react/useSyncExternalStore
- https://react.dev/reference/react/useActionState
- https://react.dev/reference/react/useOptimistic
react-core-state -- Anti-Patterns
AP-1: Direct State Mutation
Problem: Mutating state objects or arrays in place does NOT trigger a re-render. React uses Object.is comparison and sees the same reference.
// WRONG -- mutates existing array, no re-render
function addItem(item: Item) {
items.push(item);
setItems(items); // Same reference -- React skips update
}
// WRONG -- mutates existing object
function updateUser() {
user.name = "New Name";
setUser(user); // Same reference -- React skips update
}Fix: ALWAYS create new references.
// CORRECT -- new array
function addItem(item: Item) {
setItems(prev => [...prev, item]);
}
// CORRECT -- new object
function updateUser() {
setUser(prev => ({ ...prev, name: "New Name" }));
}
// For deeply nested state, use Immer:
import { produce } from "immer";
setUser(produce(draft => {
draft.address.city = "Amsterdam";
}));---
AP-2: Storing Derived State
Problem: Storing values that can be computed from other state creates synchronization bugs. The derived value can become stale.
// WRONG -- derived state stored separately
const [items, setItems] = useState<Item[]>([]);
const [filteredItems, setFilteredItems] = useState<Item[]>([]);
const [totalPrice, setTotalPrice] = useState<number>(0);
useEffect(() => {
setFilteredItems(items.filter(i => i.active));
}, [items]);
useEffect(() => {
setTotalPrice(filteredItems.reduce((sum, i) => sum + i.price, 0));
}, [filteredItems]);
// Bug: two extra renders, possible intermediate inconsistent stateFix: Compute during render. Use useMemo if the computation is expensive.
// CORRECT -- derive during render
const [items, setItems] = useState<Item[]>([]);
const filteredItems = useMemo(
() => items.filter(i => i.active),
[items]
);
const totalPrice = useMemo(
() => filteredItems.reduce((sum, i) => sum + i.price, 0),
[filteredItems]
);
// No extra renders, always consistent---
AP-3: Props Mirrored in State
Problem: Copying props into state causes the state to become stale when props change.
// WRONG -- props copied into state
function UserCard({ user }: { user: User }) {
const [name, setName] = useState(user.name);
// name is now stale if parent updates user.name
return <p>{name}</p>;
}Fix A: Use props directly (no state needed for display).
// CORRECT -- use prop directly
function UserCard({ user }: { user: User }) {
return <p>{user.name}</p>;
}Fix B: If the component needs to "reset" on prop change, use the key prop.
// CORRECT -- parent resets via key
<EditableUserCard key={user.id} initialName={user.name} />
function EditableUserCard({ initialName }: { initialName: string }) {
const [name, setName] = useState(initialName);
// Fresh state when key changes -- no stale data
return <input value={name} onChange={e => setName(e.target.value)} />;
}---
AP-4: Context for High-Frequency Updates
Problem: Every component that calls useContext(SomeContext) re-renders whenever the context value changes. For frequent updates (mouse position, scroll, animations), this causes performance degradation.
// WRONG -- mouse position in context updates ALL consumers
const MouseContext = createContext<{ x: number; y: number }>({ x: 0, y: 0 });
function App() {
const [pos, setPos] = useState({ x: 0, y: 0 });
return (
// Every mousemove re-renders EVERY consumer
<MouseContext value={pos}>
<div onMouseMove={e => setPos({ x: e.clientX, y: e.clientY })}>
<HundredComponents /> {/* All re-render on every mouse move */}
</div>
</MouseContext>
);
}Fix: Use an external store with selectors.
// CORRECT -- Zustand with selector, only Cursor re-renders
const useMouseStore = create<{ x: number; y: number; setPos: (x: number, y: number) => void }>(
(set) => ({
x: 0,
y: 0,
setPos: (x, y) => set({ x, y }),
})
);
function Cursor() {
const x = useMouseStore(s => s.x);
const y = useMouseStore(s => s.y);
return <div style={{ left: x, top: y }} className="cursor" />;
}
// Other components do NOT re-render
function Sidebar() {
return <nav>...</nav>; // Unaffected by mouse movement
}---
AP-5: Missing Updater Function (Stale Closure)
Problem: Using the direct value from the closure instead of the updater form causes stale state when multiple updates are batched.
// WRONG -- stale closure in rapid clicks or timeouts
function Counter() {
const [count, setCount] = useState(0);
const handleTripleIncrement = () => {
setCount(count + 1); // Uses stale count from closure
setCount(count + 1); // Same stale count
setCount(count + 1); // Result: count + 1, not count + 3
};
}Fix: ALWAYS use updater form when depending on previous state.
// CORRECT -- updater receives latest pending state
function Counter() {
const [count, setCount] = useState(0);
const handleTripleIncrement = () => {
setCount(prev => prev + 1); // prev = 0 -> 1
setCount(prev => prev + 1); // prev = 1 -> 2
setCount(prev => prev + 1); // prev = 2 -> 3
};
}---
AP-6: Overly Broad Context (God Context)
Problem: Putting all application state in a single context forces every consumer to re-render on any state change.
// WRONG -- everything in one context
const AppContext = createContext<{
user: User;
theme: Theme;
notifications: Notification[];
cart: CartItem[];
locale: string;
} | null>(null);
// Changing the theme re-renders the cart, notification list, etc.Fix: Split into focused, domain-specific contexts.
// CORRECT -- separate contexts by domain
const UserContext = createContext<UserContextType | null>(null);
const ThemeContext = createContext<ThemeContextType | null>(null);
const NotificationContext = createContext<NotificationContextType | null>(null);
const CartContext = createContext<CartContextType | null>(null);
// Theme changes only re-render ThemeContext consumers---
AP-7: New Context Value Object on Every Render
Problem: Creating a new object literal in the provider re-renders all consumers on every parent render, even if values have not changed.
// WRONG -- new object on every render
function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
return (
// { user, setUser } is a new object every render
<AuthContext value={{ user, setUser }}>
{children}
</AuthContext>
);
}Fix: ALWAYS wrap context values in useMemo.
// CORRECT -- stable reference when user hasn't changed
function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const value = useMemo(() => ({ user, setUser }), [user]);
return <AuthContext value={value}>{children}</AuthContext>;
}---
AP-8: useEffect for State Synchronization
Problem: Using useEffect to sync state from props or other state causes extra renders and is harder to reason about.
// WRONG -- useEffect to transform state
const [items, setItems] = useState<Item[]>([]);
const [sortedItems, setSortedItems] = useState<Item[]>([]);
useEffect(() => {
setSortedItems([...items].sort((a, b) => a.name.localeCompare(b.name)));
}, [items]);
// Renders twice: once with stale sortedItems, once with sortedFix: Compute synchronously during render.
// CORRECT -- computed during same render
const [items, setItems] = useState<Item[]>([]);
const sortedItems = useMemo(
() => [...items].sort((a, b) => a.name.localeCompare(b.name)),
[items]
);
// Single render with correct data---
AP-9: State for Ref-Appropriate Values
Problem: Using useState for values that do not affect rendering (timers, DOM references, previous values) causes unnecessary re-renders.
// WRONG -- state triggers re-render on every interval tick
const [intervalId, setIntervalId] = useState<number | null>(null);Fix: Use useRef for values that do not affect the visual output.
// CORRECT -- ref does not trigger re-render
const intervalRef = useRef<number | null>(null);
useEffect(() => {
intervalRef.current = window.setInterval(() => {
// ...
}, 1000);
return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
};
}, []);---
AP-10: Initializing State with Expensive Function Call
Problem: Passing a function call (not a function reference) to useState runs the computation on every render.
// WRONG -- parseData() runs on EVERY render, result discarded after first
const [data, setData] = useState(parseExpensiveData(rawInput));Fix: Pass an initializer function (no parentheses).
// CORRECT -- parseData only runs once on mount
const [data, setData] = useState(() => parseExpensiveData(rawInput));react-core-state -- Code Examples
useState Examples
Simple Counter with TypeScript
import { useState } from "react";
function Counter() {
const [count, setCount] = useState<number>(0);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(prev => prev + 1)}>Increment</button>
<button onClick={() => setCount(0)}>Reset</button>
</div>
);
}Object State with Immutable Updates
interface FormData {
name: string;
email: string;
age: number;
}
function ProfileForm() {
const [form, setForm] = useState<FormData>({
name: "",
email: "",
age: 0,
});
// ALWAYS spread the previous state when updating one field
const updateField = <K extends keyof FormData>(
field: K,
value: FormData[K]
) => {
setForm(prev => ({ ...prev, [field]: value }));
};
return (
<form>
<input
value={form.name}
onChange={e => updateField("name", e.target.value)}
/>
<input
value={form.email}
onChange={e => updateField("email", e.target.value)}
/>
<input
type="number"
value={form.age}
onChange={e => updateField("age", Number(e.target.value))}
/>
</form>
);
}Array State -- Add, Remove, Update
interface Todo {
id: number;
text: string;
done: boolean;
}
function TodoList() {
const [todos, setTodos] = useState<Todo[]>([]);
const [nextId, setNextId] = useState<number>(1);
// Add -- ALWAYS create new array
const addTodo = (text: string) => {
setTodos(prev => [...prev, { id: nextId, text, done: false }]);
setNextId(prev => prev + 1);
};
// Remove -- filter creates new array
const removeTodo = (id: number) => {
setTodos(prev => prev.filter(t => t.id !== id));
};
// Update -- map creates new array with replaced item
const toggleTodo = (id: number) => {
setTodos(prev =>
prev.map(t => (t.id === id ? { ...t, done: !t.done } : t))
);
};
return (
<ul>
{todos.map(todo => (
<li key={todo.id}>
<span
style={{ textDecoration: todo.done ? "line-through" : "none" }}
onClick={() => toggleTodo(todo.id)}
>
{todo.text}
</span>
<button onClick={() => removeTodo(todo.id)}>Delete</button>
</li>
))}
</ul>
);
}Lazy Initializer -- Expensive Computation
interface AppSettings {
theme: "light" | "dark";
fontSize: number;
locale: string;
}
function SettingsPanel() {
// The function is only called on first render
const [settings, setSettings] = useState<AppSettings>(() => {
const saved = localStorage.getItem("settings");
return saved ? JSON.parse(saved) : { theme: "light", fontSize: 14, locale: "en" };
});
// NEVER do this -- parseJSON runs on EVERY render:
// const [settings, setSettings] = useState(JSON.parse(localStorage.getItem("settings")!));
return <div>{settings.theme}</div>;
}---
useReducer Examples
Form with Validation
interface FormState {
values: { username: string; password: string };
errors: Record<string, string>;
isSubmitting: boolean;
}
type FormAction =
| { type: "SET_FIELD"; field: string; value: string }
| { type: "SET_ERROR"; field: string; error: string }
| { type: "CLEAR_ERRORS" }
| { type: "SUBMIT_START" }
| { type: "SUBMIT_SUCCESS" }
| { type: "SUBMIT_FAILURE"; errors: Record<string, string> };
function formReducer(state: FormState, action: FormAction): FormState {
switch (action.type) {
case "SET_FIELD":
return {
...state,
values: { ...state.values, [action.field]: action.value },
errors: { ...state.errors, [action.field]: "" },
};
case "SET_ERROR":
return {
...state,
errors: { ...state.errors, [action.field]: action.error },
};
case "CLEAR_ERRORS":
return { ...state, errors: {} };
case "SUBMIT_START":
return { ...state, isSubmitting: true, errors: {} };
case "SUBMIT_SUCCESS":
return { ...state, isSubmitting: false };
case "SUBMIT_FAILURE":
return { ...state, isSubmitting: false, errors: action.errors };
}
}
function LoginForm() {
const [state, dispatch] = useReducer(formReducer, {
values: { username: "", password: "" },
errors: {},
isSubmitting: false,
});
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
dispatch({ type: "SUBMIT_START" });
try {
await loginAPI(state.values);
dispatch({ type: "SUBMIT_SUCCESS" });
} catch (err) {
dispatch({ type: "SUBMIT_FAILURE", errors: { form: "Login failed" } });
}
};
return (
<form onSubmit={handleSubmit}>
<input
value={state.values.username}
onChange={e => dispatch({ type: "SET_FIELD", field: "username", value: e.target.value })}
/>
{state.errors.username && <span>{state.errors.username}</span>}
<button disabled={state.isSubmitting}>
{state.isSubmitting ? "Logging in..." : "Log In"}
</button>
</form>
);
}Shopping Cart Reducer
interface CartItem {
id: string;
name: string;
price: number;
quantity: number;
}
interface CartState {
items: CartItem[];
}
type CartAction =
| { type: "ADD_ITEM"; item: Omit<CartItem, "quantity"> }
| { type: "REMOVE_ITEM"; id: string }
| { type: "UPDATE_QUANTITY"; id: string; quantity: number }
| { type: "CLEAR_CART" };
function cartReducer(state: CartState, action: CartAction): CartState {
switch (action.type) {
case "ADD_ITEM": {
const existing = state.items.find(i => i.id === action.item.id);
if (existing) {
return {
items: state.items.map(i =>
i.id === action.item.id ? { ...i, quantity: i.quantity + 1 } : i
),
};
}
return { items: [...state.items, { ...action.item, quantity: 1 }] };
}
case "REMOVE_ITEM":
return { items: state.items.filter(i => i.id !== action.id) };
case "UPDATE_QUANTITY":
return {
items: state.items.map(i =>
i.id === action.id ? { ...i, quantity: action.quantity } : i
),
};
case "CLEAR_CART":
return { items: [] };
}
}
// Derived state -- computed during render, NEVER stored
function CartSummary({ items }: { items: CartItem[] }) {
const total = items.reduce((sum, i) => sum + i.price * i.quantity, 0);
const itemCount = items.reduce((sum, i) => sum + i.quantity, 0);
return (
<div>
<p>{itemCount} items</p>
<p>Total: ${total.toFixed(2)}</p>
</div>
);
}---
Lifting State Up
// State lives in the closest common ancestor
function SearchPage() {
const [query, setQuery] = useState<string>("");
return (
<div>
<SearchBar query={query} onQueryChange={setQuery} />
<SearchResults query={query} />
</div>
);
}
// Child receives state and updater via props
function SearchBar({
query,
onQueryChange,
}: {
query: string;
onQueryChange: (q: string) => void;
}) {
return (
<input
value={query}
onChange={e => onQueryChange(e.target.value)}
placeholder="Search..."
/>
);
}
function SearchResults({ query }: { query: string }) {
const filtered = useMemo(
() => allItems.filter(item => item.name.includes(query)),
[query]
);
return (
<ul>
{filtered.map(item => (
<li key={item.id}>{item.name}</li>
))}
</ul>
);
}---
Derived State -- Compute, NEVER Store
// CORRECT: Compute during render
function FilteredList({ items, filter }: { items: Item[]; filter: string }) {
const filteredItems = useMemo(
() => items.filter(i => i.category === filter),
[items, filter]
);
const count = filteredItems.length; // Also derived -- no state needed
return <p>{count} items match "{filter}"</p>;
}
// WRONG: Storing derived data in state
function FilteredListBad({ items, filter }: { items: Item[]; filter: string }) {
const [filteredItems, setFilteredItems] = useState<Item[]>([]);
// This useEffect is unnecessary and creates sync bugs
useEffect(() => {
setFilteredItems(items.filter(i => i.category === filter));
}, [items, filter]);
return <p>{filteredItems.length} items</p>;
}---
React 19: useActionState
import { useActionState } from "react";
interface SubmitState {
message: string;
error: string | null;
}
async function submitForm(
prevState: SubmitState,
formData: FormData
): Promise<SubmitState> {
const name = formData.get("name") as string;
try {
await saveToServer({ name });
return { message: `Saved ${name}`, error: null };
} catch {
return { message: "", error: "Save failed" };
}
}
function MyForm() {
const [state, formAction, isPending] = useActionState(submitForm, {
message: "",
error: null,
});
return (
<form action={formAction}>
<input name="name" required />
<button disabled={isPending}>
{isPending ? "Saving..." : "Save"}
</button>
{state.error && <p className="error">{state.error}</p>}
{state.message && <p className="success">{state.message}</p>}
</form>
);
}---
React 19: useOptimistic
import { useOptimistic, startTransition } from "react";
interface Message {
id: string;
text: string;
sending?: boolean;
}
function Chat({ messages }: { messages: Message[] }) {
const [optimisticMessages, addOptimisticMessage] = useOptimistic(
messages,
(state: Message[], newMessage: Message) => [...state, { ...newMessage, sending: true }]
);
const sendMessage = async (text: string) => {
startTransition(async () => {
addOptimisticMessage({ id: crypto.randomUUID(), text });
await postMessageToServer(text);
// On success, parent re-renders with new messages prop
// On failure, optimistic message automatically disappears
});
};
return (
<div>
{optimisticMessages.map(msg => (
<div key={msg.id} style={{ opacity: msg.sending ? 0.5 : 1 }}>
{msg.text}
</div>
))}
</div>
);
}react-core-state -- State Architecture Patterns
Pattern 1: Context with Split Providers
Split unrelated state into separate contexts. This prevents unrelated re-renders.
// ALWAYS split contexts by concern -- NEVER put unrelated state in one context
// auth-context.ts
interface AuthContextType {
user: User | null;
login: (credentials: Credentials) => Promise<void>;
logout: () => void;
}
const AuthContext = createContext<AuthContextType | null>(null);
function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const login = useCallback(async (credentials: Credentials) => {
const user = await authenticateAPI(credentials);
setUser(user);
}, []);
const logout = useCallback(() => {
setUser(null);
}, []);
// ALWAYS memoize context value to prevent consumer re-renders
const value = useMemo(() => ({ user, login, logout }), [user, login, logout]);
return <AuthContext value={value}>{children}</AuthContext>;
}
function useAuth(): AuthContextType {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth must be used within AuthProvider");
return ctx;
}
// theme-context.ts -- separate from auth
interface ThemeContextType {
theme: "light" | "dark";
toggleTheme: () => void;
}
const ThemeContext = createContext<ThemeContextType | null>(null);
function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<"light" | "dark">("light");
const toggleTheme = useCallback(() => {
setTheme(prev => (prev === "light" ? "dark" : "light"));
}, []);
const value = useMemo(() => ({ theme, toggleTheme }), [theme, toggleTheme]);
return <ThemeContext value={value}>{children}</ThemeContext>;
}Composing Providers
// Compose providers at the app root
function App() {
return (
<AuthProvider>
<ThemeProvider>
<NotificationProvider>
<Router />
</NotificationProvider>
</ThemeProvider>
</AuthProvider>
);
}---
Pattern 2: Context + useReducer for Complex Domain State
For state with many transitions, combine Context with useReducer.
interface AppState {
notifications: Notification[];
sidebar: { open: boolean; activeTab: string };
}
type AppAction =
| { type: "ADD_NOTIFICATION"; notification: Notification }
| { type: "DISMISS_NOTIFICATION"; id: string }
| { type: "TOGGLE_SIDEBAR" }
| { type: "SET_SIDEBAR_TAB"; tab: string };
function appReducer(state: AppState, action: AppAction): AppState {
switch (action.type) {
case "ADD_NOTIFICATION":
return { ...state, notifications: [...state.notifications, action.notification] };
case "DISMISS_NOTIFICATION":
return {
...state,
notifications: state.notifications.filter(n => n.id !== action.id),
};
case "TOGGLE_SIDEBAR":
return { ...state, sidebar: { ...state.sidebar, open: !state.sidebar.open } };
case "SET_SIDEBAR_TAB":
return { ...state, sidebar: { ...state.sidebar, activeTab: action.tab } };
}
}
// Split state and dispatch into separate contexts
// This way, components that only dispatch don't re-render on state changes
const AppStateContext = createContext<AppState | null>(null);
const AppDispatchContext = createContext<React.Dispatch<AppAction> | null>(null);
function AppStateProvider({ children }: { children: ReactNode }) {
const [state, dispatch] = useReducer(appReducer, {
notifications: [],
sidebar: { open: false, activeTab: "home" },
});
return (
<AppStateContext value={state}>
<AppDispatchContext value={dispatch}>
{children}
</AppDispatchContext>
</AppStateContext>
);
}
// Typed hooks
function useAppState(): AppState {
const ctx = useContext(AppStateContext);
if (!ctx) throw new Error("useAppState must be used within AppStateProvider");
return ctx;
}
function useAppDispatch(): React.Dispatch<AppAction> {
const ctx = useContext(AppDispatchContext);
if (!ctx) throw new Error("useAppDispatch must be used within AppStateProvider");
return ctx;
}---
Pattern 3: Zustand -- Lightweight External Store
Zustand provides selector-based subscriptions. Components ONLY re-render when their selected slice changes.
import { create } from "zustand";
import { devtools, persist } from "zustand/middleware";
interface BearStore {
bears: number;
fish: number;
addBear: () => void;
addFish: () => void;
reset: () => void;
}
const useBearStore = create<BearStore>()(
devtools(
persist(
(set) => ({
bears: 0,
fish: 0,
addBear: () => set((s) => ({ bears: s.bears + 1 })),
addFish: () => set((s) => ({ fish: s.fish + 1 })),
reset: () => set({ bears: 0, fish: 0 }),
}),
{ name: "bear-storage" } // localStorage key
)
)
);
// ALWAYS use selectors -- component only re-renders when bears changes
function BearCount() {
const bears = useBearStore((s) => s.bears);
return <span>{bears} bears</span>;
}
// This component does NOT re-render when bears changes
function FishCount() {
const fish = useBearStore((s) => s.fish);
return <span>{fish} fish</span>;
}
// Actions can be used outside React components
function resetFromAPI() {
useBearStore.getState().reset();
}Zustand with Async Actions
interface DataStore {
data: Item[] | null;
loading: boolean;
error: string | null;
fetchData: () => Promise<void>;
}
const useDataStore = create<DataStore>()((set) => ({
data: null,
loading: false,
error: null,
fetchData: async () => {
set({ loading: true, error: null });
try {
const data = await fetchAPI<Item[]>("/items");
set({ data, loading: false });
} catch (err) {
set({ error: (err as Error).message, loading: false });
}
},
}));---
Pattern 4: Jotai -- Atomic State
Jotai uses atoms (bottom-up approach). Each atom is an independent piece of state.
import { atom, useAtom, useAtomValue, useSetAtom } from "jotai";
// Primitive atoms
const countAtom = atom<number>(0);
const nameAtom = atom<string>("React");
// Derived atom (read-only) -- computed from other atoms
const doubleCountAtom = atom<number>((get) => get(countAtom) * 2);
// Writable derived atom
const uppercaseNameAtom = atom(
(get) => get(nameAtom).toUpperCase(),
(_get, set, newName: string) => set(nameAtom, newName.toLowerCase())
);
// Async derived atom
const userAtom = atom(async (get) => {
const id = get(userIdAtom);
const response = await fetch(`/api/users/${id}`);
return response.json() as Promise<User>;
});
// Usage in components
function Counter() {
const [count, setCount] = useAtom(countAtom);
const doubleCount = useAtomValue(doubleCountAtom); // read-only
const setName = useSetAtom(nameAtom); // write-only (no re-render on name change)
return (
<div>
<p>{count} (double: {doubleCount})</p>
<button onClick={() => setCount(prev => prev + 1)}>+1</button>
</div>
);
}---
Pattern 5: Server State with TanStack Query
Server state has different concerns than client state: caching, revalidation, deduplication, background refetching.
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
interface Todo {
id: number;
title: string;
completed: boolean;
}
// Fetch -- automatic caching and deduplication
function useTodos() {
return useQuery<Todo[]>({
queryKey: ["todos"],
queryFn: () => fetch("/api/todos").then(r => r.json()),
staleTime: 5 * 60 * 1000, // 5 minutes before refetch
});
}
// Mutate -- with optimistic update
function useToggleTodo() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (todo: Todo) =>
fetch(`/api/todos/${todo.id}`, {
method: "PATCH",
body: JSON.stringify({ completed: !todo.completed }),
}).then(r => r.json()),
// Optimistic update
onMutate: async (todo) => {
await queryClient.cancelQueries({ queryKey: ["todos"] });
const previous = queryClient.getQueryData<Todo[]>(["todos"]);
queryClient.setQueryData<Todo[]>(["todos"], (old) =>
old?.map(t =>
t.id === todo.id ? { ...t, completed: !t.completed } : t
)
);
return { previous };
},
// Rollback on error
onError: (_err, _todo, context) => {
if (context?.previous) {
queryClient.setQueryData(["todos"], context.previous);
}
},
// Refetch after settle
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["todos"] });
},
});
}
// Usage
function TodoList() {
const { data: todos, isLoading, error } = useTodos();
const toggleMutation = useToggleTodo();
if (isLoading) return <p>Loading...</p>;
if (error) return <p>Error: {error.message}</p>;
return (
<ul>
{todos?.map(todo => (
<li key={todo.id} onClick={() => toggleMutation.mutate(todo)}>
{todo.completed ? "[x]" : "[ ]"} {todo.title}
</li>
))}
</ul>
);
}---
Pattern 6: State Reset via Key Prop
ALWAYS use the key prop to reset component state when the identity changes. This is cleaner than useEffect resets.
// Parent controls identity via key
function UserProfile({ userId }: { userId: string }) {
// When userId changes, React unmounts old EditForm and mounts a fresh one
return <EditForm key={userId} userId={userId} />;
}
// EditForm starts fresh for each userId -- no stale state
function EditForm({ userId }: { userId: string }) {
const [draft, setDraft] = useState<string>("");
// draft is always "" when userId changes -- no useEffect needed
return <input value={draft} onChange={e => setDraft(e.target.value)} />;
}---
Pattern 7: State Colocation
Keep state as close as possible to where it is used. Only lift when necessary.
App
├── Header (uses: theme) -------- theme lives in ThemeContext (shared)
├── Sidebar (uses: isOpen) ------ isOpen lives in Sidebar (local)
├── MainContent
│ ├── SearchBar (uses: query) -- query lifted to MainContent
│ └── ResultsList (uses: query) -- reads from MainContent
└── Footer (no state) ----------- pure presentationalRule: If only one component uses a piece of state, it MUST be local. Lift ONLY when a sibling or parent also needs it.