MFormations
Modern Frontend Engineering

Chapitre 7

Chapitre 07 — React

Chapitre 07 — React

Chapitre 07 — React : Cours Complet

1. Histoire et Fondamentaux

1.1 Le problème de la manipulation DOM

Avant React, les frameworks comme jQuery manipulaient directement le DOM. Problème : à mesure que les applications grandissaient, la gestion des mises à jour devenait un cauchemar.

Démo du problème :

// jQuery — manipulation impérative
$("#userList").append(`<li>${user.name}</li>`);
$("#userList li").click(function() {
  $(this).toggleClass("selected");
});

// React — approche déclarative
function UserList({ users }) {
  return (
    <ul>
      {users.map(user => (
        <li key={user.id} onClick={handleClick}>{user.name}</li>
      ))}
    </ul>
  );
}

React introduit le Virtual DOM : une représentation légère du DOM réel en mémoire. Quand l'état change, React :

  1. Crée un nouveau Virtual DOM
  2. Le compare avec le précédent (Diffing)
  3. Calcule le minimum de mutations nécessaires (Reconciliation)

1.2 React Fiber (React 16+)

En 2017, React réécrit son moteur de réconciliation : Fiber. Objectifs :

  • Incremental rendering : découper le travail en unités et le répartir sur plusieurs frames
  • Priorité : les mises à jour urgentes (input utilisateur) passent avant les mises à jour différées (animations)
  • Suspense : permettre à React d'attendre du contenu asynchrone
// Concept : chaque fibre est une unité de travail
interface Fiber {
  tag: WorkTag; // type de fibre (composant, host, etc.)
  key: string | null;
  type: any; // fonction composant, classe, string pour DOM
  stateNode: any; // instance du composant
  child: Fiber | null;
  sibling: Fiber | null;
  return: Fiber | null; // parent
  pendingProps: any;
  memoizedProps: any;
  memoizedState: any;
  effectTag: SideEffectTag; // PLACEMENT, UPDATE, DELETION
  alternate: Fiber | null; // lien vers l'ancienne fibre (double buffering)
}

1.3 JSX : Pourquoi et comment ça marche ?

JSX est un sucre syntaxique pour React.createElement.

// JSX
const element = <h1 className="title">Hello, World!</h1>;

// Ce que JSX compile
const element = React.createElement(
  "h1",
  { className: "title" },
  "Hello, World!"
);

Le compilateur (Babel, TypeScript via jsx: "react-jsx") transforme JSX en appels jsx() (React 17+), ce qui permet d'importer React automatiquement.

2. Composants : Function vs Class

2.1 Function Components (moderne)

interface GreetingProps {
  name: string;
  age?: number;
}

const Greeting: React.FC<GreetingProps> = ({ name, age }) => {
  return (
    <div>
      <h1>Bonjour, {name}!</h1>
      {age && <p>Tu as {age} ans.</p>}
    </div>
  );
};

2.2 Class Components (legacy, encore supporté)

interface CounterState {
  count: number;
}

class Counter extends React.Component<{}, CounterState> {
  state: CounterState = { count: 0 };

  componentDidMount() {
    console.log("Mounted");
  }

  componentDidUpdate(prevProps: {}, prevState: CounterState) {
    if (prevState.count !== this.state.count) {
      console.log(`Count changed: ${this.state.count}`);
    }
  }

  componentWillUnmount() {
    console.log("Will unmount");
  }

  render() {
    return (
      <div>
        <p>Count: {this.state.count}</p>
        <button onClick={() => this.setState({ count: this.state.count + 1 })}>
          +
        </button>
      </div>
    );
  }
}

Depuis React 19, les Class Components sont dépréciés — utilisez des fonctions et des hooks.

3. Les Hooks

3.1 useState

const [state, setState] = useState<number>(0);

// Fonction de mise à jour
setState(prev => prev + 1);

// Lazy initializer
const [data] = useState(() => expensiveComputation());

3.2 useEffect

useEffect(() => {
  // Effet : s'exécute après le rendu
  const subscription = api.subscribe(id, handleUpdate);

  // Cleanup : s'exécute avant le prochain effet ou au démontage
  return () => {
    subscription.unsubscribe();
  };
}, [id]); // Se ré-exécute si `id` change

// useEffect sans dépendances (s'exécute à chaque rendu) — rare
useEffect(() => {
  console.log("Rendu");
});

