Modèles de Développement Frontend
Modèles frontend modernes pour React, Next.js et interfaces utilisateur performantes.
Quand l'activer
- Construire des composants React (composition, props, rendu)
- Gérer l'état (useState, useReducer, Zustand, Context)
- Implémenter la récupération de données (SWR, React Query, composants serveur)
- Optimiser les performances (mémoïsation, virtualisation, fractionnement de code)
- Travailler avec des formulaires (validation, entrées contrôlées, schémas Zod)
- Gérer le routage et la navigation côté client
- Construire des modèles UI accessibles et réactifs
Modèles de Composants
Composition plutôt qu'héritage
// PASS : BON : Composition de composants
interface CardProps {
children: React.ReactNode
variant?: 'default' | 'outlined'
}
export function Card({ children, variant = 'default' }: CardProps) {
return <div className={`card card-${variant}`}>{children}</div>
}
export function CardHeader({ children }: { children: React.ReactNode }) {
return <div className="card-header">{children}</div>
}
export function CardBody({ children }: { children: React.ReactNode }) {
return <div className="card-body">{children}</div>
}
// Utilisation
<Card>
<CardHeader>Titre</CardHeader>
<CardBody>Contenu</CardBody>
</Card>
Composants Composés
interface TabsContextValue {
activeTab: string
setActiveTab: (tab: string) => void
}
const TabsContext = createContext<TabsContextValue | undefined>(undefined)
export function Tabs({ children, defaultTab }: {
children: React.ReactNode
defaultTab: string
}) {
const [activeTab, setActiveTab] = useState(defaultTab)
return (
<TabsContext.Provider value={{ activeTab, setActiveTab }}>
{children}
</TabsContext.Provider>
)
}
export function TabList({ children }: { children: React.ReactNode }) {
return <div className="tab-list">{children}</div>
}
export function Tab({ id, children }: { id: string, children: React.ReactNode }) {
const context = useContext(TabsContext)
if (!context) throw new Error('Tab doit être utilisé à l\'intérieur de Tabs')
return (
<button
className={context.activeTab === id ? 'active' : ''}
onClick={() => context.setActiveTab(id)}
>
{children}
</button>
)
}
// Utilisation
<Tabs defaultTab="apercu">
<TabList>
<Tab id="apercu">Aperçu</Tab>
<Tab id="details">Détails</Tab>
</TabList>
</Tabs>
Modèle Render Props
interface DataLoaderProps<T> {
url: string
children: (data: T | null, loading: boolean, error: Error | null) => React.ReactNode
}
export function DataLoader<T>({ url, children }: DataLoaderProps<T>) {
const [data, setData] = useState<T | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<Error | null>(null)
useEffect(() => {
fetch(url)
.then(res => res.json())
.then(setData)
.catch(setError)
.finally(() => setLoading(false))
}, [url])
return <>{children(data, loading, error)}</>
}
// Utilisation
<DataLoader<Market[]> url="/api/markets">
{(markets, loading, error) => {
if (loading) return <Spinner />
if (error) return <Error error={error} />
return <MarketList markets={markets!} />
}}
</DataLoader>
Modèles de Hooks Personnalisés
Hook de Gestion d'État
export function useToggle(initialValue = false): [boolean, () => void] {
const [value, setValue] = useState(initialValue)
const toggle = useCallback(() => {
setValue(v => !v)
}, [])
return [value, toggle]
}
// Utilisation
const [isOpen, toggleOpen] = useToggle()
Hook de Récupération de Données Asynchrone
interface UseQueryOptions<T> {
onSuccess?: (data: T) => void
onError?: (error: Error) => void
enabled?: boolean
}
export function useQuery<T>(
key: string,
fetcher: () => Promise<T>,
options?: UseQueryOptions<T>
) {
const [data, setData] = useState<T | null>(null)
const [error, setError] = useState<Error | null>(null)
const [loading, setLoading] = useState(false)
// Garder le dernier fetcher/options dans des refs pour que refetch reste
// stable référentiellement même lorsque les appelants passent des fonctions
// inline et des littéraux d'objet. Sans cela, chaque rendu crée un nouveau
// refetch, et l'effet ci-dessous se réexécute après chaque mise à jour d'état -
// une boucle de récupération infinie.
const fetcherRef = useRef(fetcher)
const optionsRef = useRef(options)
useEffect(() => {
fetcherRef.current = fetcher
optionsRef.current = options
})
const refetch = useCallback(async () => {
setLoading(true)
setError(null)
try {
const result = await fetcherRef.current()
setData(result)
optionsRef.current?.onSuccess?.(result)
} catch (err) {
const error = err as Error
setError(error)
optionsRef.current?.onError?.(error)
} finally {
setLoading(false)
}
}, [])
const enabled = options?.enabled !== false
useEffect(() => {
if (enabled) {
refetch()
}
}, [key, enabled, refetch])
return { data, error, loading, refetch }
}
// Utilisation
const { data: markets, loading, error, refetch } = useQuery(
'markets',
() => fetch('/api/markets').then(r => r.json()),
{
onSuccess: data => console.log('Récupéré', data.length, 'markets'),
onError: err => console.error('Échec :', err)
}
)
Hook Debounce
export function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value)
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value)
}, delay)
return () => clearTimeout(handler)
}, [value, delay])
return debouncedValue
}
// Utilisation
const [searchQuery, setSearchQuery] = useState('')
const debouncedQuery = useDebounce(searchQuery, 500)
useEffect(() => {
if (debouncedQuery) {
performSearch(debouncedQuery)
}
}, [debouncedQuery])
Modèles de Gestion d'État
Modèle Context + Reducer
interface State {
markets: Market[]
selectedMarket: Market | null
loading: boolean
}
type Action =
| { type: 'SET_MARKETS'; payload: Market[] }
| { type: 'SELECT_MARKET'; payload: Market }
| { type: 'SET_LOADING'; payload: boolean }
function reducer(state: State, action: Action): State {
switch (action.type) {
case 'SET_MARKETS':
return { ...state, markets: action.payload }
case 'SELECT_MARKET':
return { ...state, selectedMarket: action.payload }
case 'SET_LOADING':
return { ...state, loading: action.payload }
default:
return state
}
}
const MarketContext = createContext<{
state: State
dispatch: Dispatch<Action>
} | undefined>(undefined)
export function MarketProvider({ children }: { children: React.ReactNode }) {
const [state, dispatch] = useReducer(reducer, {
markets: [],
selectedMarket: null,
loading: false
})
return (
<MarketContext.Provider value={{ state, dispatch }}>
{children}
</MarketContext.Provider>
)
}
export function useMarkets() {
const context = useContext(MarketContext)
if (!context) throw new Error('useMarkets doit être utilisé à l\'intérieur de MarketProvider')
return context
}
Optimisation des Performances
Mémoïsation
// PASS : useMemo pour les calculs coûteux
// Copier avant de trier - Array.prototype.sort modifie sur place
const sortedMarkets = useMemo(() => {
return [...markets].sort((a, b) => b.volume - a.volume)
}, [markets])
// PASS : useCallback pour les fonctions passées aux enfants
const handleSearch = useCallback((query: string) => {
setSearchQuery(query)
}, [])
// PASS : React.memo pour les composants purs
export const MarketCard = React.memo<MarketCardProps>(({ market }) => {
return (
<div className="market-card">
<h3>{market.name}</h3>
<p>{market.description}</p>
</div>
)
})
Fractionnement de Code et Chargement Paresseux
import { lazy, Suspense } from 'react'
// PASS : Charger paresseusement les composants lourds
const HeavyChart = lazy(() => import('./HeavyChart'))
const ThreeJsBackground = lazy(() => import('./ThreeJsBackground'))
export function Dashboard() {
return (
<div>
<Suspense fallback={<ChartSkeleton />}>
<HeavyChart data={data} />
</Suspense>
<Suspense fallback={null}>
<ThreeJsBackground />
</Suspense>
</div>
)
}
Virtualisation pour Longues Listes
import { useVirtualizer } from '@tanstack/react-virtual'
export function VirtualMarketList({ markets }: { markets: Market[] }) {
const parentRef = useRef<HTMLDivElement>(null)
const virtualizer = useVirtualizer({
count: markets.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 100, // Hauteur de ligne estimée
overscan: 5 // Éléments supplémentaires à rendre
})
return (
<div ref={parentRef} style={{ height: '600px', overflow: 'auto' }}>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
position: 'relative'
}}
>
{virtualizer.getVirtualItems().map(virtualRow => (
<div
key={virtualRow.index}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`
}}
>
<MarketCard market={markets[virtualRow.index]} />
</div>
))}
</div>
</div>
)
}
Modèles de Gestion de Formulaires
Formulaire Contrôlé avec Validation
interface FormData {
name: string
description: string
endDate: string
}
interface FormErrors {
name?: string
description?: string
endDate?: string
}
export function CreateMarketForm() {
const [formData, setFormData] = useState<FormData>({
name: '',
description: '',
endDate: ''
})
const [errors, setErrors] = useState<FormErrors>({})
const validate = (): boolean => {
const newErrors: FormErrors = {}
if (!formData.name.trim()) {
newErrors.name = 'Le nom est requis'
} else if (formData.name.length > 200) {
newErrors.name = 'Le nom doit faire moins de 200 caractères'
}
if (!formData.description.trim()) {
newErrors.description = 'La description est requise'
}
if (!formData.endDate) {
newErrors.endDate = 'La date de fin est requise'
}
setErrors(newErrors)
return Object.keys(newErrors).length === 0
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!validate()) return
try {
await createMarket(formData)
// Gestion du succès
} catch (error) {
// Gestion de l'erreur
}
}
return (
<form onSubmit={handleSubmit}>
<input
value={formData.name}
onChange={e => setFormData(prev => ({ ...prev, name: e.target.value }))}
placeholder="Nom du marché"
/>
{errors.name && <span className="error">{errors.name}</span>}
{/* Autres champs */}
<button type="submit">Créer le marché</button>
</form>
)
}
Modèle de Limite d'Erreur
interface ErrorBoundaryState {
hasError: boolean
error: Error | null
}
export class ErrorBoundary extends React.Component<
{ children: React.ReactNode },
ErrorBoundaryState
> {
state: ErrorBoundaryState = {
hasError: false,
error: null
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error }
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error('Limite d\'erreur attrapée :', error, errorInfo)
}
render() {
if (this.state.hasError) {
return (
<div className="error-fallback">
<h2>Quelque chose s'est mal passé</h2>
<p>{this.state.error?.message}</p>
<button onClick={() => this.setState({ hasError: false })}>
Réessayer
</button>
</div>
)
}
return this.props.children
}
}
// Utilisation
<ErrorBoundary>
<App />
</ErrorBoundary>
Modèles d'Animation
Animations Framer Motion
import { motion, AnimatePresence } from 'framer-motion'
// PASS : Animations de liste
export function AnimatedMarketList({ markets }: { markets: Market[] }) {
return (
<AnimatePresence>
{markets.map(market => (
<motion.div
key={market.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.3 }}
>
<MarketCard market={market} />
</motion.div>
))}
</AnimatePresence>
)
}
// PASS : Animations de modale
export function Modal({ isOpen, onClose, children }: ModalProps) {
return (
<AnimatePresence>
{isOpen && (
<>
<motion.div
className="modal-overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
/>
<motion.div
className="modal-content"
initial={{ opacity: 0, scale: 0.9, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9, y: 20 }}
>
{children}
</motion.div>
</>
)}
</AnimatePresence>
)
}
Modèles d'Accessibilité
Navigation au Clavier
export function Dropdown({ options, onSelect }: DropdownProps) {
const [isOpen, setIsOpen] = useState(false)
const [activeIndex, setActiveIndex] = useState(0)
const handleKeyDown = (e: React.KeyboardEvent) => {
switch (e.key) {
case 'ArrowDown':
e.preventDefault()
setActiveIndex(i => Math.min(i + 1, options.length - 1))
break
case 'ArrowUp':
e.preventDefault()
setActiveIndex(i => Math.max(i - 1, 0))
break
case 'Enter':
e.preventDefault()
onSelect(options[activeIndex])
setIsOpen(false)
break
case 'Escape':
e.preventDefault()
setIsOpen(false)
break
}
}
return (
<div className="dropdown" onKeyDown={handleKeyDown}>
<button onClick={() => setIsOpen(!isOpen)}>
Sélectionner une option
</button>
{isOpen && (
<ul className="dropdown-menu">
{options.map((option, index) => (
<li
key={option.value}
className={index === activeIndex ? 'active' : ''}
onClick={() => {
onSelect(option)
setIsOpen(false)
}}
>
{option.label}
</li>
))}
</ul>
)}
</div>
)
}
Modèles de Composants Serveur Next.js
Composants Serveur
// Ce composant s'exécute sur le serveur - aucune interactivité côté client
// Les composants serveur peuvent directement attendre des données async
// sans avoir besoin de hooks ni d'états de chargement
export default async function MarketPage() {
const markets = await getMarkets()
return (
<div>
<h1>Marchés</h1>
<MarketList markets={markets} />
</div>
)
}
Composants Serveur avec Rendu Partiel
import { Suspense } from 'react'
// Le composant parent est un composant serveur
export default function DashboardPage() {
return (
<div>
{/* Contenu statique rendu immédiatement */}
<h1>Tableau de bord</h1>
<p>Bienvenue sur votre tableau de bord</p>
{/* Contenu dynamique avec streaming */}
<Suspense fallback={<div>Chargement des marchés...</div>}>
<MarketList />
</Suspense>
<Suspense fallback={<div>Chargement des notifications...</div>}>
<NotificationList />
</Suspense>
</div>
)
}
// Composant serveur avec données asynchrones
async function MarketList() {
const markets = await fetchMarkets()
return (
<ul>
{markets.map(market => (
<li key={market.id}>
{market.name} - {market.price}
</li>
))}
</ul>
)
}
async function NotificationList() {
const notifications = await fetchNotifications()
return (
<ul>
{notifications.map(notification => (
<li key={notification.id} className="notification">
{notification.message}
</li>
))}
</ul>
)
}
Modèles de Composants Côté Client Next.js
Utilisez 'use client' pour l'Interactivité
'use client'
import { useState } from 'react'
export function SearchMarkets() {
const [query, setQuery] = useState('')
const [results, setResults] = useState<Market[]>([])
const handleSearch = async () => {
const data = await searchMarkets(query)
setResults(data)
}
return (
<div>
<input
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Rechercher des marchés..."
/>
<button onClick={handleSearch}>Rechercher</button>
<ul>
{results.map(market => (
<li key={market.id}>{market.name}</li>
))}
</ul>
</div>
)
}
Routage et Navigation
Utilisation de next/navigation
'use client'
import { useRouter, usePathname, useSearchParams } from 'next/navigation'
export function Navigation() {
const router = useRouter()
const pathname = usePathname()
const searchParams = useSearchParams()
const createQueryString = (name: string, value: string) => {
const params = new URLSearchParams(searchParams.toString())
params.set(name, value)
return params.toString()
}
return (
<div>
<p>Chemin actuel : {pathname}</p>
<button onClick={() => router.push('/markets')}>
Voir les marchés
</button>
<button
onClick={() => {
router.push('/markets?' + createQueryString('sort', 'volume'))
}}
>
Trier par volume
</button>
</div>
)
}
Tests
Test de Hook Personnalisé
import { renderHook, act } from '@testing-library/react'
import { useToggle } from './useToggle'
describe('useToggle', () => {
it('devrait commencer par la valeur par défaut', () => {
const { result } = renderHook(() => useToggle())
expect(result.current[0]).toBe(false)
})
it('devrait basculer la valeur', () => {
const { result } = renderHook(() => useToggle())
act(() => {
result.current[1]()
})
expect(result.current[0]).toBe(true)
act(() => {
result.current[1]()
})
expect(result.current[0]).toBe(false)
})
})
Test de Composant avec Rendu
import { render, screen, fireEvent } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { CreateMarketForm } from './CreateMarketForm'
describe('CreateMarketForm', () => {
it('devrait afficher les erreurs de validation', async () => {
render(<CreateMarketForm />)
const submitButton = screen.getByText('Créer le marché')
fireEvent.click(submitButton)
expect(await screen.findByText('Le nom est requis')).toBeInTheDocument()
expect(await screen.findByText('La description est requise')).toBeInTheDocument()
expect(await screen.findByText('La date de fin est requise')).toBeInTheDocument()
})
it('devrait soumettre avec des données valides', async () => {
const user = userEvent.setup()
render(<CreateMarketForm />)
const nameInput = screen.getByPlaceholderText('Nom du marché')
await user.type(nameInput, 'Mon Marché')
// Remplir les autres champs et soumettre...
})
})