mirror of
https://github.com/duthaho/claudekit.git
synced 2026-07-18 21:49:41 +03:00
feat: improved the Claude Kit as a plugin
This commit is contained in:
@@ -0,0 +1,779 @@
|
||||
# State Management — Patterns
|
||||
|
||||
|
||||
# State Management Patterns
|
||||
|
||||
## When to Use
|
||||
|
||||
- Choosing between local, shared, or global state in a React application
|
||||
- Setting up server state caching with TanStack Query or SWR
|
||||
- Building forms with validation, arrays, and nested fields
|
||||
- Syncing application state with URL search parameters
|
||||
- Designing Python domain models with dataclasses or Pydantic
|
||||
- Refactoring prop-drilling into a shared store
|
||||
- Deciding whether to add a state management library or keep things simple
|
||||
|
||||
## When NOT to Use
|
||||
|
||||
- Static sites with no interactive state (pure content pages, docs)
|
||||
- Server-only rendering with no client-side interactivity
|
||||
- Simple CRUD backends where database is the only source of truth and there is no in-process state to manage
|
||||
|
||||
---
|
||||
|
||||
## Core Patterns
|
||||
|
||||
### 1. Local vs Global State Decision Tree
|
||||
|
||||
Before reaching for a library, walk through this decision tree.
|
||||
|
||||
```
|
||||
Is the state used by a single component?
|
||||
├── YES --> useState or useReducer
|
||||
└── NO
|
||||
Is it shared by a parent and 1-2 direct children?
|
||||
├── YES --> Lift state up to the common parent, pass via props
|
||||
└── NO
|
||||
Is it server data (fetched from an API)?
|
||||
├── YES --> TanStack Query (useQuery / useMutation)
|
||||
└── NO
|
||||
Is it URL-representable (filters, pagination, tabs)?
|
||||
├── YES --> URL state (useSearchParams / nuqs)
|
||||
└── NO
|
||||
Is it form data with validation?
|
||||
├── YES --> react-hook-form + zod
|
||||
└── NO
|
||||
Zustand store (or Jotai for atomic state)
|
||||
```
|
||||
|
||||
**Rules of thumb:**
|
||||
|
||||
- Start with the simplest option. Only add a library when props become painful.
|
||||
- Server state and client state are different concerns. Never put fetched API data in Zustand; use TanStack Query instead.
|
||||
- URL state is free persistence. If the user should be able to bookmark or share the current view, put it in the URL.
|
||||
- Form state belongs to the form library. Do not mirror react-hook-form values in a Zustand store.
|
||||
|
||||
---
|
||||
|
||||
### 2. React State Patterns
|
||||
|
||||
**useState for simple values**
|
||||
|
||||
```typescript
|
||||
function Counter() {
|
||||
const [count, setCount] = useState(0);
|
||||
|
||||
return (
|
||||
<button onClick={() => setCount((prev) => prev + 1)}>
|
||||
Count: {count}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**useReducer for complex state with multiple transitions**
|
||||
|
||||
```typescript
|
||||
interface TimerState {
|
||||
status: "idle" | "running" | "paused";
|
||||
elapsed: number;
|
||||
}
|
||||
|
||||
type TimerAction =
|
||||
| { type: "start" }
|
||||
| { type: "pause" }
|
||||
| { type: "reset" }
|
||||
| { type: "tick" };
|
||||
|
||||
function timerReducer(state: TimerState, action: TimerAction): TimerState {
|
||||
switch (action.type) {
|
||||
case "start":
|
||||
return { ...state, status: "running" };
|
||||
case "pause":
|
||||
return { ...state, status: "paused" };
|
||||
case "reset":
|
||||
return { status: "idle", elapsed: 0 };
|
||||
case "tick":
|
||||
return state.status === "running"
|
||||
? { ...state, elapsed: state.elapsed + 1 }
|
||||
: state;
|
||||
}
|
||||
}
|
||||
|
||||
function Timer() {
|
||||
const [state, dispatch] = useReducer(timerReducer, {
|
||||
status: "idle",
|
||||
elapsed: 0,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (state.status !== "running") return;
|
||||
const id = setInterval(() => dispatch({ type: "tick" }), 1000);
|
||||
return () => clearInterval(id);
|
||||
}, [state.status]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p>{state.elapsed}s</p>
|
||||
{state.status !== "running" && (
|
||||
<button onClick={() => dispatch({ type: "start" })}>Start</button>
|
||||
)}
|
||||
{state.status === "running" && (
|
||||
<button onClick={() => dispatch({ type: "pause" })}>Pause</button>
|
||||
)}
|
||||
<button onClick={() => dispatch({ type: "reset" })}>Reset</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**When to pick which:**
|
||||
|
||||
| Criteria | useState | useReducer |
|
||||
|----------|----------|------------|
|
||||
| Single primitive value | Yes | Overkill |
|
||||
| Multiple related fields | Possible | Preferred |
|
||||
| Complex transitions | Messy | Clean |
|
||||
| Needs testing in isolation | Hard | Easy (test the reducer) |
|
||||
|
||||
---
|
||||
|
||||
### 3. Global State (Zustand)
|
||||
|
||||
Zustand is lightweight, TypeScript-friendly, and avoids the boilerplate of Redux.
|
||||
|
||||
**Basic store**
|
||||
|
||||
```typescript
|
||||
import { create } from "zustand";
|
||||
|
||||
interface AuthStore {
|
||||
user: User | null;
|
||||
token: string | null;
|
||||
login: (user: User, token: string) => void;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
const useAuthStore = create<AuthStore>((set) => ({
|
||||
user: null,
|
||||
token: null,
|
||||
login: (user, token) => set({ user, token }),
|
||||
logout: () => set({ user: null, token: null }),
|
||||
}));
|
||||
|
||||
// In components - use selectors to avoid unnecessary re-renders
|
||||
function UserMenu() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const logout = useAuthStore((s) => s.logout);
|
||||
|
||||
if (!user) return <LoginButton />;
|
||||
return (
|
||||
<div>
|
||||
<span>{user.name}</span>
|
||||
<button onClick={logout}>Log out</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Slices pattern for large stores**
|
||||
|
||||
```typescript
|
||||
import { create, type StateCreator } from "zustand";
|
||||
import { devtools, persist } from "zustand/middleware";
|
||||
|
||||
// Each slice is its own interface + creator
|
||||
interface CartSlice {
|
||||
items: CartItem[];
|
||||
addItem: (item: CartItem) => void;
|
||||
removeItem: (id: string) => void;
|
||||
clearCart: () => void;
|
||||
}
|
||||
|
||||
interface UISlice {
|
||||
sidebarOpen: boolean;
|
||||
toggleSidebar: () => void;
|
||||
}
|
||||
|
||||
const createCartSlice: StateCreator<
|
||||
CartSlice & UISlice,
|
||||
[],
|
||||
[],
|
||||
CartSlice
|
||||
> = (set) => ({
|
||||
items: [],
|
||||
addItem: (item) =>
|
||||
set((state) => ({ items: [...state.items, item] })),
|
||||
removeItem: (id) =>
|
||||
set((state) => ({ items: state.items.filter((i) => i.id !== id) })),
|
||||
clearCart: () => set({ items: [] }),
|
||||
});
|
||||
|
||||
const createUISlice: StateCreator<
|
||||
CartSlice & UISlice,
|
||||
[],
|
||||
[],
|
||||
UISlice
|
||||
> = (set) => ({
|
||||
sidebarOpen: false,
|
||||
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
|
||||
});
|
||||
|
||||
// Combine slices with middleware
|
||||
const useAppStore = create<CartSlice & UISlice>()(
|
||||
devtools(
|
||||
persist(
|
||||
(...args) => ({
|
||||
...createCartSlice(...args),
|
||||
...createUISlice(...args),
|
||||
}),
|
||||
{
|
||||
name: "app-store",
|
||||
partialize: (state) => ({ items: state.items }), // only persist cart
|
||||
}
|
||||
)
|
||||
)
|
||||
);
|
||||
```
|
||||
|
||||
**Zustand best practices:**
|
||||
|
||||
- Always use selectors (`useStore((s) => s.field)`) instead of the whole store.
|
||||
- Keep stores small and focused. One store per domain, not one mega-store.
|
||||
- Use `persist` middleware for state that should survive page reloads.
|
||||
- Use `devtools` middleware in development for Redux DevTools integration.
|
||||
|
||||
---
|
||||
|
||||
### 4. Server State (TanStack Query)
|
||||
|
||||
Server state (data from APIs) has different needs than client state: caching, background refetching, deduplication, pagination.
|
||||
|
||||
**Basic query**
|
||||
|
||||
```typescript
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
// Query key conventions: [entity, ...params]
|
||||
const userKeys = {
|
||||
all: ["users"] as const,
|
||||
list: (filters: UserFilters) => ["users", "list", filters] as const,
|
||||
detail: (id: string) => ["users", "detail", id] as const,
|
||||
};
|
||||
|
||||
function useUser(id: string) {
|
||||
return useQuery({
|
||||
queryKey: userKeys.detail(id),
|
||||
queryFn: () => api.users.get(id),
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
});
|
||||
}
|
||||
|
||||
function UserProfile({ id }: { id: string }) {
|
||||
const { data: user, isLoading, error } = useUser(id);
|
||||
|
||||
if (isLoading) return <Skeleton />;
|
||||
if (error) return <ErrorMessage error={error} />;
|
||||
|
||||
return <div>{user.name}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
**Mutations with optimistic updates**
|
||||
|
||||
```typescript
|
||||
function useUpdateUser() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: UpdateUserInput) => api.users.update(data.id, data),
|
||||
onMutate: async (newData) => {
|
||||
// Cancel outgoing refetches
|
||||
await queryClient.cancelQueries({
|
||||
queryKey: userKeys.detail(newData.id),
|
||||
});
|
||||
|
||||
// Snapshot current value for rollback
|
||||
const previous = queryClient.getQueryData(userKeys.detail(newData.id));
|
||||
|
||||
// Optimistically update
|
||||
queryClient.setQueryData(userKeys.detail(newData.id), (old: User) => ({
|
||||
...old,
|
||||
...newData,
|
||||
}));
|
||||
|
||||
return { previous };
|
||||
},
|
||||
onError: (_err, newData, context) => {
|
||||
// Rollback on failure
|
||||
if (context?.previous) {
|
||||
queryClient.setQueryData(
|
||||
userKeys.detail(newData.id),
|
||||
context.previous
|
||||
);
|
||||
}
|
||||
},
|
||||
onSettled: (_data, _err, variables) => {
|
||||
// Always refetch after mutation to ensure consistency
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: userKeys.detail(variables.id),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**Prefetching for instant navigation**
|
||||
|
||||
```typescript
|
||||
function UserListItem({ user }: { user: UserSummary }) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const prefetch = () => {
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: userKeys.detail(user.id),
|
||||
queryFn: () => api.users.get(user.id),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={`/users/${user.id}`}
|
||||
onMouseEnter={prefetch}
|
||||
onFocus={prefetch}
|
||||
>
|
||||
{user.name}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5. Form State
|
||||
|
||||
Use react-hook-form for performance (uncontrolled inputs) and zod for schema validation.
|
||||
|
||||
**Basic form with validation**
|
||||
|
||||
```typescript
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
|
||||
const createUserSchema = z.object({
|
||||
name: z.string().min(1, "Name is required").max(100),
|
||||
email: z.string().email("Invalid email address"),
|
||||
role: z.enum(["admin", "editor", "viewer"]),
|
||||
notifications: z.boolean().default(true),
|
||||
});
|
||||
|
||||
type CreateUserForm = z.infer<typeof createUserSchema>;
|
||||
|
||||
function CreateUserForm() {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<CreateUserForm>({
|
||||
resolver: zodResolver(createUserSchema),
|
||||
defaultValues: {
|
||||
role: "viewer",
|
||||
notifications: true,
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (data: CreateUserForm) => {
|
||||
await api.users.create(data);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div>
|
||||
<label htmlFor="name">Name</label>
|
||||
<input id="name" {...register("name")} />
|
||||
{errors.name && <p className="text-red-500">{errors.name.message}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="email">Email</label>
|
||||
<input id="email" type="email" {...register("email")} />
|
||||
{errors.email && <p className="text-red-500">{errors.email.message}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="role">Role</label>
|
||||
<select id="role" {...register("role")}>
|
||||
<option value="viewer">Viewer</option>
|
||||
<option value="editor">Editor</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? "Creating..." : "Create User"}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Dynamic field arrays**
|
||||
|
||||
```typescript
|
||||
import { useFieldArray } from "react-hook-form";
|
||||
|
||||
const orderSchema = z.object({
|
||||
customer: z.string().min(1),
|
||||
items: z
|
||||
.array(
|
||||
z.object({
|
||||
productId: z.string().min(1),
|
||||
quantity: z.number().int().min(1).max(999),
|
||||
})
|
||||
)
|
||||
.min(1, "At least one item is required"),
|
||||
});
|
||||
|
||||
type OrderForm = z.infer<typeof orderSchema>;
|
||||
|
||||
function OrderForm() {
|
||||
const { register, control, handleSubmit } = useForm<OrderForm>({
|
||||
resolver: zodResolver(orderSchema),
|
||||
defaultValues: { items: [{ productId: "", quantity: 1 }] },
|
||||
});
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control,
|
||||
name: "items",
|
||||
});
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
{fields.map((field, index) => (
|
||||
<div key={field.id} className="flex gap-2">
|
||||
<input
|
||||
{...register(`items.${index}.productId`)}
|
||||
placeholder="Product ID"
|
||||
/>
|
||||
<input
|
||||
{...register(`items.${index}.quantity`, { valueAsNumber: true })}
|
||||
type="number"
|
||||
min={1}
|
||||
/>
|
||||
<button type="button" onClick={() => remove(index)}>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => append({ productId: "", quantity: 1 })}
|
||||
>
|
||||
Add item
|
||||
</button>
|
||||
<button type="submit">Place order</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 6. URL State
|
||||
|
||||
Encode filters, pagination, and view settings in the URL so users can bookmark and share.
|
||||
|
||||
**Using nuqs (type-safe URL search params)**
|
||||
|
||||
```typescript
|
||||
import { useQueryState, parseAsInteger, parseAsStringEnum } from "nuqs";
|
||||
|
||||
const sortOptions = ["name", "date", "price"] as const;
|
||||
|
||||
function ProductList() {
|
||||
const [search, setSearch] = useQueryState("q", { defaultValue: "" });
|
||||
const [page, setPage] = useQueryState("page", parseAsInteger.withDefault(1));
|
||||
const [sort, setSort] = useQueryState(
|
||||
"sort",
|
||||
parseAsStringEnum(sortOptions).withDefault("name")
|
||||
);
|
||||
|
||||
// URL looks like: /products?q=shoes&page=2&sort=price
|
||||
const { data } = useQuery({
|
||||
queryKey: ["products", { search, page, sort }],
|
||||
queryFn: () => api.products.list({ search, page, sort }),
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value || null)}
|
||||
placeholder="Search products..."
|
||||
/>
|
||||
<select
|
||||
value={sort}
|
||||
onChange={(e) => setSort(e.target.value as typeof sort)}
|
||||
>
|
||||
{sortOptions.map((opt) => (
|
||||
<option key={opt} value={opt}>
|
||||
{opt}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<ProductGrid products={data?.items ?? []} />
|
||||
|
||||
<Pagination
|
||||
page={page}
|
||||
total={data?.totalPages ?? 1}
|
||||
onChange={setPage}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Using React Router useSearchParams**
|
||||
|
||||
```typescript
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
|
||||
function FilteredList() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const status = searchParams.get("status") ?? "all";
|
||||
const page = Number(searchParams.get("page") ?? "1");
|
||||
|
||||
const updateFilter = (key: string, value: string | null) => {
|
||||
setSearchParams((prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
if (value === null) {
|
||||
next.delete(key);
|
||||
} else {
|
||||
next.set(key, value);
|
||||
}
|
||||
// Reset page when filter changes
|
||||
if (key !== "page") next.set("page", "1");
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<select
|
||||
value={status}
|
||||
onChange={(e) => updateFilter("status", e.target.value)}
|
||||
>
|
||||
<option value="all">All</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="archived">Archived</option>
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 7. Python State
|
||||
|
||||
Use dataclasses for lightweight domain objects and Pydantic for validated external data. Combine with the repository pattern for persistence.
|
||||
|
||||
**Dataclasses for domain objects**
|
||||
|
||||
```python
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
|
||||
class OrderStatus(str, Enum):
|
||||
DRAFT = "draft"
|
||||
CONFIRMED = "confirmed"
|
||||
SHIPPED = "shipped"
|
||||
DELIVERED = "delivered"
|
||||
CANCELLED = "cancelled"
|
||||
|
||||
|
||||
@dataclass
|
||||
class OrderItem:
|
||||
product_id: str
|
||||
quantity: int
|
||||
unit_price: float
|
||||
|
||||
@property
|
||||
def total(self) -> float:
|
||||
return self.quantity * self.unit_price
|
||||
|
||||
|
||||
@dataclass
|
||||
class Order:
|
||||
customer_id: str
|
||||
items: list[OrderItem] = field(default_factory=list)
|
||||
id: UUID = field(default_factory=uuid4)
|
||||
status: OrderStatus = OrderStatus.DRAFT
|
||||
created_at: datetime = field(default_factory=datetime.utcnow)
|
||||
|
||||
@property
|
||||
def subtotal(self) -> float:
|
||||
return sum(item.total for item in self.items)
|
||||
|
||||
def confirm(self) -> None:
|
||||
if self.status != OrderStatus.DRAFT:
|
||||
raise ValueError(f"Cannot confirm order in '{self.status}' state")
|
||||
if not self.items:
|
||||
raise ValueError("Cannot confirm an empty order")
|
||||
self.status = OrderStatus.CONFIRMED
|
||||
|
||||
def cancel(self) -> None:
|
||||
if self.status in (OrderStatus.DELIVERED, OrderStatus.CANCELLED):
|
||||
raise ValueError(f"Cannot cancel order in '{self.status}' state")
|
||||
self.status = OrderStatus.CANCELLED
|
||||
```
|
||||
|
||||
**Pydantic for validated external input**
|
||||
|
||||
```python
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
|
||||
class CreateOrderRequest(BaseModel):
|
||||
customer_id: str = Field(min_length=1, max_length=50)
|
||||
items: list["OrderItemInput"] = Field(min_length=1)
|
||||
|
||||
@field_validator("items")
|
||||
@classmethod
|
||||
def no_duplicate_products(cls, items: list["OrderItemInput"]) -> list["OrderItemInput"]:
|
||||
product_ids = [item.product_id for item in items]
|
||||
if len(product_ids) != len(set(product_ids)):
|
||||
raise ValueError("Duplicate product IDs are not allowed")
|
||||
return items
|
||||
|
||||
|
||||
class OrderItemInput(BaseModel):
|
||||
product_id: str = Field(min_length=1)
|
||||
quantity: int = Field(ge=1, le=999)
|
||||
```
|
||||
|
||||
**Repository pattern for persistence**
|
||||
|
||||
```python
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
|
||||
class OrderRepository(ABC):
|
||||
@abstractmethod
|
||||
async def save(self, order: Order) -> None: ...
|
||||
|
||||
@abstractmethod
|
||||
async def get(self, order_id: UUID) -> Order | None: ...
|
||||
|
||||
@abstractmethod
|
||||
async def list_by_customer(self, customer_id: str) -> list[Order]: ...
|
||||
|
||||
|
||||
class PostgresOrderRepository(OrderRepository):
|
||||
def __init__(self, pool) -> None:
|
||||
self.pool = pool
|
||||
|
||||
async def save(self, order: Order) -> None:
|
||||
async with self.pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO orders (id, customer_id, status, created_at)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (id) DO UPDATE SET status = $3
|
||||
""",
|
||||
order.id,
|
||||
order.customer_id,
|
||||
order.status.value,
|
||||
order.created_at,
|
||||
)
|
||||
# Upsert items...
|
||||
|
||||
async def get(self, order_id: UUID) -> Order | None:
|
||||
async with self.pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"SELECT * FROM orders WHERE id = $1", order_id
|
||||
)
|
||||
if row is None:
|
||||
return None
|
||||
items = await conn.fetch(
|
||||
"SELECT * FROM order_items WHERE order_id = $1", order_id
|
||||
)
|
||||
return self._row_to_order(row, items)
|
||||
|
||||
async def list_by_customer(self, customer_id: str) -> list[Order]:
|
||||
async with self.pool.acquire() as conn:
|
||||
rows = await conn.fetch(
|
||||
"SELECT * FROM orders WHERE customer_id = $1 ORDER BY created_at DESC",
|
||||
customer_id,
|
||||
)
|
||||
# Fetch items for each order...
|
||||
return [self._row_to_order(row, items) for row, items in results]
|
||||
|
||||
def _row_to_order(self, row, item_rows) -> Order:
|
||||
return Order(
|
||||
id=row["id"],
|
||||
customer_id=row["customer_id"],
|
||||
status=OrderStatus(row["status"]),
|
||||
created_at=row["created_at"],
|
||||
items=[
|
||||
OrderItem(
|
||||
product_id=r["product_id"],
|
||||
quantity=r["quantity"],
|
||||
unit_price=float(r["unit_price"]),
|
||||
)
|
||||
for r in item_rows
|
||||
],
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Start local, promote when needed.** Begin with `useState`. Only move state up or into a store when two or more unrelated components need the same data. Premature globalization makes refactoring painful.
|
||||
|
||||
2. **Separate server state from client state.** Use TanStack Query (or SWR) for anything fetched from an API. These libraries handle caching, deduplication, background refetching, and stale-while-revalidate. Do not duplicate fetched data in Zustand.
|
||||
|
||||
3. **Use selectors to prevent re-renders.** In Zustand, always select the specific field you need: `useStore((s) => s.count)`, not `useStore()`. The latter re-renders on every store change.
|
||||
|
||||
4. **Co-locate state with the component that owns it.** If only `<Sidebar>` uses `isOpen`, keep that state inside `<Sidebar>`. Moving it to a global store just because "it might be needed later" creates unnecessary coupling.
|
||||
|
||||
5. **Derive, do not duplicate.** If `fullName` can be computed from `firstName` and `lastName`, compute it on the fly or with `useMemo`. Storing derived values introduces synchronization bugs.
|
||||
|
||||
6. **Validate at the boundary, trust internally.** Use Pydantic or Zod to validate data when it enters the system (API requests, form submissions, external events). Once validated, pass typed objects without re-checking.
|
||||
|
||||
7. **Keep URL state minimal.** Only encode values the user would want to bookmark or share: active tab, search query, page number, sort column. Do not put ephemeral UI state (hover, open dropdown) in the URL.
|
||||
|
||||
8. **Treat form state as its own domain.** Let react-hook-form manage form values, dirty tracking, and validation. Submit the validated result to your mutation or API call. Do not synchronize form fields with external stores.
|
||||
|
||||
---
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
1. **Putting everything in global state.** Not all state needs to be global. A modal's open/closed state, an input's current text, or a component's loading spinner should stay local. Global stores should hold state that genuinely needs to be shared across distant parts of the tree.
|
||||
|
||||
2. **Storing server data in Zustand.** Zustand has no built-in cache invalidation, stale detection, or background refetch. Using it for API data means you are rebuilding TanStack Query poorly. Use the right tool for the job.
|
||||
|
||||
3. **Forgetting to invalidate queries after mutations.** After a `useMutation` succeeds, call `queryClient.invalidateQueries` with the affected keys. Without this, the UI shows stale data until the next refetch interval.
|
||||
|
||||
4. **Over-using React Context for frequently changing state.** Every Context value change re-renders every consumer. Context is good for low-frequency values (theme, locale, auth). For high-frequency updates (cursor position, scroll offset), use Zustand or a ref.
|
||||
|
||||
5. **Duplicating form state.** Calling `useForm()` and then also storing the same values in `useState` or Zustand means two sources of truth that can drift apart. Let the form library be the single owner.
|
||||
|
||||
6. **Ignoring URL state for filterable lists.** If a user applies filters and then hits the back button or refreshes, losing the filters is a bad experience. Encode filters in the URL so they survive navigation.
|
||||
|
||||
---
|
||||
|
||||
## Related Skills
|
||||
|
||||
- `react` - React component patterns and hooks
|
||||
- `nextjs` - Next.js server components and data fetching
|
||||
- `typescript` - TypeScript types and generics
|
||||
- `caching` - Cache strategies and invalidation patterns
|
||||
@@ -0,0 +1,143 @@
|
||||
# State Management Decision Tree
|
||||
|
||||
## Primary Decision Tree
|
||||
|
||||
```
|
||||
What kind of state is it?
|
||||
│
|
||||
├─ SERVER DATA (fetched from API/DB)
|
||||
│ │
|
||||
│ ├─ React/Next.js project?
|
||||
│ │ ├─ YES ──> TanStack Query (React Query)
|
||||
│ │ │ - Auto caching, dedup, background refresh
|
||||
│ │ │ - Stale-while-revalidate out of the box
|
||||
│ │ │ - DevTools for debugging
|
||||
│ │ │
|
||||
│ │ └─ Next.js App Router with Server Components?
|
||||
│ │ └─ Consider: fetch() in Server Components + revalidation
|
||||
│ │ - No client-side state library needed
|
||||
│ │ - Use TanStack Query only for client-interactive data
|
||||
│ │
|
||||
│ └─ Need real-time sync?
|
||||
│ ├─ WebSocket data ──> TanStack Query + custom subscription
|
||||
│ └─ Collaborative ──> Liveblocks, Yjs, or Partykit
|
||||
│
|
||||
├─ URL STATE (filters, pagination, search, tabs)
|
||||
│ │
|
||||
│ ├─ Next.js App Router ──> useSearchParams() + useRouter()
|
||||
│ ├─ React Router ──> useSearchParams()
|
||||
│ └─ Plain React ──> nuqs or custom URL sync hook
|
||||
│
|
||||
│ Why URL state? Shareable links, back/forward navigation,
|
||||
│ bookmarkable, SSR-friendly.
|
||||
│
|
||||
├─ FORM STATE (input values, validation, dirty/touched)
|
||||
│ │
|
||||
│ ├─ Complex forms (multi-step, dynamic fields, arrays)
|
||||
│ │ └─ react-hook-form + zod
|
||||
│ │ - Uncontrolled by default (performant)
|
||||
│ │ - Schema validation with zod resolver
|
||||
│ │
|
||||
│ ├─ Simple forms (login, search, contact)
|
||||
│ │ └─ Server Actions (Next.js) or native form + useState
|
||||
│ │
|
||||
│ └─ Form + server state (edit existing record)
|
||||
│ └─ TanStack Query (fetch) + react-hook-form (edit)
|
||||
│ - Populate form with query data
|
||||
│ - Submit with mutation
|
||||
│
|
||||
├─ GLOBAL CLIENT STATE (shared across many components)
|
||||
│ │
|
||||
│ ├─ Simple (theme, sidebar open, user preferences)
|
||||
│ │ │
|
||||
│ │ ├─ Changes rarely ──> React Context
|
||||
│ │ │ (Wrap app, useContext to consume)
|
||||
│ │ │
|
||||
│ │ └─ Changes often or many consumers ──> Zustand
|
||||
│ │ - Avoids Context re-render problem
|
||||
│ │ - Selector-based subscriptions
|
||||
│ │ - Tiny bundle, minimal boilerplate
|
||||
│ │
|
||||
│ ├─ Complex (shopping cart, multi-step wizard, editor)
|
||||
│ │ └─ Zustand (or Jotai for atomic state)
|
||||
│ │
|
||||
│ └─ Need devtools and time-travel debugging?
|
||||
│ └─ Zustand with devtools middleware
|
||||
│
|
||||
├─ LOCAL COMPONENT STATE (only used in one component)
|
||||
│ │
|
||||
│ ├─ Single value ──> useState
|
||||
│ │ const [count, setCount] = useState(0);
|
||||
│ │
|
||||
│ ├─ Related values or complex transitions ──> useReducer
|
||||
│ │ const [state, dispatch] = useReducer(reducer, initial);
|
||||
│ │
|
||||
│ └─ Derived value (computed from other state) ──> useMemo
|
||||
│ const total = useMemo(() => items.reduce(...), [items]);
|
||||
│
|
||||
└─ TRANSIENT UI STATE (animations, hover, drag position)
|
||||
│
|
||||
├─ CSS can handle it? ──> Use CSS (transitions, :hover)
|
||||
├─ Ref-based (no re-render needed) ──> useRef
|
||||
└─ Needs re-render ──> useState (local)
|
||||
```
|
||||
|
||||
## Quick Lookup Table
|
||||
|
||||
| State Type | Recommended Tool | When NOT to Use |
|
||||
|-----------|-----------------|-----------------|
|
||||
| Server data | TanStack Query | Data never changes, or SSR-only |
|
||||
| URL params | useSearchParams | Ephemeral UI state (hover, etc.) |
|
||||
| Form inputs | react-hook-form | Single `<input>` |
|
||||
| Global UI | Zustand | Only 1-2 consumers (use Context) |
|
||||
| Global UI (simple) | React Context | Frequent updates with many consumers |
|
||||
| Local state | useState | Complex state transitions |
|
||||
| Complex local | useReducer | Single boolean toggle |
|
||||
| Derived data | useMemo | Cheap computations |
|
||||
| No re-render needed | useRef | Value that should trigger re-render |
|
||||
|
||||
## Library Comparison
|
||||
|
||||
| Library | Bundle Size | Boilerplate | Learning Curve | Best For |
|
||||
|---------|------------|-------------|----------------|----------|
|
||||
| useState/useReducer | 0 KB | Minimal | Low | Local state |
|
||||
| React Context | 0 KB | Low | Low | Rarely-changing global state |
|
||||
| Zustand | ~1 KB | Minimal | Low | Global client state |
|
||||
| Jotai | ~3 KB | Minimal | Medium | Atomic/derived state |
|
||||
| TanStack Query | ~12 KB | Medium | Medium | Server state |
|
||||
| Redux Toolkit | ~30 KB | High | High | Large teams needing strict patterns |
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
| Mistake | Problem | Fix |
|
||||
|---------|---------|-----|
|
||||
| Storing server data in Zustand/Redux | Manual cache invalidation, stale data | Use TanStack Query |
|
||||
| Storing URL state in useState | Not shareable, lost on refresh | Use URL search params |
|
||||
| Putting everything in global state | Unnecessary re-renders, complexity | Colocate state where used |
|
||||
| Context for frequently changing data | Re-renders all consumers | Use Zustand with selectors |
|
||||
| Duplicating derived state | Out-of-sync bugs | Compute with useMemo |
|
||||
| useState for complex transitions | Inconsistent intermediate states | Use useReducer |
|
||||
|
||||
## Zustand Quick Setup
|
||||
|
||||
```typescript
|
||||
import { create } from "zustand";
|
||||
|
||||
interface CartStore {
|
||||
items: CartItem[];
|
||||
addItem: (item: CartItem) => void;
|
||||
removeItem: (id: string) => void;
|
||||
total: () => number;
|
||||
}
|
||||
|
||||
const useCartStore = create<CartStore>((set, get) => ({
|
||||
items: [],
|
||||
addItem: (item) => set((s) => ({ items: [...s.items, item] })),
|
||||
removeItem: (id) => set((s) => ({ items: s.items.filter(i => i.id !== id) })),
|
||||
total: () => get().items.reduce((sum, i) => sum + i.price, 0),
|
||||
}));
|
||||
|
||||
// Usage with selector (only re-renders when items change)
|
||||
const items = useCartStore((s) => s.items);
|
||||
const addItem = useCartStore((s) => s.addItem);
|
||||
```
|
||||
Reference in New Issue
Block a user