// useEffect avec [] (s'exécute une fois au montage)
useEffect(() => {
  fetchData();
}, []);

3.3 useContext

interface ThemeContextType {
  theme: "light" | "dark";
  toggleTheme: () => void;
}

const ThemeContext = createContext<ThemeContextType | undefined>(undefined);

function useTheme() {
  const context = useContext(ThemeContext);
  if (!context) {
    throw new Error("useTheme must be used within ThemeProvider");
  }
  return context;
}

// Provider
function App() {
  const [theme, setTheme] = useState<"light" | "dark">("light");
  const toggleTheme = () => setTheme(t => t === "light" ? "dark" : "light");

  return (
    <ThemeContext.Provider value={{ theme, toggleTheme }}>
      <MainContent />
    </ThemeContext.Provider>
  );
}

// Consumer
function MainContent() {
  const { theme, toggleTheme } = useTheme();
  return <div className={theme}>...<button onClick={toggleTheme}>Toggle</button></div>;
}

3.4 useRef

// Référence à un élément DOM
const inputRef = useRef<HTMLInputElement>(null);

useEffect(() => {
  inputRef.current?.focus();
}, []);

// Valeur mutable persistante entre les rendus
const intervalRef = useRef<number | null>(null);
intervalRef.current = setInterval(() => {}, 1000);

// Stocker la valeur précédente
function usePrevious<T>(value: T): T | undefined {
  const ref = useRef<T>();
  useEffect(() => {
    ref.current = value;
  });
  return ref.current;
}

3.5 useMemo et useCallback

// useMemo : mémoïse une valeur calculée
const sortedItems = useMemo(() => {
  return items.sort((a, b) => a.name.localeCompare(b.name));
}, [items]);

// useCallback : mémoïse une fonction
const handleClick = useCallback((e: React.MouseEvent) => {
  console.log("Clicked at", e.clientX, e.clientY);
}, []);

// Quand les utiliser ?
// - useMemo : calculs coûteux, références stables pour les enfants memoïsés
// - useCallback : passer des callbacks à des composants memoïsés
// Ne pas les utiliser systématiquement — ils ont un coût

3.6 useReducer

type TaskAction =
  | { type: "ADD"; payload: string }
  | { type: "TOGGLE"; payload: number }
  | { type: "DELETE"; payload: number }
  | { type: "SET_FILTER"; payload: Filter };

interface TaskState {
  tasks: Task[];
  filter: Filter;
}

function taskReducer(state: TaskState, action: TaskAction): TaskState {
  switch (action.type) {
    case "ADD":
      return {
        ...state,
        tasks: [...state.tasks, { id: Date.now(), text: action.payload, done: false }],
      };
    case "TOGGLE":
      return {
        ...state,
        tasks: state.tasks.map(t =>
          t.id === action.payload ? { ...t, done: !t.done } : t
        ),
      };
    case "DELETE":
      return {
        ...state,
        tasks: state.tasks.filter(t => t.id !== action.payload),
      };
    case "SET_FILTER":
      return { ...state, filter: action.payload };
    default:
      return state;
  }
}

function TaskApp() {
  const [state, dispatch] = useReducer(taskReducer, { tasks: [], filter: "all" });
}

3.7 useLayoutEffect

// Similaire à useEffect mais synchrone (avant le paint)
useLayoutEffect(() => {
  // Mesures DOM, animations, scroll position
  const rect = elementRef.current?.getBoundingClientRect();
  if (rect) {
    setPosition({ x: rect.x, y: rect.y });
  }
}, []);

// N'utilisez useLayoutEffect que si vous voyez un flash / flicker
// useEffect est généralement suffisant

3.8 useId (React 18+)

function Checkbox() {
  const id = useId();
  return (
    <div>
      <label htmlFor={id}>Accept terms</label>
      <input id={id} type="checkbox" />
    </div>
  );
}
// Génère des IDs uniques pour l'accessibilité et le SSR hydration

3.9 useTransition (React 18+)

function SearchPage() {
  const [query, setQuery] = useState("");
  const [isPending, startTransition] = useTransition();

  function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
    // Urgent : mettre à jour l'input immédiatement
    setQuery(e.target.value);

    // Non urgent : lancer la recherche (peut être interrompue)
    startTransition(() => {
      setSearchQuery(e.target.value);
    });
  }

  return (
    <div>
      <input value={query} onChange={handleChange} />
      {isPending && <Spinner />}
      <SearchResults query={searchQuery} />
    </div>
  );
}

