MFormations
Modern Design Patterns

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

  1. Props drilling profond → Utiliser Context
  2. Too many re-renders → useMemo, useCallback
  3. State lifting excessif → Context ou state management
  4. Giant components → Split en composants plus petits
  5. useEffect sans dépendances → Toujours spécifier les dépendances
  6. Mutation directe du state → Toujours utiliser setState immutably
  7. Key index dans les listes → Utiliser un id unique et stable
  8. Nested render functions → Extraire en composants séparés

15. Tableau récapitulatif

PatternQuand l'utiliserÉviter quand
HOCLogique cross-cutting sans hooksAlternatives hooks existent
Render PropsBesoin de flexibilité maxPeu de réutilisation
Compound ComponentsComposants complexes type UI kitComposants simples
Custom HooksRéutilisation de logique statefulLogique pure (fonctions)
Context + useReducerState global simpleState très complexe (Redux)
Error BoundariesGestion erreurs UILogique métier
PortalsModales/tooltips overlayRendu standard
SuspenseData fetching, lazy loadingDonné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.