chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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');
|
||||
}
|
||||
```
|
||||
Reference in New Issue
Block a user