3.10 useDeferredValue (React 18+)

function SearchPage({ query }: { query: string }) {
  const deferredQuery = useDeferredValue(query);
  const isStale = query !== deferredQuery;

  return (
    <div style={{ opacity: isStale ? 0.5 : 1 }}>
      <SearchResults query={deferredQuery} />
    </div>
  );
}
// Similaire à useTransition mais pour les valeurs, pas les fonctions

3.11 useOptimistic (React 19)

function MessageThread({ messages, sendMessage }: MessageThreadProps) {
  const [optimisticMessages, addOptimisticMessage] = useOptimistic(
    messages,
    (state, newMessage: string) => [...state, { text: newMessage, sending: true }]
  );

  async function handleSubmit(formData: FormData) {
    const message = formData.get("message") as string;
    addOptimisticMessage(message);
    await sendMessage(message); // La mise à jour réelle remplace l'optimistic
  }

  return (
    <form action={handleSubmit}>
      {optimisticMessages.map((msg, i) => (
        <div key={i}>
          {msg.text} {msg.sending && <Spinner />}
        </div>
      ))}
      <input name="message" />
      <button type="submit">Send</button>
    </form>
  );
}

3.12 useSyncExternalStore (React 18+)

function useOnlineStatus() {
  const isOnline = useSyncExternalStore(
    (callback) => {
      window.addEventListener("online", callback);
      window.addEventListener("offline", callback);
      return () => {
        window.removeEventListener("online", callback);
        window.removeEventListener("offline", callback);
      };
    },
    () => navigator.onLine, // Snapshot côté client
    () => true               // Snapshot côté serveur (SSR)
  );
  return isOnline;
}
// Pour s'abonner à des stores externes non-React (Redux, Zustand, etc.)

4. Rules of Hooks

  1. Appelez les Hooks uniquement au niveau racine — pas dans des conditions, boucles ou fonctions imbriquées
  2. Appelez les Hooks uniquement depuis des composants React ou des Custom Hooks — pas depuis des fonctions JavaScript classiques
// ❌ FAUX
function BadComponent({ condition }: { condition: boolean }) {
  if (condition) {
    const [value, setValue] = useState(0); // Violation de la règle 1
  }
}

// ✅ CORRECT
function GoodComponent({ condition }: { condition: boolean }) {
  const [value, setValue] = useState(0); // Toujours au même endroit
  // Utiliser condition dans le JSX ou useEffect
}

Pourquoi ? React utilise l'ordre d'appel des hooks pour associer chaque hook à son état. Si l'ordre change entre deux rendus, les états sont mélangés.

5. Gestion d'État

5.1 Stratégies de gestion d'état

Type d'étatSolutionQuand l'utiliser
LocaluseStateÉtat propre à un composant
Composant voisinuseState + LiftingComposants parent/enfant proches
Arbre profonduseContextÉviter le prop drilling
Formulaires complexesuseReducerLogique d'état complexe
Global (données serveur)TanStack Query / SWRCache, revalidation, pagination
Global (état client)Zustand / Redux ToolkitÉtat partagé entre pages ou modules
URLuseSearchParamsÉtat partagé via URL (filtres, pagination)

5.2 Context + useReducer (état local global)

interface AuthState {
  user: User | null;
  isLoading: boolean;
}

type AuthAction =
  | { type: "LOGIN_START" }
  | { type: "LOGIN_SUCCESS"; payload: User }
  | { type: "LOGIN_ERROR"; payload: string }
  | { type: "LOGOUT" };

function authReducer(state: AuthState, action: AuthAction): AuthState {
  switch (action.type) {
    case "LOGIN_START":
      return { ...state, isLoading: true };
    case "LOGIN_SUCCESS":
      return { user: action.payload, isLoading: false };
    case "LOGIN_ERROR":
      return { user: null, isLoading: false };
    case "LOGOUT":
      return { user: null, isLoading: false };
    default:
      return state;
  }
}

5.3 Zustand (moderne, minimaliste)

import { create } from "zustand";
import { persist } from "zustand/middleware";

interface CartStore {
  items: CartItem[];
  total: number;
  addItem: (item: CartItem) => void;
  removeItem: (id: string) => void;
  clearCart: () => void;
}

