chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,404 @@
|
||||
# React TypeScript Development Skill
|
||||
|
||||
## Purpose
|
||||
|
||||
The `react-dev` skill provides comprehensive TypeScript patterns and best practices for building type-safe React applications. It serves as a complete reference for modern React development with TypeScript, covering React 18-19 features, including Server Components, type-safe routing, and proper event handling.
|
||||
|
||||
This skill exists to eliminate TypeScript guesswork in React development by providing compile-time guarantees, confident refactoring, and production-ready patterns that catch bugs before runtime.
|
||||
|
||||
## When to Use
|
||||
|
||||
Activate this skill when working on:
|
||||
|
||||
- **Building typed React components** - Creating new components with proper TypeScript types
|
||||
- **Implementing generic components** - Tables, lists, modals, form fields that work with any data type
|
||||
- **Typing event handlers and forms** - Mouse events, form submissions, input changes, keyboard events
|
||||
- **Using React 19 features** - Actions, Server Components, `use()` hook, `useActionState`
|
||||
- **Router integration** - TanStack Router or React Router v7 with type safety
|
||||
- **Custom hooks with proper typing** - Creating reusable hooks with correct return types
|
||||
- **Migrating to React 19** - Understanding breaking changes and new patterns
|
||||
|
||||
**Trigger phrases:**
|
||||
- "Build a typed React component"
|
||||
- "Type this event handler"
|
||||
- "Create a generic Table/List/Modal"
|
||||
- "Use React 19 Server Components"
|
||||
- "Type-safe routing with TanStack/React Router"
|
||||
- "How do I type this hook?"
|
||||
- "React TypeScript best practices"
|
||||
|
||||
**Not for:**
|
||||
- Non-React TypeScript projects
|
||||
- Vanilla JavaScript React (without TypeScript)
|
||||
- React Native-specific patterns
|
||||
|
||||
## How It Works
|
||||
|
||||
The skill provides progressive disclosure of React TypeScript knowledge:
|
||||
|
||||
1. **Core patterns loaded immediately** - Component props, event handlers, hooks typing
|
||||
2. **Advanced patterns referenced on-demand** - Generic components, Server Components, routing
|
||||
3. **Reference files for deep dives** - Detailed examples and edge cases only loaded when needed
|
||||
4. **React 19 migration guidance** - Breaking changes and new APIs clearly marked
|
||||
|
||||
The skill is structured around:
|
||||
- **Quick reference sections** - Common patterns you can copy immediately
|
||||
- **Detailed reference files** - In-depth examples and explanations
|
||||
- **Rules and anti-patterns** - What to always do and what to never do
|
||||
- **Version-specific guidance** - React 18 vs React 19 differences highlighted
|
||||
|
||||
## Key Features
|
||||
|
||||
### 1. React 19 Breaking Changes
|
||||
|
||||
Covers all major breaking changes in React 19:
|
||||
|
||||
- **ref as prop** - No more `forwardRef`, refs are regular props now
|
||||
- **useActionState** - Replaces deprecated `useFormState`
|
||||
- **use() hook** - Unwraps promises and context in components
|
||||
- **Migration patterns** - Step-by-step guides for updating existing code
|
||||
|
||||
### 2. Component Patterns
|
||||
|
||||
Type-safe patterns for common component scenarios:
|
||||
|
||||
- **Props typing** - Extending native HTML elements with `ComponentPropsWithoutRef`
|
||||
- **Children typing** - `ReactNode`, `ReactElement`, render props
|
||||
- **Discriminated unions** - Type-safe variant props (e.g., button vs link)
|
||||
- **Generic components** - Reusable components that infer types from data
|
||||
|
||||
### 3. Event Handler Typing
|
||||
|
||||
Specific event types for accurate TypeScript inference:
|
||||
|
||||
- Mouse events (`MouseEvent<HTMLButtonElement>`)
|
||||
- Form events (`FormEvent<HTMLFormElement>`)
|
||||
- Input changes (`ChangeEvent<HTMLInputElement>`)
|
||||
- Keyboard events (`KeyboardEvent<HTMLInputElement>`)
|
||||
- Focus, drag, clipboard, touch, wheel events (in reference docs)
|
||||
|
||||
### 4. Hooks Typing
|
||||
|
||||
Proper TypeScript patterns for all React hooks:
|
||||
|
||||
- `useState` - Explicit types for unions and nullable values
|
||||
- `useRef` - DOM refs (null) vs mutable refs (direct access)
|
||||
- `useReducer` - Discriminated union actions
|
||||
- `useContext` - Null guard patterns
|
||||
- Custom hooks - Tuple returns with `as const`
|
||||
|
||||
### 5. Server Components (React 19)
|
||||
|
||||
Modern patterns for Server Components and Server Actions:
|
||||
|
||||
- Async components with direct data fetching
|
||||
- Server Actions with `'use server'` directive
|
||||
- Client components consuming Server Actions
|
||||
- Promise handoff with `use()` hook
|
||||
- Parallel fetching and streaming
|
||||
|
||||
### 6. Type-Safe Routing
|
||||
|
||||
Comprehensive routing patterns for both major routers:
|
||||
|
||||
- **TanStack Router** - Compile-time type safety with Zod validation
|
||||
- **React Router v7** - Auto-generated types with Framework Mode
|
||||
- Type-safe params, search params, loaders, actions
|
||||
|
||||
### 7. Generic Components
|
||||
|
||||
Reusable components that work with any data type:
|
||||
|
||||
- Tables with type-safe columns
|
||||
- Lists with constrained generics
|
||||
- Modals, selects, form fields
|
||||
- Render props and keyof patterns
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **React 18 or 19** - Patterns are optimized for modern React
|
||||
- **TypeScript 5.0+** - Uses latest TypeScript features
|
||||
- **Router (optional)** - TanStack Router or React Router v7 for routing patterns
|
||||
- **Zod (optional)** - For TanStack Router search param validation
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Example 1: Type-Safe Button Component
|
||||
|
||||
```typescript
|
||||
type ButtonProps = {
|
||||
variant: 'primary' | 'secondary';
|
||||
} & React.ComponentPropsWithoutRef<'button'>;
|
||||
|
||||
function Button({ variant, children, ...props }: ButtonProps) {
|
||||
return (
|
||||
<button
|
||||
className={variant}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// Usage
|
||||
<Button variant="primary" onClick={(e) => console.log(e.currentTarget.disabled)}>
|
||||
Click me
|
||||
</Button>
|
||||
```
|
||||
|
||||
### Example 2: Generic Table Component
|
||||
|
||||
```typescript
|
||||
type Column<T> = {
|
||||
key: keyof T;
|
||||
header: string;
|
||||
render?: (value: T[keyof T], item: T) => React.ReactNode;
|
||||
};
|
||||
|
||||
type TableProps<T> = {
|
||||
data: T[];
|
||||
columns: Column<T>[];
|
||||
keyExtractor: (item: T) => string | number;
|
||||
};
|
||||
|
||||
function Table<T>({ data, columns, keyExtractor }: TableProps<T>) {
|
||||
return (
|
||||
<table>
|
||||
<thead>
|
||||
<tr>{columns.map(col => <th key={String(col.key)}>{col.header}</th>)}</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.map(item => (
|
||||
<tr key={keyExtractor(item)}>
|
||||
{columns.map(col => (
|
||||
<td key={String(col.key)}>
|
||||
{col.render ? col.render(item[col.key], item) : String(item[col.key])}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
// Usage - types are inferred!
|
||||
type User = { id: number; name: string; email: string };
|
||||
const users: User[] = [...];
|
||||
|
||||
<Table
|
||||
data={users}
|
||||
columns={[
|
||||
{ key: 'name', header: 'Name' },
|
||||
{ key: 'email', header: 'Email' }
|
||||
]}
|
||||
keyExtractor={user => user.id}
|
||||
/>
|
||||
```
|
||||
|
||||
### Example 3: React 19 Server Action with Form
|
||||
|
||||
```typescript
|
||||
// actions/user.ts
|
||||
'use server';
|
||||
|
||||
export async function updateUser(userId: string, formData: FormData) {
|
||||
const name = formData.get('name') as string;
|
||||
await db.user.update({
|
||||
where: { id: userId },
|
||||
data: { name }
|
||||
});
|
||||
revalidatePath(`/users/${userId}`);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// UserForm.tsx
|
||||
'use client';
|
||||
|
||||
import { useActionState } from 'react';
|
||||
import { updateUser } from '@/actions/user';
|
||||
|
||||
function UserForm({ userId }: { userId: string }) {
|
||||
const [state, formAction, isPending] = useActionState(
|
||||
(prev, formData) => updateUser(userId, formData),
|
||||
{}
|
||||
);
|
||||
|
||||
return (
|
||||
<form action={formAction}>
|
||||
<input name="name" />
|
||||
<button disabled={isPending}>
|
||||
{isPending ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
{state.success && <p>Saved!</p>}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Example 4: Type-Safe Event Handlers
|
||||
|
||||
```typescript
|
||||
function Form() {
|
||||
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.currentTarget);
|
||||
console.log(Object.fromEntries(formData));
|
||||
};
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
console.log(e.target.value); // Typed correctly!
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.currentTarget.blur(); // currentTarget is typed as HTMLInputElement
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<input
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Example 5: Custom Hook with Proper Typing
|
||||
|
||||
```typescript
|
||||
function useToggle(initial = false) {
|
||||
const [value, setValue] = useState(initial);
|
||||
const toggle = () => setValue(v => !v);
|
||||
return [value, toggle] as const; // Tuple type preserved!
|
||||
}
|
||||
|
||||
// Usage
|
||||
const [isOpen, toggleOpen] = useToggle(false);
|
||||
// isOpen is boolean, toggleOpen is () => void
|
||||
```
|
||||
|
||||
### Example 6: TanStack Router Type-Safe Route
|
||||
|
||||
```typescript
|
||||
import { createRoute } from '@tanstack/react-router';
|
||||
import { z } from 'zod';
|
||||
|
||||
const userRoute = createRoute({
|
||||
path: '/users/$userId',
|
||||
component: UserPage,
|
||||
loader: async ({ params }) => ({
|
||||
user: await fetchUser(params.userId)
|
||||
}),
|
||||
validateSearch: z.object({
|
||||
tab: z.enum(['profile', 'settings']).optional(),
|
||||
page: z.number().int().positive().default(1),
|
||||
}),
|
||||
});
|
||||
|
||||
function UserPage() {
|
||||
// All fully typed from the route definition!
|
||||
const { user } = useLoaderData({ from: userRoute.id });
|
||||
const { tab, page } = useSearch({ from: userRoute.id });
|
||||
const { userId } = useParams({ from: userRoute.id });
|
||||
|
||||
return <div>{user.name} - {tab} - Page {page}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
## Output
|
||||
|
||||
The skill provides:
|
||||
|
||||
1. **Code examples** - Copy-paste ready TypeScript code
|
||||
2. **Type definitions** - Exact types to use for your scenarios
|
||||
3. **Pattern explanations** - Why patterns work and when to use them
|
||||
4. **Migration guides** - Step-by-step updates for React 19
|
||||
5. **Reference links** - Pointers to detailed documentation when needed
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Always Do
|
||||
|
||||
✅ **Use specific event types** - `MouseEvent<HTMLButtonElement>` not `React.MouseEvent`
|
||||
✅ **Explicit `useState` for unions/null** - `useState<User | null>(null)`
|
||||
✅ **Extend native elements** - `ComponentPropsWithoutRef<'button'>`
|
||||
✅ **Discriminated unions for variants** - Type-safe props based on variant
|
||||
✅ **`as const` for tuple returns** - Preserve tuple types in custom hooks
|
||||
✅ **`ref` as prop in React 19** - No `forwardRef` needed
|
||||
✅ **`useActionState` for forms** - Not deprecated `useFormState`
|
||||
✅ **Type-safe routing patterns** - Use provided router patterns
|
||||
|
||||
### Never Do
|
||||
|
||||
❌ **Use `any` for event handlers** - Defeats TypeScript's purpose
|
||||
❌ **Use `JSX.Element` for children** - Use `ReactNode` instead
|
||||
❌ **Use `forwardRef` in React 19+** - It's deprecated
|
||||
❌ **Use `useFormState`** - Deprecated in React 19
|
||||
❌ **Forget null handling for DOM refs** - Always use `ref?.current`
|
||||
❌ **Mix Server/Client in same file** - Will cause hydration errors
|
||||
❌ **Await promises before `use()`** - Defeats streaming/Suspense
|
||||
|
||||
### Progressive Enhancement
|
||||
|
||||
1. **Start simple** - Basic component props and event handlers
|
||||
2. **Add constraints** - Discriminated unions, generic constraints
|
||||
3. **Leverage inference** - Let TypeScript infer types from usage
|
||||
4. **Reference docs when stuck** - Deep dive into specific patterns
|
||||
|
||||
### Reference File Organization
|
||||
|
||||
The skill includes detailed reference files:
|
||||
|
||||
- **hooks.md** - `useState`, `useRef`, `useReducer`, `useContext`, custom hooks
|
||||
- **event-handlers.md** - All event types, generic handlers, edge cases
|
||||
- **react-19-patterns.md** - `useActionState`, `use()`, `useOptimistic`, migration
|
||||
- **generic-components.md** - Table, Select, List, Modal, FormField patterns
|
||||
- **server-components.md** - Async components, Server Actions, streaming
|
||||
- **tanstack-router.md** - TanStack Router typed routes, search params, navigation
|
||||
- **react-router.md** - React Router v7 loaders, actions, type generation
|
||||
|
||||
These files are loaded on-demand to keep context efficient. The skill will reference them when deeper knowledge is needed.
|
||||
|
||||
## Context Efficiency
|
||||
|
||||
This skill follows progressive disclosure principles:
|
||||
|
||||
- **Core patterns in SKILL.md** - Most common use cases immediately available
|
||||
- **Reference files on-demand** - Deep dives only when needed
|
||||
- **Script-free design** - Pure knowledge, no executable scripts needed
|
||||
- **Single-level references** - Direct links, no nested includes
|
||||
|
||||
This keeps the skill's context footprint minimal while providing comprehensive coverage when needed.
|
||||
|
||||
## Version Compatibility
|
||||
|
||||
- **React 18** - All patterns work, ignore React 19-specific sections
|
||||
- **React 19** - Full support including Server Components, `use()`, `useActionState`
|
||||
- **TypeScript 5.0+** - Recommended for best type inference
|
||||
- **TanStack Router** - Any version supporting `createRoute`
|
||||
- **React Router v7+** - Framework Mode for auto-generated types
|
||||
|
||||
## Getting Started
|
||||
|
||||
1. **Install the skill** in Claude Code or add to claude.ai project knowledge
|
||||
2. **Mention React TypeScript** in your conversation or request
|
||||
3. **Reference specific patterns** - "How do I type this event handler?"
|
||||
4. **Let Claude suggest patterns** - Describe what you're building
|
||||
5. **Explore reference docs** - Ask for deep dives when needed
|
||||
|
||||
## Related Skills
|
||||
|
||||
- **typescript-patterns** - General TypeScript patterns beyond React
|
||||
- **frontend-testing** - Testing typed React components
|
||||
- **component-library** - Building design systems with TypeScript
|
||||
|
||||
---
|
||||
|
||||
**Version:** 1.0.0
|
||||
**Last Updated:** 2026-01-20
|
||||
**Maintained by:** Softaworks Agent Skills
|
||||
@@ -0,0 +1,391 @@
|
||||
---
|
||||
name: react-dev
|
||||
version: 1.0.0
|
||||
description: This skill should be used when building React components with TypeScript, typing hooks, handling events, or when React TypeScript, React 19, Server Components are mentioned. Covers type-safe patterns for React 18-19 including generic components, proper event typing, and routing integration (TanStack Router, React Router).
|
||||
---
|
||||
|
||||
# React TypeScript
|
||||
|
||||
Type-safe React = compile-time guarantees = confident refactoring.
|
||||
|
||||
<when_to_use>
|
||||
|
||||
- Building typed React components
|
||||
- Implementing generic components
|
||||
- Typing event handlers, forms, refs
|
||||
- Using React 19 features (Actions, Server Components, use())
|
||||
- Router integration (TanStack Router, React Router)
|
||||
- Custom hooks with proper typing
|
||||
|
||||
NOT for: non-React TypeScript, vanilla JS React
|
||||
|
||||
</when_to_use>
|
||||
|
||||
<react_19_changes>
|
||||
|
||||
React 19 breaking changes require migration. Key patterns:
|
||||
|
||||
**ref as prop** - forwardRef deprecated:
|
||||
|
||||
```typescript
|
||||
// React 19 - ref as regular prop
|
||||
type ButtonProps = {
|
||||
ref?: React.Ref<HTMLButtonElement>;
|
||||
} & React.ComponentPropsWithoutRef<'button'>;
|
||||
|
||||
function Button({ ref, children, ...props }: ButtonProps) {
|
||||
return <button ref={ref} {...props}>{children}</button>;
|
||||
}
|
||||
```
|
||||
|
||||
**useActionState** - replaces useFormState:
|
||||
|
||||
```typescript
|
||||
import { useActionState } from 'react';
|
||||
|
||||
type FormState = { errors?: string[]; success?: boolean };
|
||||
|
||||
function Form() {
|
||||
const [state, formAction, isPending] = useActionState(submitAction, {});
|
||||
return <form action={formAction}>...</form>;
|
||||
}
|
||||
```
|
||||
|
||||
**use()** - unwraps promises/context:
|
||||
|
||||
```typescript
|
||||
function UserProfile({ userPromise }: { userPromise: Promise<User> }) {
|
||||
const user = use(userPromise); // Suspends until resolved
|
||||
return <div>{user.name}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
See [react-19-patterns.md](references/react-19-patterns.md) for useOptimistic, useTransition, migration checklist.
|
||||
|
||||
</react_19_changes>
|
||||
|
||||
<component_patterns>
|
||||
|
||||
**Props** - extend native elements:
|
||||
|
||||
```typescript
|
||||
type ButtonProps = {
|
||||
variant: 'primary' | 'secondary';
|
||||
} & React.ComponentPropsWithoutRef<'button'>;
|
||||
|
||||
function Button({ variant, children, ...props }: ButtonProps) {
|
||||
return <button className={variant} {...props}>{children}</button>;
|
||||
}
|
||||
```
|
||||
|
||||
**Children typing**:
|
||||
|
||||
```typescript
|
||||
type Props = {
|
||||
children: React.ReactNode; // Anything renderable
|
||||
icon: React.ReactElement; // Single element
|
||||
render: (data: T) => React.ReactNode; // Render prop
|
||||
};
|
||||
```
|
||||
|
||||
**Discriminated unions** for variant props:
|
||||
|
||||
```typescript
|
||||
type ButtonProps =
|
||||
| { variant: 'link'; href: string }
|
||||
| { variant: 'button'; onClick: () => void };
|
||||
|
||||
function Button(props: ButtonProps) {
|
||||
if (props.variant === 'link') {
|
||||
return <a href={props.href}>Link</a>;
|
||||
}
|
||||
return <button onClick={props.onClick}>Button</button>;
|
||||
}
|
||||
```
|
||||
|
||||
</component_patterns>
|
||||
|
||||
<event_handlers>
|
||||
|
||||
Use specific event types for accurate target typing:
|
||||
|
||||
```typescript
|
||||
// Mouse
|
||||
function handleClick(e: React.MouseEvent<HTMLButtonElement>) {
|
||||
e.currentTarget.disabled = true;
|
||||
}
|
||||
|
||||
// Form
|
||||
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.currentTarget);
|
||||
}
|
||||
|
||||
// Input
|
||||
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
console.log(e.target.value);
|
||||
}
|
||||
|
||||
// Keyboard
|
||||
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
|
||||
if (e.key === 'Enter') e.currentTarget.blur();
|
||||
}
|
||||
```
|
||||
|
||||
See [event-handlers.md](references/event-handlers.md) for focus, drag, clipboard, touch, wheel events.
|
||||
|
||||
</event_handlers>
|
||||
|
||||
<hooks_typing>
|
||||
|
||||
**useState** - explicit for unions/null:
|
||||
|
||||
```typescript
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [status, setStatus] = useState<'idle' | 'loading'>('idle');
|
||||
```
|
||||
|
||||
**useRef** - null for DOM, value for mutable:
|
||||
|
||||
```typescript
|
||||
const inputRef = useRef<HTMLInputElement>(null); // DOM - use ?.
|
||||
const countRef = useRef<number>(0); // Mutable - direct access
|
||||
```
|
||||
|
||||
**useReducer** - discriminated unions for actions:
|
||||
|
||||
```typescript
|
||||
type Action =
|
||||
| { type: 'increment' }
|
||||
| { type: 'set'; payload: number };
|
||||
|
||||
function reducer(state: State, action: Action): State {
|
||||
switch (action.type) {
|
||||
case 'set': return { ...state, count: action.payload };
|
||||
default: return state;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Custom hooks** - tuple returns with as const:
|
||||
|
||||
```typescript
|
||||
function useToggle(initial = false) {
|
||||
const [value, setValue] = useState(initial);
|
||||
const toggle = () => setValue(v => !v);
|
||||
return [value, toggle] as const;
|
||||
}
|
||||
```
|
||||
|
||||
**useContext** - null guard pattern:
|
||||
|
||||
```typescript
|
||||
const UserContext = createContext<User | null>(null);
|
||||
|
||||
function useUser() {
|
||||
const user = useContext(UserContext);
|
||||
if (!user) throw new Error('useUser outside UserProvider');
|
||||
return user;
|
||||
}
|
||||
```
|
||||
|
||||
See [hooks.md](references/hooks.md) for useCallback, useMemo, useImperativeHandle, useSyncExternalStore.
|
||||
|
||||
</hooks_typing>
|
||||
|
||||
<generic_components>
|
||||
|
||||
Generic components infer types from props - no manual annotations at call site.
|
||||
|
||||
**Pattern** - keyof T for column keys, render props for custom rendering:
|
||||
|
||||
```typescript
|
||||
type Column<T> = {
|
||||
key: keyof T;
|
||||
header: string;
|
||||
render?: (value: T[keyof T], item: T) => React.ReactNode;
|
||||
};
|
||||
|
||||
type TableProps<T> = {
|
||||
data: T[];
|
||||
columns: Column<T>[];
|
||||
keyExtractor: (item: T) => string | number;
|
||||
};
|
||||
|
||||
function Table<T>({ data, columns, keyExtractor }: TableProps<T>) {
|
||||
return (
|
||||
<table>
|
||||
<thead>
|
||||
<tr>{columns.map(col => <th key={String(col.key)}>{col.header}</th>)}</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.map(item => (
|
||||
<tr key={keyExtractor(item)}>
|
||||
{columns.map(col => (
|
||||
<td key={String(col.key)}>
|
||||
{col.render ? col.render(item[col.key], item) : String(item[col.key])}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Constrained generics** for required properties:
|
||||
|
||||
```typescript
|
||||
type HasId = { id: string | number };
|
||||
|
||||
function List<T extends HasId>({ items }: { items: T[] }) {
|
||||
return <ul>{items.map(item => <li key={item.id}>...</li>)}</ul>;
|
||||
}
|
||||
```
|
||||
|
||||
See [generic-components.md](examples/generic-components.md) for Select, List, Modal, FormField patterns.
|
||||
|
||||
</generic_components>
|
||||
|
||||
<server_components>
|
||||
|
||||
React 19 Server Components run on server, can be async.
|
||||
|
||||
**Async data fetching**:
|
||||
|
||||
```typescript
|
||||
export default async function UserPage({ params }: { params: { id: string } }) {
|
||||
const user = await fetchUser(params.id);
|
||||
return <div>{user.name}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
**Server Actions** - 'use server' for mutations:
|
||||
|
||||
```typescript
|
||||
'use server';
|
||||
|
||||
export async function updateUser(userId: string, formData: FormData) {
|
||||
await db.user.update({ where: { id: userId }, data: { ... } });
|
||||
revalidatePath(`/users/${userId}`);
|
||||
}
|
||||
```
|
||||
|
||||
**Client + Server Action**:
|
||||
|
||||
```typescript
|
||||
'use client';
|
||||
|
||||
import { useActionState } from 'react';
|
||||
import { updateUser } from '@/actions/user';
|
||||
|
||||
function UserForm({ userId }: { userId: string }) {
|
||||
const [state, formAction, isPending] = useActionState(
|
||||
(prev, formData) => updateUser(userId, formData), {}
|
||||
);
|
||||
return <form action={formAction}>...</form>;
|
||||
}
|
||||
```
|
||||
|
||||
**use() for promise handoff**:
|
||||
|
||||
```typescript
|
||||
// Server: pass promise without await
|
||||
async function Page() {
|
||||
const userPromise = fetchUser('123');
|
||||
return <UserProfile userPromise={userPromise} />;
|
||||
}
|
||||
|
||||
// Client: unwrap with use()
|
||||
'use client';
|
||||
function UserProfile({ userPromise }: { userPromise: Promise<User> }) {
|
||||
const user = use(userPromise);
|
||||
return <div>{user.name}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
See [server-components.md](examples/server-components.md) for parallel fetching, streaming, error boundaries.
|
||||
|
||||
</server_components>
|
||||
|
||||
<routing>
|
||||
|
||||
Both TanStack Router and React Router v7 provide type-safe routing solutions.
|
||||
|
||||
**TanStack Router** - Compile-time type safety with Zod validation:
|
||||
|
||||
```typescript
|
||||
import { createRoute } from '@tanstack/react-router';
|
||||
import { z } from 'zod';
|
||||
|
||||
const userRoute = createRoute({
|
||||
path: '/users/$userId',
|
||||
component: UserPage,
|
||||
loader: async ({ params }) => ({ user: await fetchUser(params.userId) }),
|
||||
validateSearch: z.object({
|
||||
tab: z.enum(['profile', 'settings']).optional(),
|
||||
page: z.number().int().positive().default(1),
|
||||
}),
|
||||
});
|
||||
|
||||
function UserPage() {
|
||||
const { user } = useLoaderData({ from: userRoute.id });
|
||||
const { tab, page } = useSearch({ from: userRoute.id });
|
||||
const { userId } = useParams({ from: userRoute.id });
|
||||
}
|
||||
```
|
||||
|
||||
**React Router v7** - Automatic type generation with Framework Mode:
|
||||
|
||||
```typescript
|
||||
import type { Route } from "./+types/user";
|
||||
|
||||
export async function loader({ params }: Route.LoaderArgs) {
|
||||
return { user: await fetchUser(params.userId) };
|
||||
}
|
||||
|
||||
export default function UserPage({ loaderData }: Route.ComponentProps) {
|
||||
const { user } = loaderData; // Typed from loader
|
||||
return <h1>{user.name}</h1>;
|
||||
}
|
||||
```
|
||||
|
||||
See [tanstack-router.md](references/tanstack-router.md) for TanStack patterns and [react-router.md](references/react-router.md) for React Router patterns.
|
||||
|
||||
</routing>
|
||||
|
||||
<rules>
|
||||
|
||||
ALWAYS:
|
||||
- Specific event types (MouseEvent, ChangeEvent, etc)
|
||||
- Explicit useState for unions/null
|
||||
- ComponentPropsWithoutRef for native element extension
|
||||
- Discriminated unions for variant props
|
||||
- as const for tuple returns
|
||||
- ref as prop in React 19 (no forwardRef)
|
||||
- useActionState for form actions
|
||||
- Type-safe routing patterns (see routing section)
|
||||
|
||||
NEVER:
|
||||
- any for event handlers
|
||||
- JSX.Element for children (use ReactNode)
|
||||
- forwardRef in React 19+
|
||||
- useFormState (deprecated)
|
||||
- Forget null handling for DOM refs
|
||||
- Mix Server/Client components in same file
|
||||
- Await promises when passing to use()
|
||||
|
||||
</rules>
|
||||
|
||||
<references>
|
||||
|
||||
- [hooks.md](references/hooks.md) - useState, useRef, useReducer, useContext, custom hooks
|
||||
- [event-handlers.md](references/event-handlers.md) - all event types, generic handlers
|
||||
- [react-19-patterns.md](references/react-19-patterns.md) - useActionState, use(), useOptimistic, migration
|
||||
- [generic-components.md](examples/generic-components.md) - Table, Select, List, Modal patterns
|
||||
- [server-components.md](examples/server-components.md) - async components, Server Actions, streaming
|
||||
- [tanstack-router.md](references/tanstack-router.md) - TanStack Router typed routes, search params, navigation
|
||||
- [react-router.md](references/react-router.md) - React Router v7 loaders, actions, type generation, forms
|
||||
|
||||
</references>
|
||||
@@ -0,0 +1,579 @@
|
||||
# Generic Component Patterns
|
||||
|
||||
Generic components provide type safety while maintaining reusability. TypeScript infers generic type parameters from prop values — no manual type annotations needed at call site.
|
||||
|
||||
## Generic Table Component
|
||||
|
||||
Full-featured table with typed columns, custom rendering, sorting.
|
||||
|
||||
```typescript
|
||||
type Column<T> = {
|
||||
key: keyof T;
|
||||
header: string;
|
||||
render?: (value: T[keyof T], item: T) => React.ReactNode;
|
||||
sortable?: boolean;
|
||||
};
|
||||
|
||||
type TableProps<T> = {
|
||||
data: T[];
|
||||
columns: Column<T>[];
|
||||
keyExtractor: (item: T) => string | number;
|
||||
onSort?: (key: keyof T, direction: 'asc' | 'desc') => void;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function Table<T>({
|
||||
data,
|
||||
columns,
|
||||
keyExtractor,
|
||||
onSort,
|
||||
className,
|
||||
}: TableProps<T>) {
|
||||
const [sortKey, setSortKey] = React.useState<keyof T | null>(null);
|
||||
const [sortDir, setSortDir] = React.useState<'asc' | 'desc'>('asc');
|
||||
|
||||
const handleSort = (key: keyof T) => {
|
||||
const newDir = sortKey === key && sortDir === 'asc' ? 'desc' : 'asc';
|
||||
setSortKey(key);
|
||||
setSortDir(newDir);
|
||||
onSort?.(key, newDir);
|
||||
};
|
||||
|
||||
return (
|
||||
<table className={className}>
|
||||
<thead>
|
||||
<tr>
|
||||
{columns.map((col) => (
|
||||
<th key={String(col.key)}>
|
||||
{col.sortable ? (
|
||||
<button onClick={() => handleSort(col.key)}>
|
||||
{col.header}
|
||||
{sortKey === col.key && (sortDir === 'asc' ? ' ↑' : ' ↓')}
|
||||
</button>
|
||||
) : (
|
||||
col.header
|
||||
)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.map((item) => (
|
||||
<tr key={keyExtractor(item)}>
|
||||
{columns.map((col) => (
|
||||
<td key={String(col.key)}>
|
||||
{col.render
|
||||
? col.render(item[col.key], item)
|
||||
: String(item[col.key])}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Usage:
|
||||
|
||||
```typescript
|
||||
type User = {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
role: 'admin' | 'user';
|
||||
createdAt: Date;
|
||||
};
|
||||
|
||||
function UserTable({ users }: { users: User[] }) {
|
||||
const [sortedUsers, setSortedUsers] = React.useState(users);
|
||||
|
||||
const handleSort = (key: keyof User, direction: 'asc' | 'desc') => {
|
||||
const sorted = [...users].sort((a, b) => {
|
||||
if (a[key] < b[key]) return direction === 'asc' ? -1 : 1;
|
||||
if (a[key] > b[key]) return direction === 'asc' ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
setSortedUsers(sorted);
|
||||
};
|
||||
|
||||
return (
|
||||
<Table
|
||||
data={sortedUsers}
|
||||
columns={[
|
||||
{ key: 'name', header: 'Name', sortable: true },
|
||||
{
|
||||
key: 'email',
|
||||
header: 'Email',
|
||||
render: (email) => <a href={`mailto:${email}`}>{email}</a>,
|
||||
},
|
||||
{
|
||||
key: 'role',
|
||||
header: 'Role',
|
||||
render: (role) => (
|
||||
<span className={role === 'admin' ? 'badge-admin' : 'badge-user'}>
|
||||
{role}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'createdAt',
|
||||
header: 'Created',
|
||||
render: (date) => new Date(date).toLocaleDateString(),
|
||||
sortable: true,
|
||||
},
|
||||
]}
|
||||
keyExtractor={(user) => user.id}
|
||||
onSort={handleSort}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Generic Select/Dropdown
|
||||
|
||||
Type-safe select with custom option rendering, searching.
|
||||
|
||||
```typescript
|
||||
type SelectProps<T> = {
|
||||
options: T[];
|
||||
value: T | null;
|
||||
onChange: (value: T) => void;
|
||||
getLabel: (option: T) => string;
|
||||
getValue: (option: T) => string | number;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
searchable?: boolean;
|
||||
};
|
||||
|
||||
function Select<T>({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
getLabel,
|
||||
getValue,
|
||||
placeholder = 'Select...',
|
||||
disabled = false,
|
||||
searchable = false,
|
||||
}: SelectProps<T>) {
|
||||
const [search, setSearch] = React.useState('');
|
||||
const [isOpen, setIsOpen] = React.useState(false);
|
||||
|
||||
const filtered = searchable
|
||||
? options.filter((opt) =>
|
||||
getLabel(opt).toLowerCase().includes(search.toLowerCase())
|
||||
)
|
||||
: options;
|
||||
|
||||
const handleSelect = (option: T) => {
|
||||
onChange(option);
|
||||
setIsOpen(false);
|
||||
setSearch('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="select-wrapper">
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
disabled={disabled}
|
||||
className="select-trigger"
|
||||
>
|
||||
{value ? getLabel(value) : placeholder}
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="select-dropdown">
|
||||
{searchable && (
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search..."
|
||||
autoFocus
|
||||
/>
|
||||
)}
|
||||
<ul>
|
||||
{filtered.map((option) => (
|
||||
<li
|
||||
key={getValue(option)}
|
||||
onClick={() => handleSelect(option)}
|
||||
className={value === option ? 'selected' : ''}
|
||||
>
|
||||
{getLabel(option)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Usage:
|
||||
|
||||
```typescript
|
||||
type Country = {
|
||||
code: string;
|
||||
name: string;
|
||||
flag: string;
|
||||
};
|
||||
|
||||
const countries: Country[] = [
|
||||
{ code: 'US', name: 'United States', flag: '🇺🇸' },
|
||||
{ code: 'CA', name: 'Canada', flag: '🇨🇦' },
|
||||
{ code: 'MX', name: 'Mexico', flag: '🇲🇽' },
|
||||
];
|
||||
|
||||
function CountrySelector() {
|
||||
const [country, setCountry] = React.useState<Country | null>(null);
|
||||
|
||||
return (
|
||||
<Select
|
||||
options={countries}
|
||||
value={country}
|
||||
onChange={setCountry}
|
||||
getLabel={(c) => `${c.flag} ${c.name}`}
|
||||
getValue={(c) => c.code}
|
||||
searchable
|
||||
placeholder="Select country"
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Generic List with Render Props
|
||||
|
||||
Flexible list rendering with type-safe render props.
|
||||
|
||||
```typescript
|
||||
type ListProps<T> = {
|
||||
items: T[];
|
||||
renderItem: (item: T, index: number) => React.ReactNode;
|
||||
renderEmpty?: () => React.ReactNode;
|
||||
keyExtractor: (item: T, index: number) => string | number;
|
||||
className?: string;
|
||||
as?: 'ul' | 'ol' | 'div';
|
||||
};
|
||||
|
||||
function List<T>({
|
||||
items,
|
||||
renderItem,
|
||||
renderEmpty,
|
||||
keyExtractor,
|
||||
className,
|
||||
as: Component = 'ul',
|
||||
}: ListProps<T>) {
|
||||
if (items.length === 0 && renderEmpty) {
|
||||
return <>{renderEmpty()}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Component className={className}>
|
||||
{items.map((item, index) => {
|
||||
const key = keyExtractor(item, index);
|
||||
const element = renderItem(item, index);
|
||||
|
||||
return Component === 'div' ? (
|
||||
<div key={key}>{element}</div>
|
||||
) : (
|
||||
<li key={key}>{element}</li>
|
||||
);
|
||||
})}
|
||||
</Component>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Usage:
|
||||
|
||||
```typescript
|
||||
type Task = {
|
||||
id: string;
|
||||
title: string;
|
||||
completed: boolean;
|
||||
priority: 'low' | 'medium' | 'high';
|
||||
};
|
||||
|
||||
function TaskList({ tasks }: { tasks: Task[] }) {
|
||||
return (
|
||||
<List
|
||||
items={tasks}
|
||||
keyExtractor={(task) => task.id}
|
||||
renderItem={(task) => (
|
||||
<div className={`task priority-${task.priority}`}>
|
||||
<input type="checkbox" checked={task.completed} readOnly />
|
||||
<span className={task.completed ? 'completed' : ''}>{task.title}</span>
|
||||
</div>
|
||||
)}
|
||||
renderEmpty={() => (
|
||||
<div className="empty-state">No tasks yet. Add one to get started!</div>
|
||||
)}
|
||||
as="div"
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Constrained Generic Components
|
||||
|
||||
Use constraints to ensure required properties or methods.
|
||||
|
||||
```typescript
|
||||
// Constraint: items must have `id` property
|
||||
type HasId = { id: string | number };
|
||||
|
||||
type GridProps<T extends HasId> = {
|
||||
items: T[];
|
||||
renderCard: (item: T) => React.ReactNode;
|
||||
columns?: 2 | 3 | 4;
|
||||
};
|
||||
|
||||
function Grid<T extends HasId>({
|
||||
items,
|
||||
renderCard,
|
||||
columns = 3,
|
||||
}: GridProps<T>) {
|
||||
return (
|
||||
<div className={`grid grid-cols-${columns}`}>
|
||||
{items.map((item) => (
|
||||
<div key={item.id} className="grid-item">
|
||||
{renderCard(item)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Usage - type must have `id` property
|
||||
type Product = {
|
||||
id: number;
|
||||
name: string;
|
||||
price: number;
|
||||
image: string;
|
||||
};
|
||||
|
||||
<Grid
|
||||
items={products}
|
||||
renderCard={(product) => (
|
||||
<div>
|
||||
<img src={product.image} alt={product.name} />
|
||||
<h3>{product.name}</h3>
|
||||
<p>${product.price}</p>
|
||||
</div>
|
||||
)}
|
||||
columns={3}
|
||||
/>;
|
||||
```
|
||||
|
||||
## Multiple Generic Parameters
|
||||
|
||||
Components with multiple type parameters.
|
||||
|
||||
```typescript
|
||||
type PairProps<K, V> = {
|
||||
pairs: Array<[K, V]>;
|
||||
renderKey: (key: K) => React.ReactNode;
|
||||
renderValue: (value: V) => React.ReactNode;
|
||||
};
|
||||
|
||||
function KeyValueList<K, V>({ pairs, renderKey, renderValue }: PairProps<K, V>) {
|
||||
return (
|
||||
<dl>
|
||||
{pairs.map(([key, value], index) => (
|
||||
<React.Fragment key={index}>
|
||||
<dt>{renderKey(key)}</dt>
|
||||
<dd>{renderValue(value)}</dd>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
|
||||
// Usage
|
||||
type MetricKey = 'cpu' | 'memory' | 'disk';
|
||||
type MetricValue = { current: number; max: number };
|
||||
|
||||
const metrics: Array<[MetricKey, MetricValue]> = [
|
||||
['cpu', { current: 45, max: 100 }],
|
||||
['memory', { current: 8, max: 16 }],
|
||||
['disk', { current: 250, max: 500 }],
|
||||
];
|
||||
|
||||
<KeyValueList
|
||||
pairs={metrics}
|
||||
renderKey={(key) => <strong>{key.toUpperCase()}</strong>}
|
||||
renderValue={(val) => (
|
||||
<span>
|
||||
{val.current}/{val.max}
|
||||
</span>
|
||||
)}
|
||||
/>;
|
||||
```
|
||||
|
||||
## Generic Form Field Component
|
||||
|
||||
Reusable form field with type-safe value handling.
|
||||
|
||||
```typescript
|
||||
type FieldProps<T> = {
|
||||
name: string;
|
||||
label: string;
|
||||
value: T;
|
||||
onChange: (value: T) => void;
|
||||
type: 'text' | 'number' | 'email' | 'password';
|
||||
error?: string;
|
||||
required?: boolean;
|
||||
};
|
||||
|
||||
function FormField<T extends string | number>({
|
||||
name,
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
type,
|
||||
error,
|
||||
required = false,
|
||||
}: FieldProps<T>) {
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newValue =
|
||||
type === 'number' ? (Number(e.target.value) as T) : (e.target.value as T);
|
||||
onChange(newValue);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="form-field">
|
||||
<label htmlFor={name}>
|
||||
{label}
|
||||
{required && <span className="required">*</span>}
|
||||
</label>
|
||||
<input
|
||||
id={name}
|
||||
name={name}
|
||||
type={type}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
required={required}
|
||||
aria-invalid={!!error}
|
||||
aria-describedby={error ? `${name}-error` : undefined}
|
||||
/>
|
||||
{error && (
|
||||
<span id={`${name}-error`} className="error">
|
||||
{error}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Usage
|
||||
function UserForm() {
|
||||
const [name, setName] = React.useState('');
|
||||
const [age, setAge] = React.useState(0);
|
||||
|
||||
return (
|
||||
<form>
|
||||
<FormField
|
||||
name="name"
|
||||
label="Name"
|
||||
value={name}
|
||||
onChange={setName}
|
||||
type="text"
|
||||
required
|
||||
/>
|
||||
<FormField
|
||||
name="age"
|
||||
label="Age"
|
||||
value={age}
|
||||
onChange={setAge}
|
||||
type="number"
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Generic Modal/Dialog
|
||||
|
||||
Type-safe modal with generic result type.
|
||||
|
||||
```typescript
|
||||
type ModalProps<T> = {
|
||||
isOpen: boolean;
|
||||
onClose: (result?: T) => void;
|
||||
title: string;
|
||||
children: (submit: (result: T) => void) => React.ReactNode;
|
||||
};
|
||||
|
||||
function Modal<T>({ isOpen, onClose, title, children }: ModalProps<T>) {
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleSubmit = (result: T) => {
|
||||
onClose(result);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay">
|
||||
<div className="modal">
|
||||
<header>
|
||||
<h2>{title}</h2>
|
||||
<button onClick={() => onClose()}>×</button>
|
||||
</header>
|
||||
<div className="modal-body">{children(handleSubmit)}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Usage
|
||||
type UserFormData = { name: string; email: string };
|
||||
|
||||
function App() {
|
||||
const [showModal, setShowModal] = React.useState(false);
|
||||
const [formData, setFormData] = React.useState<UserFormData>({
|
||||
name: '',
|
||||
email: '',
|
||||
});
|
||||
|
||||
const handleModalClose = (result?: UserFormData) => {
|
||||
if (result) {
|
||||
console.log('User submitted:', result);
|
||||
}
|
||||
setShowModal(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button onClick={() => setShowModal(true)}>Add User</button>
|
||||
|
||||
<Modal<UserFormData>
|
||||
isOpen={showModal}
|
||||
onClose={handleModalClose}
|
||||
title="Add New User"
|
||||
>
|
||||
{(submit) => (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
submit(formData);
|
||||
}}
|
||||
>
|
||||
<input
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="Name"
|
||||
/>
|
||||
<input
|
||||
value={formData.email}
|
||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||
placeholder="Email"
|
||||
/>
|
||||
<button type="submit">Submit</button>
|
||||
</form>
|
||||
)}
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,579 @@
|
||||
# Server Components and Server Actions
|
||||
|
||||
React 19 Server Components run on server, can be async, enable zero-bundle data fetching. Server Actions handle mutations with progressive enhancement.
|
||||
|
||||
## Async Server Component
|
||||
|
||||
Server Components can be async functions — await data fetching directly.
|
||||
|
||||
```typescript
|
||||
// app/users/[id]/page.tsx
|
||||
type PageProps = {
|
||||
params: { id: string };
|
||||
searchParams?: { tab?: string; edit?: string };
|
||||
};
|
||||
|
||||
export default async function UserPage({ params, searchParams }: PageProps) {
|
||||
// Runs on server - no client bundle
|
||||
const user = await fetchUser(params.id);
|
||||
const posts = await fetchUserPosts(params.id);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<header>
|
||||
<h1>{user.name}</h1>
|
||||
<p>{user.email}</p>
|
||||
</header>
|
||||
|
||||
<UserTabs user={user} posts={posts} activeTab={searchParams?.tab} />
|
||||
|
||||
{searchParams?.edit === 'true' && (
|
||||
<UserEditForm user={user} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function fetchUser(id: string): Promise<User> {
|
||||
const res = await fetch(`https://api.example.com/users/${id}`, {
|
||||
cache: 'no-store', // Or 'force-cache', 'revalidate'
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to fetch user');
|
||||
return res.json();
|
||||
}
|
||||
```
|
||||
|
||||
## Parallel Data Fetching
|
||||
|
||||
Fetch multiple resources in parallel with Promise.all.
|
||||
|
||||
```typescript
|
||||
type DashboardProps = {
|
||||
params: { userId: string };
|
||||
};
|
||||
|
||||
export default async function Dashboard({ params }: DashboardProps) {
|
||||
// Parallel fetching
|
||||
const [user, stats, activity] = await Promise.all([
|
||||
fetchUser(params.userId),
|
||||
fetchUserStats(params.userId),
|
||||
fetchRecentActivity(params.userId),
|
||||
]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<UserHeader user={user} />
|
||||
<StatsGrid stats={stats} />
|
||||
<ActivityFeed items={activity} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Sequential vs Waterfall Fetching
|
||||
|
||||
```typescript
|
||||
// ❌ Waterfall - slow
|
||||
async function SlowPage() {
|
||||
const user = await fetchUser('123');
|
||||
const posts = await fetchUserPosts(user.id); // Waits for user
|
||||
const comments = await fetchPostComments(posts[0].id); // Waits for posts
|
||||
return <div>...</div>;
|
||||
}
|
||||
|
||||
// ✅ Parallel - fast
|
||||
async function FastPage() {
|
||||
const userPromise = fetchUser('123');
|
||||
const postsPromise = fetchUserPosts('123');
|
||||
|
||||
const [user, posts] = await Promise.all([userPromise, postsPromise]);
|
||||
|
||||
// If comments depend on posts, fetch after
|
||||
const comments = await fetchPostComments(posts[0].id);
|
||||
|
||||
return <div>...</div>;
|
||||
}
|
||||
```
|
||||
|
||||
## Server Actions - Form Mutations
|
||||
|
||||
Server Actions marked with 'use server' — run on server, callable from client.
|
||||
|
||||
```typescript
|
||||
// actions/user.ts
|
||||
'use server';
|
||||
|
||||
import { revalidatePath, revalidateTag } from 'next/cache';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { z } from 'zod';
|
||||
|
||||
const updateUserSchema = z.object({
|
||||
name: z.string().min(2, 'Name must be at least 2 characters'),
|
||||
email: z.string().email('Invalid email address'),
|
||||
bio: z.string().max(500, 'Bio must be less than 500 characters').optional(),
|
||||
});
|
||||
|
||||
type FormState = {
|
||||
success?: boolean;
|
||||
errors?: Record<string, string[]>;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
export async function updateUser(
|
||||
userId: string,
|
||||
prevState: FormState,
|
||||
formData: FormData
|
||||
): Promise<FormState> {
|
||||
// Validate
|
||||
const parsed = updateUserSchema.safeParse({
|
||||
name: formData.get('name'),
|
||||
email: formData.get('email'),
|
||||
bio: formData.get('bio'),
|
||||
});
|
||||
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
success: false,
|
||||
errors: parsed.error.flatten().fieldErrors,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// Mutate database
|
||||
await db.user.update({
|
||||
where: { id: userId },
|
||||
data: parsed.data,
|
||||
});
|
||||
|
||||
// Revalidate cached data
|
||||
revalidatePath(`/users/${userId}`);
|
||||
revalidateTag(`user-${userId}`);
|
||||
|
||||
return { success: true, message: 'Profile updated successfully' };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Failed to update profile. Please try again.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteUser(userId: string) {
|
||||
await db.user.delete({ where: { id: userId } });
|
||||
revalidatePath('/users');
|
||||
redirect('/users'); // Navigate after mutation
|
||||
}
|
||||
```
|
||||
|
||||
## Client Component Using Server Action
|
||||
|
||||
```typescript
|
||||
// components/UserForm.tsx
|
||||
'use client';
|
||||
|
||||
import { useActionState } from 'react';
|
||||
import { updateUser } from '@/actions/user';
|
||||
|
||||
type FormState = {
|
||||
success?: boolean;
|
||||
errors?: Record<string, string[]>;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
export function UserEditForm({ userId, initialData }: Props) {
|
||||
const [state, formAction, isPending] = useActionState<FormState, FormData>(
|
||||
(prevState, formData) => updateUser(userId, prevState, formData),
|
||||
{}
|
||||
);
|
||||
|
||||
return (
|
||||
<form action={formAction}>
|
||||
<div>
|
||||
<label htmlFor="name">Name</label>
|
||||
<input
|
||||
id="name"
|
||||
name="name"
|
||||
defaultValue={initialData.name}
|
||||
required
|
||||
aria-invalid={!!state.errors?.name}
|
||||
/>
|
||||
{state.errors?.name?.map((error) => (
|
||||
<p key={error} className="error">
|
||||
{error}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="email">Email</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
defaultValue={initialData.email}
|
||||
required
|
||||
aria-invalid={!!state.errors?.email}
|
||||
/>
|
||||
{state.errors?.email?.map((error) => (
|
||||
<p key={error} className="error">
|
||||
{error}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="bio">Bio</label>
|
||||
<textarea
|
||||
id="bio"
|
||||
name="bio"
|
||||
defaultValue={initialData.bio}
|
||||
aria-invalid={!!state.errors?.bio}
|
||||
/>
|
||||
{state.errors?.bio?.map((error) => (
|
||||
<p key={error} className="error">
|
||||
{error}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{state.message && (
|
||||
<div className={state.success ? 'success' : 'error'}>{state.message}</div>
|
||||
)}
|
||||
|
||||
<button type="submit" disabled={isPending}>
|
||||
{isPending ? 'Saving...' : 'Save Changes'}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Programmatic Server Action
|
||||
|
||||
Call Server Actions directly from client code, not just forms.
|
||||
|
||||
```typescript
|
||||
'use client';
|
||||
|
||||
import { deleteUser } from '@/actions/user';
|
||||
import { useTransition } from 'react';
|
||||
|
||||
export function DeleteButton({ userId }: { userId: string }) {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const handleDelete = () => {
|
||||
if (!confirm('Are you sure you want to delete this user?')) return;
|
||||
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await deleteUser(userId);
|
||||
// deleteUser calls redirect(), navigation happens automatically
|
||||
} catch (error) {
|
||||
console.error('Failed to delete user:', error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<button onClick={handleDelete} disabled={isPending}>
|
||||
{isPending ? 'Deleting...' : 'Delete User'}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## use() Hook - Unwrapping Promises
|
||||
|
||||
Pass promises from Server to Client components, unwrap with use().
|
||||
|
||||
```typescript
|
||||
// Server Component
|
||||
async function UserPage({ params }: { params: { id: string } }) {
|
||||
// Don't await - pass promise to client
|
||||
const userPromise = fetchUser(params.id);
|
||||
|
||||
return (
|
||||
<Suspense fallback={<UserSkeleton />}>
|
||||
<UserProfile userPromise={userPromise} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
// Client Component
|
||||
'use client';
|
||||
|
||||
import { use } from 'react';
|
||||
|
||||
type Props = {
|
||||
userPromise: Promise<User>;
|
||||
};
|
||||
|
||||
export function UserProfile({ userPromise }: Props) {
|
||||
// Suspends until resolved
|
||||
const user = use(userPromise);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>{user.name}</h1>
|
||||
<p>{user.email}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## use() with Context
|
||||
|
||||
use() also unwraps context — alternative to useContext.
|
||||
|
||||
```typescript
|
||||
'use client';
|
||||
|
||||
import { use } from 'react';
|
||||
import { ThemeContext } from './ThemeProvider';
|
||||
|
||||
export function ThemedButton() {
|
||||
const theme = use(ThemeContext); // Same as useContext(ThemeContext)
|
||||
|
||||
return <button className={theme.mode}>{theme.primaryColor}</button>;
|
||||
}
|
||||
```
|
||||
|
||||
## Streaming with Suspense
|
||||
|
||||
Stream components as they resolve — faster initial page load.
|
||||
|
||||
```typescript
|
||||
// Server Component
|
||||
export default async function Page() {
|
||||
return (
|
||||
<div>
|
||||
<h1>Dashboard</h1>
|
||||
|
||||
{/* Renders immediately */}
|
||||
<StaticContent />
|
||||
|
||||
{/* Streams when ready */}
|
||||
<Suspense fallback={<Spinner />}>
|
||||
<SlowComponent />
|
||||
</Suspense>
|
||||
|
||||
{/* Independent stream */}
|
||||
<Suspense fallback={<Skeleton />}>
|
||||
<AnotherSlowComponent />
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function SlowComponent() {
|
||||
const data = await slowFetch(); // Takes 2s
|
||||
return <div>{data}</div>;
|
||||
}
|
||||
|
||||
async function AnotherSlowComponent() {
|
||||
const data = await anotherSlowFetch(); // Takes 1s
|
||||
return <div>{data}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling in Server Components
|
||||
|
||||
Use error.tsx for error boundaries.
|
||||
|
||||
```typescript
|
||||
// app/users/[id]/error.tsx
|
||||
'use client';
|
||||
|
||||
export default function Error({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<h2>Something went wrong!</h2>
|
||||
<p>{error.message}</p>
|
||||
<button onClick={reset}>Try again</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Server Component throws error
|
||||
export default async function UserPage({ params }: Props) {
|
||||
const user = await fetchUser(params.id);
|
||||
|
||||
if (!user) {
|
||||
throw new Error('User not found'); // Caught by error.tsx
|
||||
}
|
||||
|
||||
return <div>{user.name}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
## Loading States with loading.tsx
|
||||
|
||||
```typescript
|
||||
// app/users/[id]/loading.tsx
|
||||
export default function Loading() {
|
||||
return <UserSkeleton />;
|
||||
}
|
||||
|
||||
// Automatically wraps page in Suspense
|
||||
// No need for manual Suspense boundary
|
||||
```
|
||||
|
||||
## Server-Only Code
|
||||
|
||||
Ensure code never runs on client.
|
||||
|
||||
```typescript
|
||||
// lib/server-only-utils.ts
|
||||
import 'server-only'; // Throws if imported in client component
|
||||
|
||||
export async function getSecretKey() {
|
||||
return process.env.SECRET_KEY; // Safe - never in client bundle
|
||||
}
|
||||
|
||||
export async function hashPassword(password: string) {
|
||||
const bcrypt = await import('bcrypt');
|
||||
return bcrypt.hash(password, 10);
|
||||
}
|
||||
```
|
||||
|
||||
## Client-Only Code
|
||||
|
||||
Ensure code never runs on server.
|
||||
|
||||
```typescript
|
||||
// lib/client-only-utils.ts
|
||||
import 'client-only';
|
||||
|
||||
export function useLocalStorage(key: string) {
|
||||
// localStorage only available in browser
|
||||
const [value, setValue] = useState(() => localStorage.getItem(key));
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(key, value || '');
|
||||
}, [key, value]);
|
||||
|
||||
return [value, setValue] as const;
|
||||
}
|
||||
```
|
||||
|
||||
## Mixing Server and Client Components
|
||||
|
||||
```typescript
|
||||
// app/page.tsx (Server Component)
|
||||
export default async function Page() {
|
||||
const data = await fetchData();
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Server Component - can be async */}
|
||||
<ServerComponent data={data} />
|
||||
|
||||
{/* Client Component - interactive */}
|
||||
<ClientComponent initialData={data} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ServerComponent.tsx (Server Component - default)
|
||||
export function ServerComponent({ data }: { data: Data }) {
|
||||
return <div>{data.title}</div>;
|
||||
}
|
||||
|
||||
// ClientComponent.tsx (Client Component)
|
||||
'use client';
|
||||
|
||||
export function ClientComponent({ initialData }: { initialData: Data }) {
|
||||
const [data, setData] = useState(initialData);
|
||||
|
||||
return (
|
||||
<button onClick={() => setData({ ...data, count: data.count + 1 })}>
|
||||
{data.count}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Server Component Patterns
|
||||
|
||||
```typescript
|
||||
// ✅ Server Component can:
|
||||
// - Be async
|
||||
// - Fetch data directly
|
||||
// - Access backend resources (DB, filesystem)
|
||||
// - Use server-only packages
|
||||
// - Pass serializable props to client components
|
||||
|
||||
export default async function Page() {
|
||||
const db = await connectDB(); // Direct DB access
|
||||
const users = await db.user.findMany();
|
||||
|
||||
return <UserList users={users} />; // Pass serializable data
|
||||
}
|
||||
|
||||
// ❌ Server Component cannot:
|
||||
// - Use hooks (useState, useEffect, etc)
|
||||
// - Use browser APIs (localStorage, window, etc)
|
||||
// - Add event listeners (onClick, onChange, etc)
|
||||
// - Use React Context
|
||||
|
||||
// ✅ Client Component can:
|
||||
// - Use hooks
|
||||
// - Use browser APIs
|
||||
// - Add event listeners
|
||||
// - Use React Context
|
||||
// - Import Server Components as children
|
||||
|
||||
'use client';
|
||||
|
||||
export function Layout({ children }: { children: React.ReactNode }) {
|
||||
const [theme, setTheme] = useState('light');
|
||||
|
||||
return (
|
||||
<div className={theme}>
|
||||
<button onClick={() => setTheme('dark')}>Toggle</button>
|
||||
{children} {/* Server Component can be child */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ❌ Client Component cannot:
|
||||
// - Be async
|
||||
// - Directly access backend resources
|
||||
// - Import server-only packages
|
||||
```
|
||||
|
||||
## Progressive Enhancement with Server Actions
|
||||
|
||||
Forms work without JavaScript when using Server Actions.
|
||||
|
||||
```typescript
|
||||
// components/AddTodoForm.tsx
|
||||
import { addTodo } from '@/actions/todos';
|
||||
|
||||
export function AddTodoForm() {
|
||||
return (
|
||||
<form action={addTodo}>
|
||||
<input name="title" required />
|
||||
<button type="submit">Add Todo</button>
|
||||
{/* Works without JS - progressive enhancement */}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
// actions/todos.ts
|
||||
'use server';
|
||||
|
||||
export async function addTodo(formData: FormData) {
|
||||
const title = formData.get('title');
|
||||
if (typeof title !== 'string') return;
|
||||
|
||||
await db.todo.create({ data: { title } });
|
||||
revalidatePath('/todos');
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,574 @@
|
||||
# Event Handler TypeScript Patterns
|
||||
|
||||
Proper event typing ensures type-safe access to event properties and target elements.
|
||||
|
||||
## Mouse Events
|
||||
|
||||
```typescript
|
||||
// Click events
|
||||
function handleClick(event: React.MouseEvent<HTMLButtonElement>) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
// Target element typed correctly
|
||||
event.currentTarget.disabled = true;
|
||||
event.currentTarget.textContent = 'Clicked';
|
||||
|
||||
// Mouse position
|
||||
console.log(event.clientX, event.clientY);
|
||||
console.log(event.pageX, event.pageY);
|
||||
|
||||
// Mouse buttons
|
||||
console.log(event.button); // 0 = left, 1 = middle, 2 = right
|
||||
console.log(event.buttons); // Bitmask of pressed buttons
|
||||
|
||||
// Modifier keys
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
console.log('Ctrl/Cmd + Click');
|
||||
}
|
||||
if (event.shiftKey) {
|
||||
console.log('Shift + Click');
|
||||
}
|
||||
if (event.altKey) {
|
||||
console.log('Alt + Click');
|
||||
}
|
||||
}
|
||||
|
||||
// Mouse movement
|
||||
function handleMouseMove(event: React.MouseEvent<HTMLDivElement>) {
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
const x = event.clientX - rect.left;
|
||||
const y = event.clientY - rect.top;
|
||||
console.log(`Position in element: ${x}, ${y}`);
|
||||
}
|
||||
|
||||
// Hover events
|
||||
function handleMouseEnter(event: React.MouseEvent<HTMLDivElement>) {
|
||||
event.currentTarget.style.backgroundColor = 'lightblue';
|
||||
}
|
||||
|
||||
function handleMouseLeave(event: React.MouseEvent<HTMLDivElement>) {
|
||||
event.currentTarget.style.backgroundColor = '';
|
||||
}
|
||||
|
||||
// Double click
|
||||
function handleDoubleClick(event: React.MouseEvent<HTMLElement>) {
|
||||
console.log('Double clicked');
|
||||
}
|
||||
```
|
||||
|
||||
## Form Events
|
||||
|
||||
```typescript
|
||||
// Form submission
|
||||
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
|
||||
const form = event.currentTarget;
|
||||
const formData = new FormData(form);
|
||||
|
||||
const data = {
|
||||
name: formData.get('name') as string,
|
||||
email: formData.get('email') as string,
|
||||
};
|
||||
|
||||
console.log(data);
|
||||
}
|
||||
|
||||
// Input change
|
||||
function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
|
||||
const target = event.target;
|
||||
|
||||
// For text inputs
|
||||
if (target.type === 'text' || target.type === 'email') {
|
||||
console.log(target.value); // string
|
||||
}
|
||||
|
||||
// For checkboxes
|
||||
if (target.type === 'checkbox') {
|
||||
console.log(target.checked); // boolean
|
||||
}
|
||||
|
||||
// For radio buttons
|
||||
if (target.type === 'radio') {
|
||||
console.log(target.value, target.checked);
|
||||
}
|
||||
|
||||
// For number inputs
|
||||
if (target.type === 'number') {
|
||||
console.log(target.valueAsNumber); // number
|
||||
}
|
||||
|
||||
// For file inputs
|
||||
if (target.type === 'file') {
|
||||
const files = target.files; // FileList | null
|
||||
if (files && files.length > 0) {
|
||||
console.log(files[0].name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Textarea change
|
||||
function handleTextareaChange(event: React.ChangeEvent<HTMLTextAreaElement>) {
|
||||
console.log(event.target.value);
|
||||
console.log(event.target.selectionStart); // Cursor position
|
||||
}
|
||||
|
||||
// Select change
|
||||
function handleSelectChange(event: React.ChangeEvent<HTMLSelectElement>) {
|
||||
const value = event.target.value;
|
||||
const selectedIndex = event.target.selectedIndex;
|
||||
const selectedOption = event.target.options[selectedIndex];
|
||||
console.log(value, selectedOption.text);
|
||||
}
|
||||
|
||||
// Input events (fires on every keystroke)
|
||||
function handleInput(event: React.FormEvent<HTMLInputElement>) {
|
||||
console.log(event.currentTarget.value);
|
||||
}
|
||||
|
||||
// Reset event
|
||||
function handleReset(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
console.log('Form reset');
|
||||
}
|
||||
```
|
||||
|
||||
## Keyboard Events
|
||||
|
||||
```typescript
|
||||
function handleKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
|
||||
// Key identification
|
||||
console.log(event.key); // 'Enter', 'Escape', 'a', etc.
|
||||
console.log(event.code); // 'Enter', 'Escape', 'KeyA', etc.
|
||||
|
||||
// Common patterns
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
console.log('Enter pressed');
|
||||
}
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
event.currentTarget.blur();
|
||||
}
|
||||
|
||||
// Arrow keys
|
||||
if (event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
// Navigate up
|
||||
}
|
||||
|
||||
// Modifier keys
|
||||
if (event.ctrlKey && event.key === 's') {
|
||||
event.preventDefault();
|
||||
console.log('Ctrl+S - Save');
|
||||
}
|
||||
|
||||
if (event.metaKey && event.key === 'k') {
|
||||
event.preventDefault();
|
||||
console.log('Cmd+K - Search');
|
||||
}
|
||||
|
||||
// Check multiple modifiers
|
||||
if (event.ctrlKey && event.shiftKey && event.key === 'P') {
|
||||
event.preventDefault();
|
||||
console.log('Ctrl+Shift+P - Command palette');
|
||||
}
|
||||
|
||||
// Key combinations
|
||||
if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') {
|
||||
console.log('Submit with Ctrl/Cmd+Enter');
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyUp(event: React.KeyboardEvent<HTMLInputElement>) {
|
||||
console.log('Key released:', event.key);
|
||||
}
|
||||
|
||||
function handleKeyPress(event: React.KeyboardEvent<HTMLInputElement>) {
|
||||
// Deprecated - use keyDown instead
|
||||
console.log('Key pressed:', event.key);
|
||||
}
|
||||
```
|
||||
|
||||
## Focus Events
|
||||
|
||||
```typescript
|
||||
function handleFocus(event: React.FocusEvent<HTMLInputElement>) {
|
||||
// Select all text on focus
|
||||
event.target.select();
|
||||
|
||||
// Add visual indicator
|
||||
event.currentTarget.classList.add('focused');
|
||||
}
|
||||
|
||||
function handleBlur(event: React.FocusEvent<HTMLInputElement>) {
|
||||
// Validate on blur
|
||||
const value = event.target.value;
|
||||
if (value === '') {
|
||||
event.currentTarget.classList.add('error');
|
||||
}
|
||||
|
||||
// Remove visual indicator
|
||||
event.currentTarget.classList.remove('focused');
|
||||
}
|
||||
|
||||
// Focus within
|
||||
function handleFocusWithin(event: React.FocusEvent<HTMLDivElement>) {
|
||||
// Related target - element receiving focus
|
||||
const relatedTarget = event.relatedTarget as HTMLElement | null;
|
||||
console.log('Focus moved from:', relatedTarget);
|
||||
}
|
||||
```
|
||||
|
||||
## Drag and Drop Events
|
||||
|
||||
```typescript
|
||||
function handleDragStart(event: React.DragEvent<HTMLDivElement>) {
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
event.dataTransfer.setData('text/plain', event.currentTarget.id);
|
||||
|
||||
// Custom drag image
|
||||
const dragImage = document.createElement('div');
|
||||
dragImage.textContent = 'Dragging...';
|
||||
event.dataTransfer.setDragImage(dragImage, 0, 0);
|
||||
}
|
||||
|
||||
function handleDragOver(event: React.DragEvent<HTMLDivElement>) {
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = 'move';
|
||||
event.currentTarget.classList.add('drag-over');
|
||||
}
|
||||
|
||||
function handleDragLeave(event: React.DragEvent<HTMLDivElement>) {
|
||||
event.currentTarget.classList.remove('drag-over');
|
||||
}
|
||||
|
||||
function handleDrop(event: React.DragEvent<HTMLDivElement>) {
|
||||
event.preventDefault();
|
||||
event.currentTarget.classList.remove('drag-over');
|
||||
|
||||
const data = event.dataTransfer.getData('text/plain');
|
||||
console.log('Dropped:', data);
|
||||
|
||||
// Handle files
|
||||
const files = event.dataTransfer.files;
|
||||
if (files.length > 0) {
|
||||
Array.from(files).forEach((file) => {
|
||||
console.log(file.name, file.type, file.size);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function handleDragEnd(event: React.DragEvent<HTMLDivElement>) {
|
||||
console.log('Drag ended');
|
||||
}
|
||||
```
|
||||
|
||||
## Clipboard Events
|
||||
|
||||
```typescript
|
||||
function handleCopy(event: React.ClipboardEvent<HTMLDivElement>) {
|
||||
event.preventDefault();
|
||||
|
||||
// Custom copy behavior
|
||||
const selection = window.getSelection();
|
||||
if (selection) {
|
||||
const text = `Copied from app: ${selection.toString()}`;
|
||||
event.clipboardData.setData('text/plain', text);
|
||||
}
|
||||
}
|
||||
|
||||
function handleCut(event: React.ClipboardEvent<HTMLInputElement>) {
|
||||
console.log('Cut:', event.currentTarget.value);
|
||||
}
|
||||
|
||||
function handlePaste(event: React.ClipboardEvent<HTMLInputElement>) {
|
||||
event.preventDefault();
|
||||
|
||||
const pastedText = event.clipboardData.getData('text/plain');
|
||||
console.log('Pasted:', pastedText);
|
||||
|
||||
// Validate pasted content
|
||||
const sanitized = pastedText.replace(/[^a-zA-Z0-9]/g, '');
|
||||
event.currentTarget.value = sanitized;
|
||||
}
|
||||
```
|
||||
|
||||
## Composition Events (IME)
|
||||
|
||||
```typescript
|
||||
// For international input methods (Chinese, Japanese, etc.)
|
||||
function handleCompositionStart(event: React.CompositionEvent<HTMLInputElement>) {
|
||||
console.log('Composition started');
|
||||
}
|
||||
|
||||
function handleCompositionUpdate(event: React.CompositionEvent<HTMLInputElement>) {
|
||||
console.log('Composing:', event.data);
|
||||
}
|
||||
|
||||
function handleCompositionEnd(event: React.CompositionEvent<HTMLInputElement>) {
|
||||
console.log('Composition ended:', event.data);
|
||||
}
|
||||
```
|
||||
|
||||
## Touch Events
|
||||
|
||||
```typescript
|
||||
function handleTouchStart(event: React.TouchEvent<HTMLDivElement>) {
|
||||
const touch = event.touches[0];
|
||||
console.log('Touch start:', touch.clientX, touch.clientY);
|
||||
}
|
||||
|
||||
function handleTouchMove(event: React.TouchEvent<HTMLDivElement>) {
|
||||
event.preventDefault(); // Prevent scrolling
|
||||
|
||||
const touch = event.touches[0];
|
||||
console.log('Touch move:', touch.clientX, touch.clientY);
|
||||
}
|
||||
|
||||
function handleTouchEnd(event: React.TouchEvent<HTMLDivElement>) {
|
||||
console.log('Touch ended');
|
||||
}
|
||||
|
||||
// Multi-touch
|
||||
function handleMultiTouch(event: React.TouchEvent<HTMLDivElement>) {
|
||||
if (event.touches.length === 2) {
|
||||
const [touch1, touch2] = event.touches;
|
||||
|
||||
const distance = Math.hypot(
|
||||
touch2.clientX - touch1.clientX,
|
||||
touch2.clientY - touch1.clientY
|
||||
);
|
||||
|
||||
console.log('Pinch distance:', distance);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Wheel Events
|
||||
|
||||
```typescript
|
||||
function handleWheel(event: React.WheelEvent<HTMLDivElement>) {
|
||||
// Prevent default scroll
|
||||
event.preventDefault();
|
||||
|
||||
// Scroll delta
|
||||
console.log('Delta X:', event.deltaX);
|
||||
console.log('Delta Y:', event.deltaY);
|
||||
console.log('Delta Z:', event.deltaZ);
|
||||
|
||||
// Delta mode (0 = pixels, 1 = lines, 2 = pages)
|
||||
console.log('Delta mode:', event.deltaMode);
|
||||
|
||||
// Zoom with Ctrl+Wheel
|
||||
if (event.ctrlKey) {
|
||||
const zoomDelta = event.deltaY > 0 ? -0.1 : 0.1;
|
||||
console.log('Zoom:', zoomDelta);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Generic Event Handlers
|
||||
|
||||
Reusable handlers with proper typing.
|
||||
|
||||
```typescript
|
||||
// Generic change handler
|
||||
function createChangeHandler<T extends HTMLElement>(
|
||||
callback: (value: string) => void
|
||||
) {
|
||||
return (event: React.ChangeEvent<T>) => {
|
||||
if ('value' in event.target) {
|
||||
callback(event.target.value);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Usage
|
||||
const handleNameChange = createChangeHandler<HTMLInputElement>((value) => {
|
||||
setName(value);
|
||||
});
|
||||
|
||||
const handleBioChange = createChangeHandler<HTMLTextAreaElement>((value) => {
|
||||
setBio(value);
|
||||
});
|
||||
|
||||
// Generic click handler with target validation
|
||||
function createClickHandler<T extends HTMLElement>(
|
||||
selector: string,
|
||||
callback: (element: T) => void
|
||||
) {
|
||||
return (event: React.MouseEvent<HTMLElement>) => {
|
||||
const target = event.target as HTMLElement;
|
||||
const match = target.closest(selector);
|
||||
|
||||
if (match) {
|
||||
callback(match as T);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Usage
|
||||
const handleItemClick = createClickHandler<HTMLLIElement>('li[data-id]', (item) => {
|
||||
const id = item.dataset.id;
|
||||
console.log('Clicked item:', id);
|
||||
});
|
||||
```
|
||||
|
||||
## Event Handler Type Aliases
|
||||
|
||||
```typescript
|
||||
// Create reusable type aliases
|
||||
type InputChangeHandler = React.ChangeEventHandler<HTMLInputElement>;
|
||||
type ButtonClickHandler = React.MouseEventHandler<HTMLButtonElement>;
|
||||
type FormSubmitHandler = React.FormEventHandler<HTMLFormElement>;
|
||||
|
||||
// Usage
|
||||
const handleChange: InputChangeHandler = (event) => {
|
||||
console.log(event.target.value);
|
||||
};
|
||||
|
||||
const handleClick: ButtonClickHandler = (event) => {
|
||||
event.currentTarget.disabled = true;
|
||||
};
|
||||
|
||||
const handleSubmit: FormSubmitHandler = (event) => {
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
// Generic handler type
|
||||
type EventHandler<E extends HTMLElement, Evt extends React.SyntheticEvent> = (
|
||||
event: Evt & { currentTarget: E }
|
||||
) => void;
|
||||
|
||||
// Usage
|
||||
const handleInput: EventHandler<HTMLInputElement, React.ChangeEvent<HTMLInputElement>> = (
|
||||
event
|
||||
) => {
|
||||
console.log(event.currentTarget.value);
|
||||
};
|
||||
```
|
||||
|
||||
## Delegated Event Handlers
|
||||
|
||||
Type-safe event delegation.
|
||||
|
||||
```typescript
|
||||
function ListContainer() {
|
||||
const handleListClick = (event: React.MouseEvent<HTMLUListElement>) => {
|
||||
const target = event.target as HTMLElement;
|
||||
|
||||
// Find clicked list item
|
||||
const listItem = target.closest('li');
|
||||
if (!listItem) return;
|
||||
|
||||
// Type guard for data attributes
|
||||
const itemId = listItem.getAttribute('data-id');
|
||||
if (itemId) {
|
||||
console.log('Clicked item:', itemId);
|
||||
}
|
||||
|
||||
// Handle button within list item
|
||||
if (target.matches('button.delete')) {
|
||||
event.stopPropagation();
|
||||
console.log('Delete clicked');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ul onClick={handleListClick}>
|
||||
<li data-id="1">
|
||||
Item 1<button className="delete">Delete</button>
|
||||
</li>
|
||||
<li data-id="2">
|
||||
Item 2<button className="delete">Delete</button>
|
||||
</li>
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Native vs Synthetic Events
|
||||
|
||||
```typescript
|
||||
function Component() {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const element = ref.current;
|
||||
if (!element) return;
|
||||
|
||||
// Native event listener
|
||||
const handleNativeClick = (event: MouseEvent) => {
|
||||
console.log('Native click:', event.target);
|
||||
};
|
||||
|
||||
element.addEventListener('click', handleNativeClick);
|
||||
|
||||
return () => {
|
||||
element.removeEventListener('click', handleNativeClick);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// React synthetic event
|
||||
const handleReactClick = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||
console.log('React click:', event.target);
|
||||
|
||||
// Access native event
|
||||
const nativeEvent = event.nativeEvent;
|
||||
console.log('Native event:', nativeEvent);
|
||||
};
|
||||
|
||||
return <div ref={ref} onClick={handleReactClick}>Click me</div>;
|
||||
}
|
||||
```
|
||||
|
||||
## Custom Events
|
||||
|
||||
```typescript
|
||||
// Define custom event type
|
||||
type CustomEventMap = {
|
||||
'user:login': CustomEvent<{ userId: string }>;
|
||||
'user:logout': CustomEvent;
|
||||
};
|
||||
|
||||
// Typed custom event dispatcher
|
||||
function dispatchCustomEvent<K extends keyof CustomEventMap>(
|
||||
element: HTMLElement,
|
||||
type: K,
|
||||
detail?: CustomEventMap[K]['detail']
|
||||
) {
|
||||
const event = new CustomEvent(type, { detail, bubbles: true });
|
||||
element.dispatchEvent(event);
|
||||
}
|
||||
|
||||
// Component using custom events
|
||||
function UserButton() {
|
||||
const ref = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const handleLogin = () => {
|
||||
if (ref.current) {
|
||||
dispatchCustomEvent(ref.current, 'user:login', { userId: '123' });
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const element = ref.current;
|
||||
if (!element) return;
|
||||
|
||||
const handleCustomLogin = (event: Event) => {
|
||||
const customEvent = event as CustomEvent<{ userId: string }>;
|
||||
console.log('User logged in:', customEvent.detail.userId);
|
||||
};
|
||||
|
||||
element.addEventListener('user:login', handleCustomLogin);
|
||||
|
||||
return () => {
|
||||
element.removeEventListener('user:login', handleCustomLogin);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return <button ref={ref} onClick={handleLogin}>Login</button>;
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,456 @@
|
||||
# Hook TypeScript Patterns
|
||||
|
||||
Type-safe hook patterns for useState, useRef, useReducer, useContext, and custom hooks.
|
||||
|
||||
## useState
|
||||
|
||||
Type inference works for simple types; explicit typing needed for unions/null.
|
||||
|
||||
```typescript
|
||||
// Inference works
|
||||
const [count, setCount] = useState(0); // number
|
||||
const [name, setName] = useState(''); // string
|
||||
const [items, setItems] = useState<string[]>([]); // explicit for empty arrays
|
||||
|
||||
// Explicit for unions/null
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [status, setStatus] = useState<'idle' | 'loading' | 'success'>('idle');
|
||||
|
||||
// Complex initial state
|
||||
type FormData = { name: string; email: string };
|
||||
const [formData, setFormData] = useState<FormData>({
|
||||
name: '',
|
||||
email: '',
|
||||
});
|
||||
|
||||
// Lazy initialization
|
||||
const [data, setData] = useState<Data>(() => {
|
||||
const cached = localStorage.getItem('data');
|
||||
return cached ? JSON.parse(cached) : defaultData;
|
||||
});
|
||||
```
|
||||
|
||||
## useRef
|
||||
|
||||
Distinguish DOM refs (null initial) from mutable value refs (value initial).
|
||||
|
||||
```typescript
|
||||
// DOM element ref - null initial, readonly .current
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const divRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus(); // Optional chaining for null
|
||||
}, []);
|
||||
|
||||
// Mutable value ref - non-null initial, mutable .current
|
||||
const countRef = useRef<number>(0);
|
||||
countRef.current += 1; // No optional chaining
|
||||
|
||||
const previousValueRef = useRef<string | undefined>(undefined);
|
||||
previousValueRef.current = currentValue;
|
||||
|
||||
// Interval/timeout ref
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval>>();
|
||||
|
||||
useEffect(() => {
|
||||
timeoutRef.current = setTimeout(() => {}, 1000);
|
||||
return () => {
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Callback ref for dynamic elements
|
||||
const callbackRef = useCallback((node: HTMLDivElement | null) => {
|
||||
if (node) {
|
||||
node.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
}, []);
|
||||
```
|
||||
|
||||
## useReducer
|
||||
|
||||
Typed actions with discriminated unions.
|
||||
|
||||
```typescript
|
||||
type State = {
|
||||
count: number;
|
||||
status: 'idle' | 'loading' | 'success' | 'error';
|
||||
error?: string;
|
||||
};
|
||||
|
||||
type Action =
|
||||
| { type: 'increment' }
|
||||
| { type: 'decrement' }
|
||||
| { type: 'set'; payload: number }
|
||||
| { type: 'setStatus'; payload: State['status'] }
|
||||
| { type: 'setError'; payload: string };
|
||||
|
||||
function reducer(state: State, action: Action): State {
|
||||
switch (action.type) {
|
||||
case 'increment':
|
||||
return { ...state, count: state.count + 1 };
|
||||
case 'decrement':
|
||||
return { ...state, count: state.count - 1 };
|
||||
case 'set':
|
||||
return { ...state, count: action.payload };
|
||||
case 'setStatus':
|
||||
return { ...state, status: action.payload };
|
||||
case 'setError':
|
||||
return { ...state, status: 'error', error: action.payload };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
function Component() {
|
||||
const [state, dispatch] = useReducer(reducer, {
|
||||
count: 0,
|
||||
status: 'idle',
|
||||
});
|
||||
|
||||
dispatch({ type: 'set', payload: 10 }); // Type-safe
|
||||
dispatch({ type: 'set' }); // Error: payload required
|
||||
dispatch({ type: 'unknown' }); // Error: invalid action type
|
||||
}
|
||||
```
|
||||
|
||||
### Reducer with Context
|
||||
|
||||
```typescript
|
||||
type AuthState = {
|
||||
user: User | null;
|
||||
isAuthenticated: boolean;
|
||||
};
|
||||
|
||||
type AuthAction =
|
||||
| { type: 'login'; payload: User }
|
||||
| { type: 'logout' };
|
||||
|
||||
type AuthContextValue = {
|
||||
state: AuthState;
|
||||
dispatch: React.Dispatch<AuthAction>;
|
||||
};
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
|
||||
function authReducer(state: AuthState, action: AuthAction): AuthState {
|
||||
switch (action.type) {
|
||||
case 'login':
|
||||
return { user: action.payload, isAuthenticated: true };
|
||||
case 'logout':
|
||||
return { user: null, isAuthenticated: false };
|
||||
}
|
||||
}
|
||||
|
||||
function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const [state, dispatch] = useReducer(authReducer, {
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
});
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ state, dispatch }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function useAuth() {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) throw new Error('useAuth must be used within AuthProvider');
|
||||
return context;
|
||||
}
|
||||
```
|
||||
|
||||
## useContext
|
||||
|
||||
Typed context with and without default values.
|
||||
|
||||
```typescript
|
||||
// Context with default value
|
||||
type Theme = 'light' | 'dark';
|
||||
const ThemeContext = createContext<Theme>('light');
|
||||
|
||||
function useTheme() {
|
||||
return useContext(ThemeContext); // Always Theme, never null
|
||||
}
|
||||
|
||||
// Context without default (must handle null)
|
||||
type User = { id: string; name: string };
|
||||
const UserContext = createContext<User | null>(null);
|
||||
|
||||
function useUser() {
|
||||
const user = useContext(UserContext);
|
||||
if (!user) throw new Error('useUser must be used within UserProvider');
|
||||
return user; // Type narrowed to User
|
||||
}
|
||||
|
||||
// Context with complex value
|
||||
type AppContextValue = {
|
||||
theme: Theme;
|
||||
user: User | null;
|
||||
setTheme: (theme: Theme) => void;
|
||||
login: (user: User) => void;
|
||||
logout: () => void;
|
||||
};
|
||||
|
||||
const AppContext = createContext<AppContextValue | null>(null);
|
||||
|
||||
function useApp() {
|
||||
const context = useContext(AppContext);
|
||||
if (!context) throw new Error('useApp must be used within AppProvider');
|
||||
return context;
|
||||
}
|
||||
```
|
||||
|
||||
## Custom Hooks
|
||||
|
||||
Return type patterns for simple and complex hooks.
|
||||
|
||||
```typescript
|
||||
// Object return - properties accessed by name
|
||||
function useCounter(initial: number) {
|
||||
const [count, setCount] = useState(initial);
|
||||
const increment = () => setCount((c) => c + 1);
|
||||
const decrement = () => setCount((c) => c - 1);
|
||||
const reset = () => setCount(initial);
|
||||
|
||||
return { count, increment, decrement, reset };
|
||||
}
|
||||
|
||||
// Usage
|
||||
const { count, increment } = useCounter(0);
|
||||
|
||||
// Tuple return - positional destructuring
|
||||
function useToggle(initial = false): [boolean, () => void, () => void, () => void] {
|
||||
const [value, setValue] = useState(initial);
|
||||
const toggle = () => setValue((v) => !v);
|
||||
const setTrue = () => setValue(true);
|
||||
const setFalse = () => setValue(false);
|
||||
|
||||
return [value, toggle, setTrue, setFalse];
|
||||
}
|
||||
|
||||
// Usage
|
||||
const [isOpen, toggleOpen, open, close] = useToggle();
|
||||
|
||||
// as const for tuple inference
|
||||
function useLocalStorage<T>(key: string, initial: T) {
|
||||
const [value, setValue] = useState<T>(() => {
|
||||
const stored = localStorage.getItem(key);
|
||||
return stored ? JSON.parse(stored) : initial;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(key, JSON.stringify(value));
|
||||
}, [key, value]);
|
||||
|
||||
return [value, setValue] as const; // readonly tuple
|
||||
}
|
||||
|
||||
// Generic custom hook
|
||||
function useFetch<T>(url: string) {
|
||||
const [data, setData] = useState<T | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
fetch(url)
|
||||
.then((res) => res.json())
|
||||
.then((json: T) => {
|
||||
if (!cancelled) {
|
||||
setData(json);
|
||||
setLoading(false);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) {
|
||||
setError(err);
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [url]);
|
||||
|
||||
return { data, loading, error };
|
||||
}
|
||||
|
||||
// Usage - T inferred from usage or explicit
|
||||
const { data } = useFetch<User[]>('/api/users');
|
||||
```
|
||||
|
||||
## useCallback and useMemo
|
||||
|
||||
Typed callbacks and memoized values.
|
||||
|
||||
```typescript
|
||||
// useCallback with typed parameters
|
||||
const handleClick = useCallback((id: string, event: React.MouseEvent) => {
|
||||
console.log(id, event.target);
|
||||
}, []);
|
||||
|
||||
// useCallback returning value
|
||||
const formatDate = useCallback((date: Date): string => {
|
||||
return date.toLocaleDateString();
|
||||
}, []);
|
||||
|
||||
// useMemo with explicit return type
|
||||
const sortedItems = useMemo((): Item[] => {
|
||||
return [...items].sort((a, b) => a.name.localeCompare(b.name));
|
||||
}, [items]);
|
||||
|
||||
// useMemo with complex computation
|
||||
const stats = useMemo(() => {
|
||||
return {
|
||||
total: items.length,
|
||||
average: items.reduce((a, b) => a + b.value, 0) / items.length,
|
||||
max: Math.max(...items.map((i) => i.value)),
|
||||
};
|
||||
}, [items]);
|
||||
```
|
||||
|
||||
## useImperativeHandle
|
||||
|
||||
Expose imperative methods from components.
|
||||
|
||||
```typescript
|
||||
type InputHandle = {
|
||||
focus: () => void;
|
||||
clear: () => void;
|
||||
getValue: () => string;
|
||||
};
|
||||
|
||||
type InputProps = {
|
||||
ref?: React.Ref<InputHandle>;
|
||||
label: string;
|
||||
};
|
||||
|
||||
function CustomInput({ ref, label }: InputProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
focus: () => inputRef.current?.focus(),
|
||||
clear: () => {
|
||||
if (inputRef.current) inputRef.current.value = '';
|
||||
},
|
||||
getValue: () => inputRef.current?.value ?? '',
|
||||
}));
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label>{label}</label>
|
||||
<input ref={inputRef} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Usage
|
||||
function Form() {
|
||||
const inputRef = useRef<InputHandle>(null);
|
||||
|
||||
const handleSubmit = () => {
|
||||
const value = inputRef.current?.getValue();
|
||||
inputRef.current?.clear();
|
||||
};
|
||||
|
||||
return (
|
||||
<form>
|
||||
<CustomInput ref={inputRef} label="Name" />
|
||||
<button onClick={() => inputRef.current?.focus()}>Focus</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## useLayoutEffect
|
||||
|
||||
Same signature as useEffect, runs synchronously after DOM mutations.
|
||||
|
||||
```typescript
|
||||
function Tooltip({ targetRef }: { targetRef: React.RefObject<HTMLElement> }) {
|
||||
const tooltipRef = useRef<HTMLDivElement>(null);
|
||||
const [position, setPosition] = useState({ top: 0, left: 0 });
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (targetRef.current && tooltipRef.current) {
|
||||
const rect = targetRef.current.getBoundingClientRect();
|
||||
setPosition({
|
||||
top: rect.bottom + 8,
|
||||
left: rect.left,
|
||||
});
|
||||
}
|
||||
}, [targetRef]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={tooltipRef}
|
||||
style={{ position: 'fixed', top: position.top, left: position.left }}
|
||||
>
|
||||
Tooltip content
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## useId
|
||||
|
||||
Generate unique IDs for accessibility.
|
||||
|
||||
```typescript
|
||||
function FormField({ label }: { label: string }) {
|
||||
const id = useId();
|
||||
const errorId = useId();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label htmlFor={id}>{label}</label>
|
||||
<input id={id} aria-describedby={errorId} />
|
||||
<span id={errorId}>Error message</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## useSyncExternalStore
|
||||
|
||||
Subscribe to external stores with SSR support.
|
||||
|
||||
```typescript
|
||||
type Store<T> = {
|
||||
getState: () => T;
|
||||
subscribe: (callback: () => void) => () => void;
|
||||
};
|
||||
|
||||
function useStore<T>(store: Store<T>): T {
|
||||
return useSyncExternalStore(
|
||||
store.subscribe,
|
||||
store.getState,
|
||||
store.getState // Server snapshot
|
||||
);
|
||||
}
|
||||
|
||||
// Example: window width store
|
||||
const widthStore: Store<number> = {
|
||||
getState: () => (typeof window !== 'undefined' ? window.innerWidth : 0),
|
||||
subscribe: (callback) => {
|
||||
window.addEventListener('resize', callback);
|
||||
return () => window.removeEventListener('resize', callback);
|
||||
},
|
||||
};
|
||||
|
||||
function useWindowWidth() {
|
||||
return useSyncExternalStore(
|
||||
widthStore.subscribe,
|
||||
widthStore.getState,
|
||||
() => 0 // Server fallback
|
||||
);
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,638 @@
|
||||
# React 19 TypeScript Patterns
|
||||
|
||||
React 19 introduces breaking changes and new APIs requiring updated TypeScript patterns.
|
||||
|
||||
## ref as Prop (No More forwardRef)
|
||||
|
||||
React 19 allows ref as regular prop — forwardRef deprecated but still works.
|
||||
|
||||
```typescript
|
||||
// ✅ React 19 - ref as prop
|
||||
type InputProps = {
|
||||
ref?: React.Ref<HTMLInputElement>;
|
||||
label: string;
|
||||
} & React.ComponentPropsWithoutRef<'input'>;
|
||||
|
||||
export function Input({ ref, label, ...props }: InputProps) {
|
||||
return (
|
||||
<div>
|
||||
<label>{label}</label>
|
||||
<input ref={ref} {...props} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Usage
|
||||
function Form() {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
return (
|
||||
<form>
|
||||
<Input ref={inputRef} label="Name" />
|
||||
<button onClick={() => inputRef.current?.focus()}>Focus</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// ❌ Old pattern (still works, but unnecessary)
|
||||
import { forwardRef } from 'react';
|
||||
|
||||
type InputProps = {
|
||||
label: string;
|
||||
} & React.ComponentPropsWithoutRef<'input'>;
|
||||
|
||||
export const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
({ label, ...props }, ref) => {
|
||||
return (
|
||||
<div>
|
||||
<label>{label}</label>
|
||||
<input ref={ref} {...props} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Input.displayName = 'Input';
|
||||
```
|
||||
|
||||
### Generic Components with ref
|
||||
|
||||
```typescript
|
||||
type SelectProps<T> = {
|
||||
ref?: React.Ref<HTMLSelectElement>;
|
||||
options: T[];
|
||||
value: T;
|
||||
onChange: (value: T) => void;
|
||||
getLabel: (option: T) => string;
|
||||
};
|
||||
|
||||
export function Select<T>({ ref, options, value, onChange, getLabel }: SelectProps<T>) {
|
||||
return (
|
||||
<select
|
||||
ref={ref}
|
||||
value={getLabel(value)}
|
||||
onChange={(e) => {
|
||||
const selected = options.find((opt) => getLabel(opt) === e.target.value);
|
||||
if (selected) onChange(selected);
|
||||
}}
|
||||
>
|
||||
{options.map((opt) => (
|
||||
<option key={getLabel(opt)} value={getLabel(opt)}>
|
||||
{getLabel(opt)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Combining ref with Other Props
|
||||
|
||||
```typescript
|
||||
type ButtonProps = {
|
||||
ref?: React.Ref<HTMLButtonElement>;
|
||||
variant?: 'primary' | 'secondary';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
} & React.ComponentPropsWithoutRef<'button'>;
|
||||
|
||||
export function Button({
|
||||
ref,
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
className={`btn btn-${variant} btn-${size} ${className || ''}`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## useActionState - Form State Management
|
||||
|
||||
Replaces useFormState — manages form submission state with Server Actions.
|
||||
|
||||
```typescript
|
||||
'use client';
|
||||
|
||||
import { useActionState } from 'react';
|
||||
|
||||
type FormState = {
|
||||
success?: boolean;
|
||||
errors?: Record<string, string[]>;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
type FormData = {
|
||||
email: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
// Server Action
|
||||
async function login(
|
||||
prevState: FormState,
|
||||
formData: FormData
|
||||
): Promise<FormState> {
|
||||
'use server';
|
||||
|
||||
const email = formData.get('email');
|
||||
const password = formData.get('password');
|
||||
|
||||
if (!email || typeof email !== 'string') {
|
||||
return {
|
||||
success: false,
|
||||
errors: { email: ['Email is required'] },
|
||||
};
|
||||
}
|
||||
|
||||
if (!password || typeof password !== 'string') {
|
||||
return {
|
||||
success: false,
|
||||
errors: { password: ['Password is required'] },
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
await signIn(email, password);
|
||||
return { success: true, message: 'Logged in successfully' };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Invalid credentials',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Client Component
|
||||
export function LoginForm() {
|
||||
const [state, formAction, isPending] = useActionState<FormState, FormData>(
|
||||
login,
|
||||
{} // Initial state
|
||||
);
|
||||
|
||||
return (
|
||||
<form action={formAction}>
|
||||
<div>
|
||||
<label htmlFor="email">Email</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
aria-invalid={!!state.errors?.email}
|
||||
/>
|
||||
{state.errors?.email?.map((error) => (
|
||||
<p key={error} className="error">{error}</p>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password">Password</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
aria-invalid={!!state.errors?.password}
|
||||
/>
|
||||
{state.errors?.password?.map((error) => (
|
||||
<p key={error} className="error">{error}</p>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{state.message && (
|
||||
<div className={state.success ? 'success' : 'error'}>
|
||||
{state.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button type="submit" disabled={isPending}>
|
||||
{isPending ? 'Logging in...' : 'Log In'}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### useActionState with Optimistic Updates
|
||||
|
||||
```typescript
|
||||
'use client';
|
||||
|
||||
import { useActionState, useOptimistic } from 'react';
|
||||
|
||||
type Todo = { id: string; title: string; completed: boolean };
|
||||
|
||||
async function toggleTodo(
|
||||
prevState: { todos: Todo[] },
|
||||
formData: FormData
|
||||
): Promise<{ todos: Todo[] }> {
|
||||
'use server';
|
||||
|
||||
const todoId = formData.get('todoId') as string;
|
||||
await db.todo.update({
|
||||
where: { id: todoId },
|
||||
data: { completed: { toggle: true } },
|
||||
});
|
||||
|
||||
const todos = await db.todo.findMany();
|
||||
return { todos };
|
||||
}
|
||||
|
||||
export function TodoList({ initialTodos }: { initialTodos: Todo[] }) {
|
||||
const [state, formAction] = useActionState(
|
||||
toggleTodo,
|
||||
{ todos: initialTodos }
|
||||
);
|
||||
|
||||
const [optimisticTodos, setOptimisticTodos] = useOptimistic(
|
||||
state.todos,
|
||||
(currentTodos, todoId: string) =>
|
||||
currentTodos.map((todo) =>
|
||||
todo.id === todoId ? { ...todo, completed: !todo.completed } : todo
|
||||
)
|
||||
);
|
||||
|
||||
return (
|
||||
<ul>
|
||||
{optimisticTodos.map((todo) => (
|
||||
<li key={todo.id}>
|
||||
<form
|
||||
action={(formData) => {
|
||||
setOptimisticTodos(todo.id);
|
||||
formAction(formData);
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="todoId" value={todo.id} />
|
||||
<button type="submit">
|
||||
{todo.completed ? '✓' : '○'} {todo.title}
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## use() Hook - Unwrapping Resources
|
||||
|
||||
use() unwraps promises and context — enables new patterns for data fetching.
|
||||
|
||||
### use() with Promises
|
||||
|
||||
```typescript
|
||||
// Server Component
|
||||
async function UserPage({ params }: { params: { id: string } }) {
|
||||
// Pass promise without awaiting
|
||||
const userPromise = fetchUser(params.id);
|
||||
|
||||
return (
|
||||
<Suspense fallback={<UserSkeleton />}>
|
||||
<UserProfile userPromise={userPromise} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
// Client Component
|
||||
'use client';
|
||||
|
||||
import { use } from 'react';
|
||||
|
||||
type UserProfileProps = {
|
||||
userPromise: Promise<User>;
|
||||
};
|
||||
|
||||
export function UserProfile({ userPromise }: UserProfileProps) {
|
||||
// Suspends until resolved
|
||||
const user = use(userPromise);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>{user.name}</h1>
|
||||
<p>{user.email}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Conditional use()
|
||||
|
||||
use() can be called conditionally — unlike hooks.
|
||||
|
||||
```typescript
|
||||
'use client';
|
||||
|
||||
import { use } from 'react';
|
||||
|
||||
type Props = {
|
||||
userPromise?: Promise<User>;
|
||||
userId?: string;
|
||||
};
|
||||
|
||||
export function UserDisplay({ userPromise, userId }: Props) {
|
||||
let user: User | undefined;
|
||||
|
||||
if (userPromise) {
|
||||
user = use(userPromise); // Conditional use() - allowed!
|
||||
} else if (userId) {
|
||||
// Fetch inline
|
||||
user = use(fetchUser(userId));
|
||||
}
|
||||
|
||||
if (!user) return <div>No user data</div>;
|
||||
|
||||
return <div>{user.name}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
### use() in Loops
|
||||
|
||||
```typescript
|
||||
'use client';
|
||||
|
||||
import { use } from 'react';
|
||||
|
||||
type Props = {
|
||||
userPromises: Promise<User>[];
|
||||
};
|
||||
|
||||
export function UserList({ userPromises }: Props) {
|
||||
return (
|
||||
<ul>
|
||||
{userPromises.map((promise, index) => {
|
||||
const user = use(promise); // use() in loop - allowed!
|
||||
return <li key={user.id}>{user.name}</li>;
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### use() with Context
|
||||
|
||||
Alternative to useContext — can be called conditionally.
|
||||
|
||||
```typescript
|
||||
import { createContext, use } from 'react';
|
||||
|
||||
type Theme = 'light' | 'dark';
|
||||
const ThemeContext = createContext<Theme>('light');
|
||||
|
||||
export function ThemedComponent({ override }: { override?: Theme }) {
|
||||
let theme: Theme;
|
||||
|
||||
if (override) {
|
||||
theme = override;
|
||||
} else {
|
||||
theme = use(ThemeContext); // Conditional context access
|
||||
}
|
||||
|
||||
return <div className={theme}>Content</div>;
|
||||
}
|
||||
```
|
||||
|
||||
## useOptimistic - Optimistic UI Updates
|
||||
|
||||
Show immediate UI feedback before server confirms.
|
||||
|
||||
```typescript
|
||||
'use client';
|
||||
|
||||
import { useOptimistic } from 'react';
|
||||
|
||||
type Message = { id: string; text: string; sending?: boolean };
|
||||
|
||||
export function MessageThread({ messages }: { messages: Message[] }) {
|
||||
const [optimisticMessages, addOptimisticMessage] = useOptimistic(
|
||||
messages,
|
||||
(state, newMessage: Message) => [...state, newMessage]
|
||||
);
|
||||
|
||||
async function sendMessage(formData: FormData) {
|
||||
const text = formData.get('message') as string;
|
||||
|
||||
// Add optimistic message immediately
|
||||
addOptimisticMessage({ id: 'temp', text, sending: true });
|
||||
|
||||
// Send to server
|
||||
await fetch('/api/messages', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ text }),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ul>
|
||||
{optimisticMessages.map((msg) => (
|
||||
<li key={msg.id} className={msg.sending ? 'opacity-50' : ''}>
|
||||
{msg.text}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<form action={sendMessage}>
|
||||
<input name="message" required />
|
||||
<button type="submit">Send</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### useOptimistic with State Updates
|
||||
|
||||
```typescript
|
||||
'use client';
|
||||
|
||||
import { useOptimistic, useState, useTransition } from 'react';
|
||||
|
||||
type Item = { id: string; name: string; quantity: number };
|
||||
|
||||
export function ShoppingCart({ items: initialItems }: { items: Item[] }) {
|
||||
const [items, setItems] = useState(initialItems);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const [optimisticItems, updateOptimistic] = useOptimistic(
|
||||
items,
|
||||
(state, { id, quantity }: { id: string; quantity: number }) =>
|
||||
state.map((item) =>
|
||||
item.id === id ? { ...item, quantity } : item
|
||||
)
|
||||
);
|
||||
|
||||
async function updateQuantity(id: string, quantity: number) {
|
||||
// Optimistic update
|
||||
updateOptimistic({ id, quantity });
|
||||
|
||||
// Server update
|
||||
startTransition(async () => {
|
||||
const updated = await fetch(`/api/cart/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ quantity }),
|
||||
}).then((r) => r.json());
|
||||
|
||||
setItems(updated);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<ul>
|
||||
{optimisticItems.map((item) => (
|
||||
<li key={item.id}>
|
||||
{item.name}
|
||||
<button onClick={() => updateQuantity(item.id, item.quantity - 1)}>
|
||||
-
|
||||
</button>
|
||||
<span>{item.quantity}</span>
|
||||
<button onClick={() => updateQuantity(item.id, item.quantity + 1)}>
|
||||
+
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## useTransition - Non-Blocking Updates
|
||||
|
||||
Mark state updates as non-urgent — UI stays responsive.
|
||||
|
||||
```typescript
|
||||
'use client';
|
||||
|
||||
import { useTransition, useState } from 'react';
|
||||
|
||||
export function SearchResults() {
|
||||
const [query, setQuery] = useState('');
|
||||
const [results, setResults] = useState<string[]>([]);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
function handleSearch(value: string) {
|
||||
setQuery(value); // Urgent - update input immediately
|
||||
|
||||
startTransition(() => {
|
||||
// Non-urgent - can be interrupted
|
||||
const filtered = hugeDataset.filter((item) =>
|
||||
item.toLowerCase().includes(value.toLowerCase())
|
||||
);
|
||||
setResults(filtered);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
placeholder="Search..."
|
||||
/>
|
||||
|
||||
{isPending && <div>Searching...</div>}
|
||||
|
||||
<ul>
|
||||
{results.map((result) => (
|
||||
<li key={result}>{result}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### useTransition with Server Actions
|
||||
|
||||
```typescript
|
||||
'use client';
|
||||
|
||||
import { useTransition } from 'react';
|
||||
import { deletePost } from '@/actions/posts';
|
||||
|
||||
export function DeleteButton({ postId }: { postId: string }) {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
function handleDelete() {
|
||||
startTransition(async () => {
|
||||
await deletePost(postId);
|
||||
// UI stays responsive during deletion
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<button onClick={handleDelete} disabled={isPending}>
|
||||
{isPending ? 'Deleting...' : 'Delete'}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## useDeferredValue - Deferred Rendering
|
||||
|
||||
Defer expensive re-renders while keeping UI responsive.
|
||||
|
||||
```typescript
|
||||
'use client';
|
||||
|
||||
import { useDeferredValue, useState } from 'react';
|
||||
|
||||
export function ProductSearch() {
|
||||
const [query, setQuery] = useState('');
|
||||
const deferredQuery = useDeferredValue(query);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search products..."
|
||||
/>
|
||||
|
||||
{/* Uses deferred value - won't block input */}
|
||||
<ExpensiveResults query={deferredQuery} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ExpensiveResults({ query }: { query: string }) {
|
||||
const results = useMemo(() => {
|
||||
// Expensive filtering/sorting
|
||||
return products.filter((p) => p.name.includes(query));
|
||||
}, [query]);
|
||||
|
||||
return (
|
||||
<ul>
|
||||
{results.map((result) => (
|
||||
<li key={result.id}>{result.name}</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Migration Checklist
|
||||
|
||||
Updating from React 18 to React 19:
|
||||
|
||||
- [ ] Replace forwardRef with ref as prop
|
||||
- [ ] Replace useFormState with useActionState
|
||||
- [ ] Update Server Action types to include prevState parameter
|
||||
- [ ] Use use() for promises in Server Components
|
||||
- [ ] Add 'use server' directive to Server Actions
|
||||
- [ ] Add 'use client' directive to Client Components
|
||||
- [ ] Update TypeScript to 5.0+ for better React 19 support
|
||||
- [ ] Update @types/react to 19.x
|
||||
- [ ] Test all forms with useActionState
|
||||
- [ ] Verify ref forwarding works without forwardRef
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,587 @@
|
||||
# TanStack Router TypeScript Patterns
|
||||
|
||||
TanStack Router provides full type safety for routes, params, search params, and loader data.
|
||||
|
||||
## Basic Route Definition
|
||||
|
||||
```typescript
|
||||
import { createRoute, createRootRoute } from '@tanstack/react-router';
|
||||
|
||||
// Root route
|
||||
const rootRoute = createRootRoute({
|
||||
component: RootLayout,
|
||||
});
|
||||
|
||||
// Basic route
|
||||
const indexRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/',
|
||||
component: HomePage,
|
||||
});
|
||||
|
||||
// Route tree
|
||||
const routeTree = rootRoute.addChildren([indexRoute]);
|
||||
|
||||
// Router
|
||||
const router = createRouter({ routeTree });
|
||||
|
||||
// Type for router - use in app
|
||||
type Router = typeof router;
|
||||
```
|
||||
|
||||
## Route with Params
|
||||
|
||||
```typescript
|
||||
import { createRoute } from '@tanstack/react-router';
|
||||
import { useParams } from '@tanstack/react-router';
|
||||
|
||||
const userRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/users/$userId',
|
||||
component: UserPage,
|
||||
});
|
||||
|
||||
function UserPage() {
|
||||
const { userId } = useParams({ from: userRoute.id });
|
||||
// userId is typed as string
|
||||
|
||||
return <div>User ID: {userId}</div>;
|
||||
}
|
||||
|
||||
// Multiple params
|
||||
const postRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/users/$userId/posts/$postId',
|
||||
component: PostPage,
|
||||
});
|
||||
|
||||
function PostPage() {
|
||||
const { userId, postId } = useParams({ from: postRoute.id });
|
||||
// Both typed as string
|
||||
|
||||
return <div>Post {postId} by user {userId}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
## Search Params with Validation
|
||||
|
||||
```typescript
|
||||
import { z } from 'zod';
|
||||
|
||||
const productsRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/products',
|
||||
component: ProductsPage,
|
||||
validateSearch: z.object({
|
||||
category: z.string().optional(),
|
||||
sortBy: z.enum(['price', 'name', 'rating']).default('name'),
|
||||
page: z.number().int().positive().default(1),
|
||||
perPage: z.number().int().positive().default(20),
|
||||
}),
|
||||
});
|
||||
|
||||
function ProductsPage() {
|
||||
const search = useSearch({ from: productsRoute.id });
|
||||
// search is typed from Zod schema:
|
||||
// {
|
||||
// category?: string;
|
||||
// sortBy: 'price' | 'name' | 'rating';
|
||||
// page: number;
|
||||
// perPage: number;
|
||||
// }
|
||||
|
||||
return (
|
||||
<div>
|
||||
Category: {search.category || 'All'}
|
||||
Sort by: {search.sortBy}
|
||||
Page: {search.page}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Loader Data
|
||||
|
||||
```typescript
|
||||
type User = {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
};
|
||||
|
||||
const userRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/users/$userId',
|
||||
component: UserPage,
|
||||
loader: async ({ params }) => {
|
||||
const user = await fetchUser(params.userId);
|
||||
return { user };
|
||||
},
|
||||
});
|
||||
|
||||
function UserPage() {
|
||||
const { user } = useLoaderData({ from: userRoute.id });
|
||||
// user typed as User
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>{user.name}</h1>
|
||||
<p>{user.email}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function fetchUser(id: string): Promise<User> {
|
||||
const res = await fetch(`/api/users/${id}`);
|
||||
return res.json();
|
||||
}
|
||||
```
|
||||
|
||||
## Loader with Search Params
|
||||
|
||||
```typescript
|
||||
const productsRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/products',
|
||||
component: ProductsPage,
|
||||
validateSearch: z.object({
|
||||
category: z.string().optional(),
|
||||
page: z.number().default(1),
|
||||
}),
|
||||
loader: async ({ search }) => {
|
||||
// search is typed from validateSearch
|
||||
const products = await fetchProducts({
|
||||
category: search.category,
|
||||
page: search.page,
|
||||
});
|
||||
|
||||
return { products };
|
||||
},
|
||||
});
|
||||
|
||||
function ProductsPage() {
|
||||
const { products } = useLoaderData({ from: productsRoute.id });
|
||||
const search = useSearch({ from: productsRoute.id });
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Products - {search.category || 'All'}</h1>
|
||||
<ul>
|
||||
{products.map((p) => (
|
||||
<li key={p.id}>{p.name}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Type-Safe Navigation
|
||||
|
||||
```typescript
|
||||
import { useNavigate, Link } from '@tanstack/react-router';
|
||||
|
||||
function ProductList() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const goToProduct = (productId: string) => {
|
||||
navigate({
|
||||
to: '/products/$productId',
|
||||
params: { productId }, // Type-checked
|
||||
search: { tab: 'reviews' }, // Type-checked against validateSearch
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{products.map((product) => (
|
||||
<Link
|
||||
key={product.id}
|
||||
to="/products/$productId"
|
||||
params={{ productId: product.id }}
|
||||
search={{ tab: 'details' }}
|
||||
>
|
||||
{product.name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Search Params Manipulation
|
||||
|
||||
```typescript
|
||||
import { useNavigate, useSearch } from '@tanstack/react-router';
|
||||
|
||||
const productsRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/products',
|
||||
validateSearch: z.object({
|
||||
category: z.string().optional(),
|
||||
sortBy: z.enum(['price', 'name']).default('name'),
|
||||
page: z.number().default(1),
|
||||
}),
|
||||
});
|
||||
|
||||
function ProductFilters() {
|
||||
const navigate = useNavigate({ from: productsRoute.id });
|
||||
const search = useSearch({ from: productsRoute.id });
|
||||
|
||||
const updateCategory = (category: string) => {
|
||||
navigate({
|
||||
search: (prev) => ({
|
||||
...prev,
|
||||
category, // Type-checked
|
||||
page: 1, // Reset page
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
const updateSort = (sortBy: 'price' | 'name') => {
|
||||
navigate({
|
||||
search: (prev) => ({ ...prev, sortBy }),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<select value={search.category || ''} onChange={(e) => updateCategory(e.target.value)}>
|
||||
<option value="">All Categories</option>
|
||||
<option value="electronics">Electronics</option>
|
||||
<option value="books">Books</option>
|
||||
</select>
|
||||
|
||||
<select value={search.sortBy} onChange={(e) => updateSort(e.target.value as 'price' | 'name')}>
|
||||
<option value="name">Name</option>
|
||||
<option value="price">Price</option>
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Nested Routes
|
||||
|
||||
```typescript
|
||||
const usersRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/users',
|
||||
component: UsersLayout,
|
||||
});
|
||||
|
||||
const usersIndexRoute = createRoute({
|
||||
getParentRoute: () => usersRoute,
|
||||
path: '/',
|
||||
component: UsersList,
|
||||
});
|
||||
|
||||
const userDetailRoute = createRoute({
|
||||
getParentRoute: () => usersRoute,
|
||||
path: '$userId',
|
||||
component: UserDetail,
|
||||
});
|
||||
|
||||
const routeTree = rootRoute.addChildren([
|
||||
usersRoute.addChildren([
|
||||
usersIndexRoute,
|
||||
userDetailRoute,
|
||||
]),
|
||||
]);
|
||||
|
||||
// Layout component with outlet
|
||||
function UsersLayout() {
|
||||
return (
|
||||
<div>
|
||||
<h1>Users</h1>
|
||||
<Outlet /> {/* Renders child route */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Before Load Hook
|
||||
|
||||
```typescript
|
||||
const protectedRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/dashboard',
|
||||
beforeLoad: async ({ location }) => {
|
||||
const isAuthenticated = await checkAuth();
|
||||
|
||||
if (!isAuthenticated) {
|
||||
throw redirect({
|
||||
to: '/login',
|
||||
search: { redirect: location.href },
|
||||
});
|
||||
}
|
||||
|
||||
return { user: await getCurrentUser() };
|
||||
},
|
||||
component: Dashboard,
|
||||
});
|
||||
|
||||
function Dashboard() {
|
||||
const { user } = useLoaderData({ from: protectedRoute.id });
|
||||
// user available from beforeLoad
|
||||
|
||||
return <div>Welcome, {user.name}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
```typescript
|
||||
const userRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/users/$userId',
|
||||
component: UserPage,
|
||||
errorComponent: UserErrorPage,
|
||||
loader: async ({ params }) => {
|
||||
const user = await fetchUser(params.userId);
|
||||
if (!user) {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
return { user };
|
||||
},
|
||||
});
|
||||
|
||||
function UserErrorPage({ error }: { error: Error }) {
|
||||
return (
|
||||
<div>
|
||||
<h1>Error</h1>
|
||||
<p>{error.message}</p>
|
||||
<Link to="/users">Back to users</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Pending Component
|
||||
|
||||
```typescript
|
||||
const userRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/users/$userId',
|
||||
component: UserPage,
|
||||
pendingComponent: UserPageSkeleton,
|
||||
loader: async ({ params }) => {
|
||||
const user = await fetchUser(params.userId);
|
||||
return { user };
|
||||
},
|
||||
});
|
||||
|
||||
function UserPageSkeleton() {
|
||||
return (
|
||||
<div>
|
||||
<div className="skeleton-header" />
|
||||
<div className="skeleton-body" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Router Context
|
||||
|
||||
Share data across all routes.
|
||||
|
||||
```typescript
|
||||
type RouterContext = {
|
||||
auth: { user: User | null };
|
||||
queryClient: QueryClient;
|
||||
};
|
||||
|
||||
const rootRoute = createRootRoute<RouterContext>({
|
||||
component: RootLayout,
|
||||
});
|
||||
|
||||
const router = createRouter({
|
||||
routeTree,
|
||||
context: {
|
||||
auth: { user: null },
|
||||
queryClient: new QueryClient(),
|
||||
},
|
||||
});
|
||||
|
||||
// Access in route
|
||||
const userRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/users/$userId',
|
||||
beforeLoad: ({ context }) => {
|
||||
// context typed as RouterContext
|
||||
console.log(context.auth.user);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Route Masks
|
||||
|
||||
Hide actual URL structure.
|
||||
|
||||
```typescript
|
||||
const userRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/users/$userId',
|
||||
});
|
||||
|
||||
// Navigate with mask
|
||||
navigate({
|
||||
to: '/users/$userId',
|
||||
params: { userId: '123' },
|
||||
mask: {
|
||||
to: '/profile',
|
||||
},
|
||||
});
|
||||
|
||||
// URL shows /profile but renders /users/123
|
||||
```
|
||||
|
||||
## Search Param Middleware
|
||||
|
||||
Transform search params before validation.
|
||||
|
||||
```typescript
|
||||
const productsRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/products',
|
||||
validateSearch: z.object({
|
||||
tags: z.array(z.string()).default([]),
|
||||
}),
|
||||
loaderDeps: ({ search }) => ({ tags: search.tags }),
|
||||
loader: async ({ deps }) => {
|
||||
const products = await fetchProducts({ tags: deps.tags });
|
||||
return { products };
|
||||
},
|
||||
});
|
||||
|
||||
// URL: /products?tags=electronics&tags=sale
|
||||
// search.tags = ['electronics', 'sale']
|
||||
```
|
||||
|
||||
## Type Helpers
|
||||
|
||||
```typescript
|
||||
import type { RouteIds, RouteById } from '@tanstack/react-router';
|
||||
|
||||
// Get all route IDs
|
||||
type AllRouteIds = RouteIds<typeof router>;
|
||||
|
||||
// Get specific route type
|
||||
type UserRoute = RouteById<typeof router, '/users/$userId'>;
|
||||
|
||||
// Extract params type
|
||||
type UserParams = UserRoute['types']['allParams'];
|
||||
|
||||
// Extract search type
|
||||
type UserSearch = UserRoute['types']['fullSearchSchema'];
|
||||
```
|
||||
|
||||
## Preloading Routes
|
||||
|
||||
```typescript
|
||||
import { useRouter } from '@tanstack/react-router';
|
||||
|
||||
function ProductCard({ productId }: { productId: string }) {
|
||||
const router = useRouter();
|
||||
|
||||
const preloadProduct = () => {
|
||||
router.preloadRoute({
|
||||
to: '/products/$productId',
|
||||
params: { productId },
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Link
|
||||
to="/products/$productId"
|
||||
params={{ productId }}
|
||||
onMouseEnter={preloadProduct} // Preload on hover
|
||||
>
|
||||
View Product
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Route Matching
|
||||
|
||||
```typescript
|
||||
import { useMatches, useMatch } from '@tanstack/react-router';
|
||||
|
||||
function Navigation() {
|
||||
const matches = useMatches();
|
||||
// Array of all matched routes
|
||||
|
||||
const userMatch = useMatch({ from: userRoute.id, shouldThrow: false });
|
||||
// userMatch is typed, null if not matched
|
||||
|
||||
return (
|
||||
<nav>
|
||||
{matches.map((match) => (
|
||||
<span key={match.id}>{match.pathname}</span>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## File-Based Routing (Code Generation)
|
||||
|
||||
```typescript
|
||||
// routes/__root.tsx
|
||||
export const Route = createRootRoute({
|
||||
component: RootLayout,
|
||||
});
|
||||
|
||||
// routes/index.tsx
|
||||
export const Route = createFileRoute('/')({
|
||||
component: HomePage,
|
||||
});
|
||||
|
||||
// routes/users/$userId.tsx
|
||||
export const Route = createFileRoute('/users/$userId')({
|
||||
component: UserPage,
|
||||
loader: async ({ params }) => {
|
||||
const user = await fetchUser(params.userId);
|
||||
return { user };
|
||||
},
|
||||
});
|
||||
|
||||
// Generate route tree with CLI
|
||||
// npm run generate-routes
|
||||
|
||||
// Import generated routes
|
||||
import { routeTree } from './routeTree.gen';
|
||||
const router = createRouter({ routeTree });
|
||||
```
|
||||
|
||||
## Integration with React Query
|
||||
|
||||
```typescript
|
||||
import { queryOptions, useQuery } from '@tanstack/react-query';
|
||||
|
||||
const userQueryOptions = (userId: string) =>
|
||||
queryOptions({
|
||||
queryKey: ['user', userId],
|
||||
queryFn: () => fetchUser(userId),
|
||||
});
|
||||
|
||||
const userRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/users/$userId',
|
||||
loader: ({ context, params }) => {
|
||||
// Prefetch with React Query
|
||||
return context.queryClient.ensureQueryData(userQueryOptions(params.userId));
|
||||
},
|
||||
component: UserPage,
|
||||
});
|
||||
|
||||
function UserPage() {
|
||||
const { userId } = useParams({ from: userRoute.id });
|
||||
|
||||
// Use same query options in component
|
||||
const { data: user } = useQuery(userQueryOptions(userId));
|
||||
|
||||
return <div>{user?.name}</div>;
|
||||
}
|
||||
```
|
||||
Reference in New Issue
Block a user