Chapitre 15
15. React Patterns
15. React Patterns
React Patterns - Cours Détaillé
Introduction
React, depuis sa création par Facebook en 2013, a introduit une approche déclarative et component-based du développement UI. Au fil des versions, des patterns spécifiques ont émergé pour résoudre des problèmes récurrents. Ce chapitre couvre l'ensemble de ces patterns, des fondations jusqu'aux nouveautés de React 19.
1. Higher-Order Components (HOC)
Définition
Un Higher-Order Component est une fonction qui prend un composant et retourne un nouveau composant enrichi.
Principe
const EnhancedComponent = higherOrderComponent(WrappedComponent);
Cas d'usage
- Authentification (withAuth)
- Logging (withLogger)
- Data fetching (withData)
- Styling (withStyles)
Implémentation
interface WithLoadingProps {
loading: boolean;
}
function withLoading<P>(WrappedComponent: React.ComponentType<P & WithLoadingProps>) {
return function WithLoadingComponent(props: P & { isLoading: boolean }) {
const { isLoading, ...restProps } = props;
if (isLoading) return <div>Loading...</div>;
return <WrappedComponent {...(restProps as P)} loading={isLoading} />;
};
}
Limitations
- Nomage des composants (résolu avec displayName)
- Collision de props
- Inférence TypeScript complexe
- Alternatives modernes avec les hooks
2. Render Props
Définition
Un composant qui utilise une prop render (ou children comme fonction) pour partager du code entre des composants React.
Principe
<DataProvider render={data => <h1>Hello {data.target}</h1>} />
Implémentation
interface MouseProps {
render: (state: { x: number; y: number }) => React.ReactNode;
}
class MouseTracker extends React.Component<MouseProps> {
state = { x: 0, y: 0 };
handleMouseMove = (event: React.MouseEvent) => {
this.setState({ x: event.clientX, y: event.clientY });
};
render() {
return (
<div onMouseMove={this.handleMouseMove}>
{this.props.render(this.state)}
</div>
);
}
}
// Utilisation
<MouseTracker render={({ x, y }) => (
<p>Mouse position: {x}, {y}</p>
)} />
Avantages
- Pas de collision de props (contrairement aux HOC)
- Flexibilité totale dans le rendu
- Bon pour le partage de comportement
Inconvénients
- "Wrapper hell" ou "callback hell"
- Performance (création de fonctions à chaque render)
- Peu intuitif pour les débutants
Alternative moderne
Les hooks (useMousePosition) remplacent la plupart des cas d'usage des Render Props.
3. Compound Components
Définition
Pattern où plusieurs composants travaillent ensemble en partageant un état implicite via Context.
Exemple classique
// Accordion
<Accordion>
<Accordion.Item>
<Accordion.Header>Section 1</Accordion.Header>
<Accordion.Panel>Contenu 1</Accordion.Panel>
</Accordion.Item>
<Accordion.Item>
<Accordion.Header>Section 2</Accordion.Header>
<Accordion.Panel>Contenu 2</Accordion.Panel>
</Accordion.Item>
</Accordion>
Implémentation
interface AccordionContextType {
openIndex: number | null;
setOpenIndex: (index: number | null) => void;
}
const AccordionContext = createContext<AccordionContextType | null>(null);
function Accordion({ children }: { children: React.ReactNode }) {
const [openIndex, setOpenIndex] = useState<number | null>(null);
return (
<AccordionContext.Provider value={{ openIndex, setOpenIndex }}>
{children}
</AccordionContext.Provider>
);
}
Accordion.Item = function AccordionItem({ children }: { children: React.ReactNode }) {
return <div className="accordion-item">{children}</div>;
};
Accordion.Header = function AccordionHeader({ children, index }: { children: React.ReactNode; index: number }) {
const context = useContext(AccordionContext);
if (!context) throw new Error('Accordion.Header must be within Accordion');
return (
<button onClick={() => context.setOpenIndex(context.openIndex === index ? null : index)}>
{children}
</button>
);
};
Variante : Compound Components with Context + useReducer
type Action = { type: 'OPEN'; index: number } | { type: 'CLOSE' } | { type: 'TOGGLE'; index: number };
function accordionReducer(state: AccordionState, action: Action): AccordionState {
switch (action.type) {
case 'OPEN':
return { ...state, openIndex: action.index };
case 'CLOSE':
return { ...state, openIndex: null };
case 'TOGGLE':
return { ...state, openIndex: state.openIndex === action.index ? null : action.index };
default:
return state;
}
}
4. Custom Hooks
useState Patterns
// Hook de formulaire générique
function useForm<T>(initialValues: T) {
const [values, setValues] = useState<T>(initialValues);
const handleChange = useCallback((field: keyof T, value: any) => {
setValues(prev => ({ ...prev, [field]: value }));
}, []);
const reset = useCallback(() => {
setValues(initialValues);
}, [initialValues]);
return { values, handleChange, reset };
}
// Hook toggle
function useToggle(initialValue = false) {
const [value, setValue] = useState(initialValue);
const toggle = useCallback(() => setValue(prev => !prev), []);
const setTrue = useCallback(() => setValue(true), []);
const setFalse = useCallback(() => setValue(false), []);
return { value, toggle, setTrue, setFalse };
}
// usePrevious
function usePrevious<T>(value: T): T | undefined {
const ref = useRef<T>();
useEffect(() => { ref.current = value; }, [value]);
return ref.current;
}
useEffect Patterns
// Debounce effect
function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}
// useInterval
function useInterval(callback: () => void, delay: number | null) {
const savedCallback = useRef(callback);
useEffect(() => { savedCallback.current = callback; });
useEffect(() => {
if (delay === null) return;
const id = setInterval(() => savedCallback.current(), delay);
return () => clearInterval(id);
}, [delay]);
}
// useEffectOnce
function useEffectOnce(callback: () => void | (() => void)) {
const hasRun = useRef(false);
useEffect(() => {
if (hasRun.current) return;
hasRun.current = true;
return callback();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}
useReducer Patterns
// State machine avec useReducer
type Status = 'idle' | 'loading' | 'success' | 'error';
interface AsyncState<T> {
status: Status;
data: T | null;
error: Error | null;
}
type AsyncAction<T> =
| { type: 'LOADING' }
| { type: 'SUCCESS'; payload: T }
| { type: 'ERROR'; payload: Error };
function asyncReducer<T>(state: AsyncState<T>, action: AsyncAction<T>): AsyncState<T> {
switch (action.type) {
case 'LOADING': return { status: 'loading', data: null, error: null };
case 'SUCCESS': return { status: 'success', data: action.payload, error: null };
case 'ERROR': return { status: 'error', data: null, error: action.payload };
default: return state;
}
}
// Hook useAsync
function useAsync<T>(asyncFn: () => Promise<T>) {
const [state, dispatch] = useReducer(asyncReducer<T>, {
status: 'idle',
data: null,
error: null,
});
const execute = useCallback(async () => {
dispatch({ type: 'LOADING' });
try {
const result = await asyncFn();
dispatch({ type: 'SUCCESS', payload: result });
} catch (error) {
dispatch({ type: 'ERROR', payload: error as Error });
}
}, [asyncFn]);
return { ...state, execute };
}
5. Context + useReducer (Redux-like)
Architecture
// 1. State et Actions
interface AuthState {
user: User | null;
isAuthenticated: boolean;
isLoading: boolean;
}
type AuthAction =
| { type: 'LOGIN_START' }
| { type: 'LOGIN_SUCCESS'; payload: User }
| { type: 'LOGIN_ERROR'; payload: string }
| { type: 'LOGOUT' };
// 2. Reducer
function authReducer(state: AuthState, action: AuthAction): AuthState {
switch (action.type) {
case 'LOGIN_START':
return { ...state, isLoading: true };
case 'LOGIN_SUCCESS':
return { user: action.payload, isAuthenticated: true, isLoading: false };
case 'LOGIN_ERROR':
return { ...state, isLoading: false };
case 'LOGOUT':
return { user: null, isAuthenticated: false, isLoading: false };
default:
return state;
}
}
// 3. Context
interface AuthContextType extends AuthState {
login: (email: string, password: string) => Promise<void>;
logout: () => void;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
// 4. Provider
function AuthProvider({ children }: { children: React.ReactNode }) {
const [state, dispatch] = useReducer(authReducer, {
user: null,
isAuthenticated: false,
isLoading: false,
});
const login = async (email: string, password: string) => {
dispatch({ type: 'LOGIN_START' });
try {
const user = await api.login(email, password);
dispatch({ type: 'LOGIN_SUCCESS', payload: user });
} catch (error) {
dispatch({ type: 'LOGIN_ERROR', payload: error.message });
}
};
const logout = () => {
dispatch({ type: 'LOGOUT' });
};
return (
<AuthContext.Provider value={{ ...state, login, logout }}>
{children}
</AuthContext.Provider>
);
}
// 5. Hook personnalisé
function useAuth(): AuthContextType {
const context = useContext(AuthContext);
if (!context) throw new Error('useAuth must be used within AuthProvider');
return context;
}
6. Controlled vs Uncontrolled
Controlled Components
function ControlledInput() {
const [value, setValue] = useState('');
return (
<input
value={value}
onChange={(e) => setValue(e.target.value)}
/>
);
}
Uncontrolled Components
function UncontrolledInput() {
const inputRef = useRef<HTMLInputElement>(null);
const handleSubmit = () => {
console.log(inputRef.current?.value);
};
return (
<div>
<input ref={inputRef} defaultValue="default" />
<button onClick={handleSubmit}>Submit</button>
</div>
);
}
useImperativeHandle pour les refs
interface CustomInputHandle {
focus: () => void;
clear: () => void;
getValue: () => string | undefined;
}
const CustomInput = forwardRef<CustomInputHandle, Props>((props, ref) => {
const inputRef = useRef<HTMLInputElement>(null);
useImperativeHandle(ref, () => ({
focus: () => inputRef.current?.focus(),
clear: () => { if (inputRef.current) inputRef.current.value = ''; },
getValue: () => inputRef.current?.value,
}));
return <input ref={inputRef} {...props} />;
});
7. Container/Presentational
Presentational Component
interface UserListProps {
users: User[];
onSelect: (user: User) => void;
loading: boolean;
error: string | null;
}
function UserListView({ users, onSelect, loading, error }: UserListProps) {
if (loading) return <Spinner />;
if (error) return <ErrorMessage message={error} />;
return (
<ul>
{users.map(user => (
<li key={user.id}>
<button onClick={() => onSelect(user)}>{user.name}</button>
</li>
))}
</ul>
);
}
Container Component
function UserListContainer() {
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetchUsers()
.then(data => { setUsers(data); setLoading(false); })
.catch(err => { setError(err.message); setLoading(false); });
}, []);
const handleSelect = (user: User) => {
navigate(`/users/${user.id}`);
};
return (
<UserListView
users={users}
onSelect={handleSelect}
loading={loading}
error={error}
/>
);
}
Critique moderne
Avec les hooks, la distinction est moins nécessaire. Les containers peuvent être remplacés par des hooks personnalisés :
function useUserList() {
// toute la logique ici
return { users, loading, error, handleSelect };
}
function UserListPage() {
const { users, loading, error, handleSelect } = useUserList();
return <UserListView users={users} onSelect={handleSelect} loading={loading} error={error} />;
}
8. Error Boundaries
Définition
Un Error Boundary est un composant qui capture les erreurs JavaScript de ses enfants et affiche une UI de secours.
Limitation
Les Error Boundaries ne capturent PAS les erreurs dans :
- Les event handlers (utiliser try/catch)
- Le code asynchrone (setTimeout, requestAnimationFrame)
- Le SSR
- Les erreurs dans l'Error Boundary lui-même
Implémentation
interface ErrorBoundaryProps {
fallback: React.ReactNode;
children: React.ReactNode;
onError?: (error: Error, errorInfo: React.ErrorInfo) => void;
}
interface ErrorBoundaryState {
hasError: boolean;
error: Error | null;
}
class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
this.props.onError?.(error, errorInfo);
}
render() {
if (this.state.hasError) {
return this.props.fallback;
}
return this.props.children;
}
}
// Utilisation
<ErrorBoundary
fallback={<ErrorScreen />}
onError={(error) => logErrorToService(error)}
>
<DataComponent />
</ErrorBoundary>
Hook useErrorBoundary (approche moderne)
function useErrorBoundary() {
const [error, setError] = useState<Error | null>(null);
const throwError = useCallback((err: Error) => {
setError(err);
throw err;
}, []);
return { error, throwError };
}
9. Portals
Définition
Les Portals permettent de rendre un composant enfant dans un nœud DOM différent de celui du parent.
Cas d'usage
- Modales
- Tooltips
- Popovers
- Notifications / Toasts
Implémentation
function Modal({ isOpen, onClose, children }: ModalProps) {
if (!isOpen) return null;
return createPortal(
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content" onClick={e => e.stopPropagation()}>
<button className="modal-close" onClick={onClose}>×</button>
{children}
</div>
</div>,
document.getElementById('modal-root')!
);
}
Portal avec contexte
Les Portals conservent l'accès au contexte React :
function NotificationProvider({ children }: { children: React.ReactNode }) {
const [notifications, setNotifications] = useState<Notification[]>([]);
const addNotification = useCallback((notification: Notification) => {
setNotifications(prev => [...prev, notification]);
}, []);
return (
<NotificationContext.Provider value={{ addNotification }}>
{children}
{createPortal(
<div className="notification-container">
{notifications.map(n => (
<NotificationItem key={n.id} {...n} />
))}
</div>,
document.body
)}
</NotificationContext.Provider>
);
}
10. Suspense Patterns
Suspense de base
function ProfilePage() {
return (
<Suspense fallback={<Loading />}>
<ProfileDetails />
<Suspense fallback={<LoadingPosts />}>
<ProfilePosts />
</Suspense>
</Suspense>
);
}
Data Fetching avec Suspense
// Wrapper pour promesse
function wrapPromise<T>(promise: Promise<T>) {
let status: 'pending' | 'success' | 'error' = 'pending';
let result: T;
let error: Error;
const suspender = promise.then(
r => { status = 'success'; result = r; },
e => { status = 'error'; error = e; }
);
return {
read(): T {
if (status === 'pending') throw suspender;
if (status === 'error') throw error;
return result;
}
};
}
// Resource
const userResource = wrapPromise(fetchUser(1));
function UserProfile() {
const user = userResource.read();
return <div>{user.name}</div>;
}
Suspense avec React.lazy
const HeavyComponent = React.lazy(() => import('./HeavyComponent'));
function App() {
return (
<Suspense fallback={<Loading />}>
<HeavyComponent />
</Suspense>
);
}
Suspense et Error Boundary
function DataPage() {
return (
<ErrorBoundary fallback={<ErrorFallback />}>
<Suspense fallback={<Loading />}>
<DataComponent />
</Suspense>
</ErrorBoundary>
);
}
11. React 19 Actions
useActionState
import { useActionState } from 'react';
async function submitForm(prevState: FormState, formData: FormData): Promise<FormState> {
// Logique de formulaire
const name = formData.get('name') as string;
if (!name) return { error: 'Name is required' };
// Appel API
return { success: true };
}
function MyForm() {
const [state, formAction, isPending] = useActionState(submitForm, { error: null });
return (
<form action={formAction}>
<input name="name" />
<button type="submit" disabled={isPending}>
{isPending ? 'Submitting...' : 'Submit'}
</button>
{state?.error && <p className="error">{state.error}</p>}
</form>
);
}
useFormStatus
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? 'Saving...' : 'Save'}
</button>
);
}
function MyForm() {
return (
<form action={handleSubmit}>
<input name="email" />
<SubmitButton />
</form>
);
}
useOptimistic
function MessageList({ messages, sendMessage }: MessageListProps) {
const [optimisticMessages, addOptimisticMessage] = useOptimistic(
messages,
(state, newMessage: Message) => [...state, newMessage]
);
const handleSubmit = async (formData: FormData) => {
const text = formData.get('message') as string;
const optimisticMsg = { id: Date.now(), text, sending: true };
addOptimisticMessage(optimisticMsg);
try {
await sendMessage(text);
} catch (error) {
// Rollback - l'état revient automatiquement
}
};
return (
<ul>
{optimisticMessages.map(msg => (
<li key={msg.id} className={msg.sending ? 'sending' : ''}>
{msg.text}
</li>
))}
</ul>
);
}
Server Components (React 19)
// Server Component (par défaut dans Next.js App Router)
async function UserProfile({ userId }: { userId: string }) {
const user = await db.user.findUnique({ where: { id: userId } });
return (
<div>
<h1>{user.name}</h1>
<ClientButton user={user} />
</div>
);
}
// Client Component
'use client';
function ClientButton({ user }: { user: User }) {
const [liked, setLiked] = useState(false);
return <button onClick={() => setLiked(!liked)}>{liked ? '❤️' : '🤍'}</button>;
}
Actions avec transitions
function SearchPage() {
const [searchTerm, setSearchTerm] = useState('');
const [results, setResults] = useState<Result[]>([]);
const [isPending, startTransition] = useTransition();
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setSearchTerm(value);
startTransition(async () => {
const newResults = await searchAPI(value);
setResults(newResults);
});
};
return (
<div>
<input value={searchTerm} onChange={handleChange} />
{isPending && <Spinner />}
<ResultsList results={results} />
</div>
);
}
12. Patterns de Performance
useMemo et useCallback
function ExpensiveList({ items, filter }: { items: Item[]; filter: Filter }) {
const filteredItems = useMemo(
() => items.filter(item => matchFilter(item, filter)),
[items, filter]
);
const handleClick = useCallback(
(id: number) => console.log('Clicked', id),
[]
);
return filteredItems.map(item => (
<Item key={item.id} data={item} onClick={handleClick} />
));
}
Virtualisation
function VirtualList({ items }: { items: Item[] }) {
const containerRef = useRef<HTMLDivElement>(null);
const [visibleRange, setVisibleRange] = useState({ start: 0, end: 20 });
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const handleScroll = () => {
const { scrollTop, clientHeight } = container;
const start = Math.floor(scrollTop / ITEM_HEIGHT);
const end = start + Math.ceil(clientHeight / ITEM_HEIGHT) + 10;
setVisibleRange({ start, end });
};
container.addEventListener('scroll', handleScroll);
return () => container.removeEventListener('scroll', handleScroll);
}, []);
return (
<div ref={containerRef} style={{ height: '500px', overflow: 'auto' }}>
<div style={{ height: items.length * ITEM_HEIGHT }}>
{items.slice(visibleRange.start, visibleRange.end).map(item => (
<div key={item.id} style={{ height: ITEM_HEIGHT }}>{item.name}</div>
))}
</div>
</div>
);
}
13. Architecture des tests
Component Testing Pattern
import { render, screen, fireEvent } from '@testing-library/react';
describe('LoginForm', () => {
it('calls onSubmit with form data', async () => {
const handleSubmit = jest.fn();
render(<LoginForm onSubmit={handleSubmit} />);
await userEvent.type(screen.getByLabelText(/email/i), 'test@test.com');
await userEvent.type(screen.getByLabelText(/password/i), 'password123');
await userEvent.click(screen.getByRole('button', { name: /submit/i }));
expect(handleSubmit).toHaveBeenCalledWith({
email: 'test@test.com',
password: 'password123',
});
});
it('displays validation errors', async () => {
render(<LoginForm onSubmit={jest.fn()} />);
await userEvent.click(screen.getByRole('button', { name: /submit/i }));
expect(screen.getByText(/email is required/i)).toBeInTheDocument();
});
});
Hook Testing Pattern
import { renderHook, act } from '@testing-library/react-hooks';
describe('useToggle', () => {
it('toggles value', () => {
const { result } = renderHook(() => useToggle(false));
expect(result.current.value).toBe(false);
act(() => result.current.toggle());
expect(result.current.value).toBe(true);
act(() => result.current.setFalse());
expect(result.current.value).toBe(false);
});
});
14. Anti-patterns à éviter
- Props drilling profond → Utiliser Context
- Too many re-renders → useMemo, useCallback
- State lifting excessif → Context ou state management
- Giant components → Split en composants plus petits
- useEffect sans dépendances → Toujours spécifier les dépendances
- Mutation directe du state → Toujours utiliser setState immutably
- Key index dans les listes → Utiliser un id unique et stable
- Nested render functions → Extraire en composants séparés
15. Tableau récapitulatif
| Pattern | Quand l'utiliser | Éviter quand |
|---|---|---|
| HOC | Logique cross-cutting sans hooks | Alternatives hooks existent |
| Render Props | Besoin de flexibilité max | Peu de réutilisation |
| Compound Components | Composants complexes type UI kit | Composants simples |
| Custom Hooks | Réutilisation de logique stateful | Logique pure (fonctions) |
| Context + useReducer | State global simple | State très complexe (Redux) |
| Error Boundaries | Gestion erreurs UI | Logique métier |
| Portals | Modales/tooltips overlay | Rendu standard |
| Suspense | Data fetching, lazy loading | Données synchrones |
16. Conclusion
React a évolué d'un simple library de vues vers un framework complet avec ses propres patterns. La tendance est aux hooks, Server Components, et Actions. Les patterns ne disparaissent pas, ils se transforment. Comprendre ces patterns permet de faire les bons choix architecturaux.