const useCartStore = create<CartStore>()(
  persist(
    (set, get) => ({
      items: [],
      total: 0,
      addItem: (item) =>
        set((state) => ({
          items: [...state.items, item],
          total: state.total + item.price * item.quantity,
        })),
      removeItem: (id) =>
        set((state) => ({
          items: state.items.filter((i) => i.id !== id),
          total: state.items
            .filter((i) => i.id !== id)
            .reduce((sum, i) => sum + i.price * i.quantity, 0),
        })),
      clearCart: () => set({ items: [], total: 0 }),
    }),
    { name: "cart-storage" }
  )
);

5.4 Redux Toolkit (industriel)

import { createSlice, configureStore, PayloadAction } from "@reduxjs/toolkit";

interface CounterState {
  value: number;
}

const counterSlice = createSlice({
  name: "counter",
  initialState: { value: 0 } as CounterState,
  reducers: {
    increment: (state) => { state.value += 1; },
    decrement: (state) => { state.value -= 1; },
    incrementByAmount: (state, action: PayloadAction<number>) => {
      state.value += action.payload;
    },
  },
});

export const { increment, decrement, incrementByAmount } = counterSlice.actions;
export const store = configureStore({
  reducer: { counter: counterSlice.reducer },
});

export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;

6. Patterns de Composition

6.1 Compound Components

interface TabsContextType {
  activeTab: string;
  setActiveTab: (tab: string) => void;
}

const TabsContext = createContext<TabsContextType | undefined>(undefined);

function Tabs({ children, defaultTab }: { children: React.ReactNode; defaultTab: string }) {
  const [activeTab, setActiveTab] = useState(defaultTab);
  return (
    <TabsContext.Provider value={{ activeTab, setActiveTab }}>
      {children}
    </TabsContext.Provider>
  );
}

Tabs.Tab = function Tab({ id, children }: { id: string; children: React.ReactNode }) {
  const { activeTab, setActiveTab } = useContext(TabsContext)!;
  return (
    <button
      className={activeTab === id ? "active" : ""}
      onClick={() => setActiveTab(id)}
    >
      {children}
    </button>
  );
};

Tabs.Panel = function Panel({ id, children }: { id: string; children: React.ReactNode }) {
  const { activeTab } = useContext(TabsContext)!;
  return activeTab === id ? <div>{children}</div> : null;
};

// Utilisation
<Tabs defaultTab="profile">
  <Tabs.Tab id="profile">Profil</Tabs.Tab>
  <Tabs.Tab id="settings">Paramètres</Tabs.Tab>
  <Tabs.Panel id="profile">Contenu du profil</Tabs.Panel>
  <Tabs.Panel id="settings">Contenu des paramètres</Tabs.Panel>
</Tabs>

6.2 Render Props

interface MouseTrackerProps {
  render: (position: { x: number; y: number }) => React.ReactNode;
}

function MouseTracker({ render }: MouseTrackerProps) {
  const [position, setPosition] = useState({ x: 0, y: 0 });

  return (
    <div onMouseMove={(e) => setPosition({ x: e.clientX, y: e.clientY })}>
      {render(position)}
    </div>
  );
}

// Utilisation
<MouseTracker
  render={({ x, y }) => (
    <p>La souris est à ({x}, {y})</p>
  )}
/>

6.3 Higher-Order Component (HOC)

function withAuth<P extends object>(Component: React.ComponentType<P>) {
  return function AuthenticatedComponent(props: P) {
    const { user } = useAuth();

    if (!user) {
      return <Redirect to="/login" />;
    }

    return <Component {...props} user={user} />;
  };
}

const DashboardPage = withAuth(Dashboard);

6.4 Custom Hooks

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;
}

function useLocalStorage<T>(key: string, initialValue: T) {
  const [storedValue, setStoredValue] = useState<T>(() => {
    try {
      const item = window.localStorage.getItem(key);
      return item ? JSON.parse(item) : initialValue;
    } catch {
      return initialValue;
    }
  });

  const setValue = (value: T | ((val: T) => T)) => {
    const valueToStore = value instanceof Function ? value(storedValue) : value;
    setStoredValue(valueToStore);
    window.localStorage.setItem(key, JSON.stringify(valueToStore));
  };

  return [storedValue, setValue] as const;
}

7. Performance

7.1 React.memo

const ExpensiveList = React.memo(function ExpensiveList({ items }: { items: Item[] }) {
  return (
    <ul>
      {items.map(item => (
        <li key={item.id}>{item.name}</li>
      ))}
    </ul>
  );
});
// Ne se re-rend que si les props changent (shallow comparison)

7.2 Code Splitting + Suspense

