Loading...
Loading...
Guide complet des bonnes pratiques React et Next.js couvrant l'optimisation des performances, l'architecture des composants, les patrons shadcn/ui, les animations Motion et les patrons modernes React 19+. À utiliser lors de l'écriture, la revue ou le refactoring de code React/Next.js. Se déclenche sur les tâches impliquant des composants React, des pages Next.js, du data fetching, des composants UI, des animations ou de l'amélioration de la qualité du code.
npx skill4agent add dedalus-erp-pas/hexagone-foundation-skills react-best-practices| Priority | Category | Impact | Prefix |
|---|---|---|---|
| 1 | Component Architecture | CRITICAL | |
| 2 | Eliminating Waterfalls | CRITICAL | |
| 3 | Bundle Size Optimization | CRITICAL | |
| 4 | Server Components & Actions | HIGH | |
| 5 | shadcn/ui Patterns | HIGH | |
| 6 | State Management | MEDIUM-HIGH | |
| 7 | Motion & Animations | MEDIUM | |
| 8 | Re-render Optimization | MEDIUM | |
| 9 | Accessibility | MEDIUM | |
| 10 | TypeScript Patterns | MEDIUM | |
arch-functional-componentsarch-composition-over-inheritancearch-single-responsibilityarch-presentational-containerarch-colocationarch-avoid-prop-drilling// Correct: Functional component with hooks
function UserProfile({ userId }: { userId: string }) {
const { data: user } = useUser(userId)
return <div>{user?.name}</div>
}
// Incorrect: Class component
class UserProfile extends React.Component { /* ... */ }// Correct: Compose smaller components
function Card({ children }: { children: React.ReactNode }) {
return <div className="rounded-lg border p-4">{children}</div>
}
function CardHeader({ children }: { children: React.ReactNode }) {
return <div className="font-semibold">{children}</div>
}
// Usage
<Card>
<CardHeader>Title</CardHeader>
<p>Content</p>
</Card>// Incorrect: Passing props through many levels
<App user={user}>
<Layout user={user}>
<Sidebar user={user}>
<UserMenu user={user} />
</Sidebar>
</Layout>
</App>
// Correct: Use Context for shared state
const UserContext = createContext<User | null>(null)
function App() {
const user = useCurrentUser()
return (
<UserContext.Provider value={user}>
<Layout>
<Sidebar>
<UserMenu />
</Sidebar>
</Layout>
</UserContext.Provider>
)
}async-defer-awaitasync-parallelasync-dependenciesasync-api-routesasync-suspense-boundaries// Incorrect: Sequential waterfalls
async function Page() {
const user = await fetchUser()
const posts = await fetchPosts()
const comments = await fetchComments()
return <div>{/* render */}</div>
}
// Correct: Parallel fetching
async function Page() {
const [user, posts, comments] = await Promise.all([
fetchUser(),
fetchPosts(),
fetchComments()
])
return <div>{/* render */}</div>
}// Stream content as it becomes available
function Page() {
return (
<div>
<Header />
<Suspense fallback={<PostsSkeleton />}>
<Posts />
</Suspense>
<Suspense fallback={<CommentsSkeleton />}>
<Comments />
</Suspense>
</div>
)
}bundle-barrel-importsbundle-dynamic-importsbundle-defer-third-partybundle-conditionalbundle-preload// Incorrect: Imports entire library
import { Button } from '@/components'
import { formatDate } from '@/utils'
// Correct: Direct imports enable tree-shaking
import { Button } from '@/components/ui/button'
import { formatDate } from '@/utils/date'import dynamic from 'next/dynamic'
// Load only when needed
const HeavyChart = dynamic(() => import('./HeavyChart'), {
loading: () => <ChartSkeleton />,
ssr: false
})
function Dashboard({ showChart }) {
return showChart ? <HeavyChart /> : null
}server-default-serverserver-use-client-boundaryserver-actionsserver-cache-reactserver-serialization// Server Component (default) - can be async
async function ProductPage({ id }: { id: string }) {
const product = await db.product.findUnique({ where: { id } })
return <ProductDetails product={product} />
}
// Client Component - only when needed for interactivity
'use client'
function AddToCartButton({ productId }: { productId: string }) {
const [isPending, startTransition] = useTransition()
return (
<Button
onClick={() => startTransition(() => addToCart(productId))}
disabled={isPending}
>
Add to Cart
</Button>
)
}// actions.ts
'use server'
export async function createPost(formData: FormData) {
const title = formData.get('title') as string
const content = formData.get('content') as string
await db.post.create({ data: { title, content } })
revalidatePath('/posts')
}
// Component usage
function CreatePostForm() {
return (
<form action={createPost}>
<Input name="title" placeholder="Title" />
<Textarea name="content" placeholder="Content" />
<Button type="submit">Create Post</Button>
</form>
)
}shadcn-compositionshadcn-variantsshadcn-theme-integrationshadcn-accessibilityshadcn-customization# Add components as needed
npx shadcn@latest add button
npx shadcn@latest add card
npx shadcn@latest add dialog
npx shadcn@latest add form// Correct: Build on existing primitives
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
interface ProductCardProps {
product: Product
onSelect?: () => void
}
function ProductCard({ product, onSelect }: ProductCardProps) {
return (
<Card
className="cursor-pointer hover:shadow-md transition-shadow"
onClick={onSelect}
>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>{product.name}</CardTitle>
{product.isNew && <Badge>New</Badge>}
</div>
</CardHeader>
<CardContent>
<p className="text-muted-foreground">{product.description}</p>
<p className="text-lg font-bold mt-2">${product.price}</p>
</CardContent>
</Card>
)
}import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const statusBadgeVariants = cva(
'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold',
{
variants: {
status: {
pending: 'bg-yellow-100 text-yellow-800',
active: 'bg-green-100 text-green-800',
inactive: 'bg-gray-100 text-gray-800',
error: 'bg-red-100 text-red-800',
},
},
defaultVariants: {
status: 'pending',
},
}
)
interface StatusBadgeProps
extends React.HTMLAttributes<HTMLSpanElement>,
VariantProps<typeof statusBadgeVariants> {
label: string
}
function StatusBadge({ status, label, className, ...props }: StatusBadgeProps) {
return (
<span className={cn(statusBadgeVariants({ status }), className)} {...props}>
{label}
</span>
)
}'use client'
import { zodResolver } from '@hookform/resolvers/zod'
import { useForm } from 'react-hook-form'
import * as z from 'zod'
import { Button } from '@/components/ui/button'
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
const formSchema = z.object({
email: z.string().email('Invalid email address'),
password: z.string().min(8, 'Password must be at least 8 characters'),
})
function LoginForm() {
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
email: '',
password: '',
},
})
function onSubmit(values: z.infer<typeof formSchema>) {
console.log(values)
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="you@example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input type="password" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" className="w-full">Sign In</Button>
</form>
</Form>
)
}import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
function ConfirmDialog({
onConfirm,
title,
description
}: {
onConfirm: () => void
title: string
description: string
}) {
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="destructive">Delete</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline">Cancel</Button>
<Button variant="destructive" onClick={onConfirm}>
Confirm
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import {
ColumnDef,
flexRender,
getCoreRowModel,
useReactTable,
} from '@tanstack/react-table'
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[]
data: TData[]
}
function DataTable<TData, TValue>({
columns,
data,
}: DataTableProps<TData, TValue>) {
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
})
return (
<div className="rounded-md border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</div>
)
}state-local-firststate-context-staticstate-derived-computestate-url-statestate-server-state// Incorrect: Storing derived state
function ProductList({ products }) {
const [items, setItems] = useState(products)
const [count, setCount] = useState(products.length) // Derived!
// Bug: count can get out of sync with items
}
// Correct: Compute derived values
function ProductList({ products }) {
const [items, setItems] = useState(products)
const count = items.length // Always in sync
}'use client'
import { useSearchParams, useRouter } from 'next/navigation'
function ProductFilters() {
const searchParams = useSearchParams()
const router = useRouter()
const category = searchParams.get('category') || 'all'
function setCategory(newCategory: string) {
const params = new URLSearchParams(searchParams)
params.set('category', newCategory)
router.push(`?${params.toString()}`)
}
return (
<Select value={category} onValueChange={setCategory}>
{/* options */}
</Select>
)
}motion-purposefulmotion-performancemotion-reduced-motionmotion-layout-idmotion-exit-animationsmotion-variantsnpm install motion'use client'
import { motion } from 'motion/react'
function FadeInCard({ children }: { children: React.ReactNode }) {
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
className="rounded-lg border p-4"
>
{children}
</motion.div>
)
}function InteractiveButton({ children }: { children: React.ReactNode }) {
return (
<motion.button
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
transition={{ type: 'spring', stiffness: 400, damping: 17 }}
className="px-4 py-2 bg-primary text-primary-foreground rounded-md"
>
{children}
</motion.button>
)
}import { motion, AnimatePresence } from 'motion/react'
function NotificationList({ notifications }: { notifications: Notification[] }) {
return (
<AnimatePresence>
{notifications.map((notification) => (
<motion.div
key={notification.id}
initial={{ opacity: 0, x: 50 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -50 }}
transition={{ duration: 0.2 }}
>
<Notification data={notification} />
</motion.div>
))}
</AnimatePresence>
)
}function ProductGrid({ products }: { products: Product[] }) {
const [selected, setSelected] = useState<Product | null>(null)
return (
<>
<div className="grid grid-cols-3 gap-4">
{products.map((product) => (
<motion.div
key={product.id}
layoutId={`product-${product.id}`}
onClick={() => setSelected(product)}
className="cursor-pointer"
>
<img src={product.image} alt={product.name} />
</motion.div>
))}
</div>
<AnimatePresence>
{selected && (
<motion.div
layoutId={`product-${selected.id}`}
className="fixed inset-0 flex items-center justify-center"
>
<ProductDetail product={selected} onClose={() => setSelected(null)} />
</motion.div>
)}
</AnimatePresence>
</>
)
}const fadeInUp = {
initial: { opacity: 0, y: 20 },
animate: { opacity: 1, y: 0 },
exit: { opacity: 0, y: -20 },
}
const staggerContainer = {
animate: {
transition: {
staggerChildren: 0.1,
},
},
}
function StaggeredList({ items }: { items: string[] }) {
return (
<motion.ul variants={staggerContainer} initial="initial" animate="animate">
{items.map((item, i) => (
<motion.li key={i} variants={fadeInUp}>
{item}
</motion.li>
))}
</motion.ul>
)
}import { motion, useInView } from 'motion/react'
import { useRef } from 'react'
function ScrollReveal({ children }: { children: React.ReactNode }) {
const ref = useRef(null)
const isInView = useInView(ref, { once: true, margin: '-100px' })
return (
<motion.div
ref={ref}
initial={{ opacity: 0, y: 50 }}
animate={isInView ? { opacity: 1, y: 0 } : { opacity: 0, y: 50 }}
transition={{ duration: 0.5 }}
>
{children}
</motion.div>
)
}import { motion, useReducedMotion } from 'motion/react'
function AccessibleAnimation({ children }: { children: React.ReactNode }) {
const shouldReduceMotion = useReducedMotion()
return (
<motion.div
initial={{ opacity: 0, y: shouldReduceMotion ? 0 : 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: shouldReduceMotion ? 0 : 0.3 }}
>
{children}
</motion.div>
)
}transformopacitywillChangeuseInViewwidthheighttopleftrerender-memorerender-usememorerender-usecallbackrerender-dependenciesrerender-transitionsimport { memo } from 'react'
const ExpensiveList = memo(function ExpensiveList({
items
}: {
items: Item[]
}) {
return (
<ul>
{items.map(item => (
<li key={item.id}>{/* expensive render */}</li>
))}
</ul>
)
})function Parent() {
const [count, setCount] = useState(0)
// Stable reference - won't cause child re-renders
const handleClick = useCallback(() => {
setCount(c => c + 1)
}, [])
return <MemoizedChild onClick={handleClick} />
}function SearchResults() {
const [query, setQuery] = useState('')
const [results, setResults] = useState([])
const [isPending, startTransition] = useTransition()
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
setQuery(e.target.value) // Urgent: update input immediately
startTransition(() => {
setResults(filterResults(e.target.value)) // Non-urgent: can be interrupted
})
}
return (
<>
<Input value={query} onChange={handleChange} />
{isPending && <Spinner />}
<ResultsList results={results} />
</>
)
}a11y-semantic-htmla11y-keyboard-nava11y-aria-labelsa11y-focus-managementa11y-color-contrast// Incorrect: div soup
<div onClick={handleClick}>Click me</div>
// Correct: semantic button
<button onClick={handleClick}>Click me</button>function Modal({ isOpen, onClose, children }) {
const closeButtonRef = useRef<HTMLButtonElement>(null)
useEffect(() => {
if (isOpen) {
closeButtonRef.current?.focus()
}
}, [isOpen])
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent>
{children}
<Button ref={closeButtonRef} onClick={onClose}>
Close
</Button>
</DialogContent>
</Dialog>
)
}function Layout({ children }) {
return (
<>
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4"
>
Skip to main content
</a>
<Header />
<main id="main-content">{children}</main>
</>
)
}ts-strict-modets-component-propsts-genericsts-discriminated-unionsts-infer-when-possibleinterface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'default' | 'destructive' | 'outline'
size?: 'sm' | 'md' | 'lg'
isLoading?: boolean
}
function Button({
variant = 'default',
size = 'md',
isLoading,
children,
disabled,
...props
}: ButtonProps) {
return (
<button
disabled={disabled || isLoading}
{...props}
>
{isLoading ? <Spinner /> : children}
</button>
)
}type AsyncState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: Error }
function useAsync<T>(asyncFn: () => Promise<T>) {
const [state, setState] = useState<AsyncState<T>>({ status: 'idle' })
// TypeScript knows exact shape based on status
if (state.status === 'success') {
return state.data // TypeScript knows data exists
}
}interface SelectProps<T> {
options: T[]
value: T
onChange: (value: T) => void
getLabel: (option: T) => string
getValue: (option: T) => string
}
function Select<T>({ options, value, onChange, getLabel, getValue }: SelectProps<T>) {
return (
<select
value={getValue(value)}
onChange={(e) => {
const selected = options.find(o => getValue(o) === e.target.value)
if (selected) onChange(selected)
}}
>
{options.map((option) => (
<option key={getValue(option)} value={getValue(option)}>
{getLabel(option)}
</option>
))}
</select>
)
}'use client'
import { useActionState } from 'react'
function SubscribeForm() {
const [state, formAction, isPending] = useActionState(
async (prevState, formData) => {
const email = formData.get('email')
const result = await subscribe(email)
return result
},
null
)
return (
<form action={formAction}>
<Input name="email" type="email" />
<Button type="submit" disabled={isPending}>
{isPending ? 'Subscribing...' : 'Subscribe'}
</Button>
{state?.error && <p className="text-red-500">{state.error}</p>}
</form>
)
}'use client'
import { useOptimistic } from 'react'
function TodoList({ todos }: { todos: Todo[] }) {
const [optimisticTodos, addOptimisticTodo] = useOptimistic(
todos,
(state, newTodo: Todo) => [...state, newTodo]
)
async function addTodo(formData: FormData) {
const title = formData.get('title') as string
const newTodo = { id: crypto.randomUUID(), title, completed: false }
addOptimisticTodo(newTodo) // Immediately show in UI
await createTodo(title) // Then persist to server
}
return (
<>
<form action={addTodo}>
<Input name="title" />
<Button type="submit">Add</Button>
</form>
<ul>
{optimisticTodos.map(todo => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
</>
)
}import { use, Suspense } from 'react'
async function fetchUser(id: string): Promise<User> {
const res = await fetch(`/api/users/${id}`)
return res.json()
}
function UserProfile({ userPromise }: { userPromise: Promise<User> }) {
const user = use(userPromise) // Suspends until resolved
return <div>{user.name}</div>
}
function Page({ userId }: { userId: string }) {
const userPromise = fetchUser(userId)
return (
<Suspense fallback={<UserSkeleton />}>
<UserProfile userPromise={userPromise} />
</Suspense>
)
}src/
├── app/ # Next.js App Router
│ ├── layout.tsx
│ ├── page.tsx
│ └── (routes)/
├── components/
│ ├── ui/ # shadcn/ui components
│ │ ├── button.tsx
│ │ ├── card.tsx
│ │ └── ...
│ └── features/ # Feature-specific components
│ ├── auth/
│ └── dashboard/
├── hooks/ # Custom hooks
├── lib/ # Utilities
│ ├── utils.ts # cn() helper, etc.
│ └── validations.ts # Zod schemas
├── actions/ # Server Actions
└── types/ # TypeScript types