const AdminPanel = lazy(() => import("./AdminPanel"));
const Dashboard = lazy(() => import("./Dashboard"));

function App() {
  return (
    <Suspense fallback={<BigSpinner />}>
      <Routes>
        <Route path="/admin" element={<AdminPanel />} />
        <Route path="/dashboard" element={<Dashboard />} />
      </Routes>
    </Suspense>
  );
}

7.3 useMemo et useCallback — guide pratique

// À utiliser
const sorted = useMemo(() => sortLargeArray(data), [data]); // Calcul coûteux

const handleSubmit = useCallback((e) => {
  formRef.current?.submit(e);
}, []); // Référence stable pour composants memoïsés

// Éviter
const a = useMemo(() => 1, []); // Trop simple
const b = useCallback(() => fn(), []); // useCallback(fn, []) === fn

8. React Server Components (RSC)

Les Server Components (React 18+, Next.js 13+ App Router) s'exécuent sur le serveur et ne sont jamais envoyés au client.

// ServerComponent.server.tsx — s'exécute sur le serveur
async function NoteList({ category }: { category: string }) {
  const notes = await db.query(
    "SELECT * FROM notes WHERE category = ?",
    [category]
  );

  return (
    <ul>
      {notes.map(note => (
        <li key={note.id}>{note.title}</li>
      ))}
    </ul>
  );
}

// ClientComponent.client.tsx — s'exécute sur le client
("use client");
function InteractiveButton({ onClick }: { onClick: () => void }) {
  return <button onClick={onClick}>Click me</button>;
}

9. React 19 Nouveautés

// 1. Actions (formulaires natifs)
function AddTodo() {
  async function addTodo(formData: FormData) {
    "use server";
    const title = formData.get("title");
    await db.todos.create({ title });
  }

  return (
    <form action={addTodo}>
      <input name="title" />
      <button type="submit">Add</button>
    </form>
  );
}

// 2. use() — lecture de promesses et contextes
function Comments({ commentsPromise }: { commentsPromise: Promise<Comment[]> }) {
  const comments = use(commentsPromise);
  return comments.map(c => <Comment key={c.id} {...c} />);
}

// 3. ref comme prop directe
function Input({ ref, ...props }: { ref: Ref<HTMLInputElement> }) {
  return <input ref={ref} {...props} />;
}
// Plus besoin de forwardRef!

10. Error Boundaries

import { Component, ErrorInfo, ReactNode } from "react";

interface ErrorBoundaryProps {
  children: ReactNode;
  fallback?: ReactNode;
}

interface ErrorBoundaryState {
  hasError: boolean;
  error?: Error;
}

class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
  state: ErrorBoundaryState = { hasError: false };

  static getDerivedStateFromError(error: Error): ErrorBoundaryState {
    return { hasError: true, error };
  }

  componentDidCatch(error: Error, info: ErrorInfo) {
    console.error("Uncaught error:", error, info.componentStack);
  }

  render() {
    if (this.state.hasError) {
      return this.props.fallback || <h1>Something went wrong.</h1>;
    }
    return this.props.children;
  }
}

11. Portals

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()}>
        {children}
      </div>
    </div>,
    document.getElementById("portal-root")!
  );
}

12. Anti-patterns et Erreurs des Juniors

// ❌ 1. Modifier l'état directement
const [items, setItems] = useState([1, 2, 3]);
items.push(4); // Mutation directe — ne déclenche pas de re-render
setItems(items); // Ne marche pas (même référence)

// ✅ CORRECT
setItems([...items, 4]);

// ❌ 2. useEffect sans dépendances quand nécessaire
useEffect(() => {
  setCount(count + 1); // Stale closure — count est capturé
}, []); // count devrait être dans les dépendances

// ❌ 3. Trop de useState
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [age, setAge] = useState(0);
// ✅ useReducer ou objet

// ❌ 4. Clés non uniques dans les listes
items.map((item) => <li key={item.index}>{item.name}</li>);
// ✅ Clé stable et unique : key={item.id}

// ❌ 5. setState dans useEffect sans condition
useEffect(() => {
  fetch("/api/data").then(setData);
}, []); // Pas de cleanup — risque de memory leak si le composant est démonté avant la fin

// ✅ CORRECT
useEffect(() => {
  let cancelled = false;
  fetch("/api/data").then(data => {
    if (!cancelled) setData(data);
  });
  return () => { cancelled = true; };
}, []);