Skip to content

6. Chapter 5 - The [React] client for the [RdvMedecins] application

This chapter details, file by file, the contents of the [rdvmedecins-react-client] folder included with this document, using the format adopted throughout this course: source code numbered line by line, followed by a “Let’s Comment on This Code:” section. Each folder in the project also contains its own [README.md].

6.1. Architecture Overview

As in the original document, the client follows a layered architecture, which we will refer to here as V-Services (View-Services):

Image

One difference in terminology is worth noting: in AngularJS and 1.x, the View (the HTML template) and the Controller (the associated JavaScript class) were two separate entities, linked by $scope. In [React], they are combined into a single entity, the component (see previous chapter)—exactly as in [Angular], even though the form differs (a function rather than a class)—which is why, in what follows, we will simply refer to “components” where the original document distinguished between view and controller.

As in the original document, components do not communicate directly with JAMAIS: this role is exclusively reserved for the Services layer (here, the hook useRdvService() and the context AuthContext). This is a design principle that applies equally to AngularJS and 1.x as well as to [Angular] and [React]—it has not changed.

6.2. Project Tree

Image

Image

This breakdown directly follows the flow of the original document (login, doctor/day selection, calendar display, booking), simply organized here into functional components [React] rather than separate controllers/views AngularJS 1.x, or standalone components [Angular]. There are two structural differences compared to the [Angular] directory structure from this course:

  • the root component consists of three files (App.tsx, App.css, README.md) rather than five (app.ts, app.html, app.css, app.config.ts, app.routes.ts): [JSX] merges the class and the template, and [React] does not have an application configuration file equivalent to app.config.ts (see “Application Configuration” below);
  • A `core/context/` folder appears, which is absent from the [Angular] variant: it contains the custom hooks and the [Context] and [React] files that serve as injectable services ([SettingsService], [AuthService], [LanguageService]) of the [Angular] variant—this mechanism (hooks + [Context]) was presented in the previous chapter, along with a generic example (see previous chapter, “Sharing Logic”).

[Bootstrap] 5 is used in all views (imported into main.tsx; see below)—a direct evolution of Bootstrap 3, already used by the original document, and unchanged from the [Angular] variant in this course.

6.3. Project configuration files

Image

6.3.1. package.json

{
  "name": "rdvmedecins-react-client",
  "version": "0.0.0",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "vite",
    "start": "vite",
    "build": "tsc -b && vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "i18next": "^25.0.0",
    "i18next-http-backend": "^3.0.0",
    "react": "^19.1.0",
    "react-dom": "^19.1.0",
    "react-i18next": "^15.0.0",
    "bootstrap": "^5.3.3"
  },
  "devDependencies": {
    "@types/react": "^19.1.0",
    "@types/react-dom": "^19.1.0",
    "@vitejs/plugin-react": "^5.0.0",
    "typescript": "~5.9.0",
    "vite": "^6.4.0"
  }
}

Let’s comment on this code:

  • line 5: "type": "module", — tells Node that this project natively uses the ECMAScript modules (import/export) without any prior transformation—a setting that a [Angular] CLI project also defines, implicitly, in its own toolset;
  • lines 6–11: "scripts": { … } — the available [npm run <nom>] commands. [start] (like `dev`) starts the [Vite] development server with automatic reloading whenever a source file is modified ([Vite] refers to this mechanism as Hot Module Replacement)—this is the command used throughout this document, on port 4200 (see [vite.config.ts] below), the same port as the [Angular] variant in this course;
  • Lines 12–19: "dependencies": { … } — the packages required to run the application in the browser: [react]/[react-dom] (the core of [React] and its integration with the browser’s DOM), [Bootstrap] (style sheet only), and - added in this chapter - [i18next]/[i18next-http-backend]/[react-i18next], the third-party library used for translating the interface FR/EN (see i18n.ts and core/context/language.context.tsx below). Unlike the [Angular] variant of this course, there is no equivalent of [@angular/router] (no router; see the previous chapter), nor of RxJS ([fetch] directly returns a Promise; see the previous chapter);
  • lines 19–25: "devDependencies": { … } — packages needed only during development, never included in the final bundle delivered to the browser: the types TypeScript and [React] ([@types/react], [@types/react-dom] - [React] itself is written in JavaScript, not in TypeScript, unlike [Angular]), [@vitejs/plugin-react] (the transformation of [JSX] into JavaScript, and the Fast Refresh currently under development), TypeScript, and [Vite] itself.

Unlike the [NestJS] server ([commonjs] module, see Chapter 3), this project runs on native ECMAScript modules (import/export)—exactly like the [Angular] variant from this course (“module”: “preserve” in tsconfig.json, see below).

6.3.2. vite.config.ts

1
2
3
4
5
6
7
8
9
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  server: {
    port: 4200,
  },
});

Let’s break down this code:

  • line 5: plugins: [react()], — enables the transformation of [JSX]/[TSX] and hot reloading — the equivalent, for [React], of what @angular/build:dev-server does for [Angular] in the background (see angular.json from the [Angular] variant of this course);
  • lines 6–8: server: { port: 4200, }, — sets the development server port to 4200, the same as [ng serve] on the [Angular] side—a purely educational choice, so that the two variants of this course remain interchangeable without changing your workflow.

This file has no equivalent in the original AngularJS 1.x project (the build tools at the time—Grunt, Gulp—were configured separately); on the [Angular] side of this course, [angular.json] serves this purpose; on the [NestJS] side (Chapter 3), it is [nest-cli.json].

6.3.3. tsconfig.json

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "preserve",
    "jsx": "react-jsx",
    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noFallthroughCasesInSwitch": true,
    "skipLibCheck": true,
    "moduleResolution": "bundler",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "noEmit": true
  },
  "include": ["src"]
}

Let’s break down this code:

  • line 5: "jsx": "react-jsx", — tells the TypeScript compiler how to transform the [JSX]; [react-jsx] is the modern setting (since React 17), which eliminates the need to write [import React from 'react'] in every .tsx file that uses [JSX] - a slight simplification compared to the very first [React] projects;
  • line 6: "strict": true, — like the [tsconfig.json] in the [Angular] variant of this course, TypeScript’s full strict mode is enabled: every variable must have a determinable type, and every null or undefined value must be handled explicitly;
  • lines 7–8: "noUnusedLocals": true, "noUnusedParameters": true, — flags any variable or parameter declared but never used during compilation—a setting that the [Angular] variant of this course does not explicitly enable (the [Angular] compiler has its own, different checks via strictTemplates) ;
  • line 14: "noEmit": true — TypeScript is used here solely for type checking by VÉRIFICATION ([npm run build] first runs `tsc -b`, which fails if a type error exists): it is [Vite] (via esbuild) that actually produces the JavaScript executed by the browser, not the TypeScript compiler itself—a notable difference from the [Angular] (ngc), which itself produces the final JavaScript.

Whereas the [Angular] variant of this course separates [tsconfig.json] (common base) and [tsconfig.app.json] (via the project references mechanism), this smaller [React] project consists of a single file—since the type checks for the templates ([Angular] and strictTemplates) have no equivalent here anyway: it is the TypeScript compiler itself that checks [JSX], just like any other .tsx file.

6.4. Starting the application

Image

6.4.1. index.html

<!doctype html>
<html lang="fr">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <link rel="icon" type="image/x-icon" href="/favicon.ico" />
    <title>RdvMedecins - client React</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>

Let’s comment on this code:

  • line 2: <html lang="fr"> — as with [Angular], since this page (HTML) is static (loaded by the browser AVANT even when the application [React] starts), it cannot immediately reflect a language selection made later by the user;
  • line 10: <div id="root"></div> — the only element this page contains initially, which [React] fills entirely once it starts (see [main.tsx] below) - the exact equivalent of <app-root></app-root> on the [Angular] side, and of [ng-app="rdvmedecinsApp"] on the <html> tag of the original document (AngularJS 1.x, 2014);
  • line 11: <script type="module" src="/src/main.tsx"></script> — first notable difference from the [Angular] variant of this course: it is index.html that directly references main.tsx (a convention specific to [Vite]), rather than the other way around (a pre-compiled JavaScript bundle, injected into the page by the [Angular] build process). During development, [Vite] transforms and serves this .tsx file on the fly; in production (npm run build), it is, of course, compiled beforehand.

As with [Angular], there is only one entry point for the entire application: this is the very definition of a single-page web application (SPA), as already described in the original document.

6.4.2. src/main.tsx

import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { SettingsProvider } from './app/core/context/settings.context';
import { AuthProvider } from './app/core/context/auth.context';
import { LanguageProvider } from './app/core/context/language.context';
import { App } from './app/App';
import 'bootstrap/dist/css/bootstrap.min.css';
import './index.css';
import './i18n';

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <SettingsProvider>
      <AuthProvider>
        <LanguageProvider>
          <App />
        </LanguageProvider>
      </AuthProvider>
    </SettingsProvider>
  </StrictMode>,
);

Let’s comment on this code:

  • line 11: createRoot(document.getElementById('root')!).render(…) — the direct equivalent of bootstrapApplication(App, appConfig) on the [Angular] side: starts the application in the #root element of index.html. The exclamation point (!) tells TypeScript that we are certain this element exists (it is defined on line 10 of [index.html]);
  • lines 13–19: <SettingsProvider><AuthProvider><LanguageProvider><App /></LanguageProvider></AuthProvider></SettingsProvider> — the three providers from core/context/ (see below) wrap the root component [App]: this is what makes useSettings(), useAuth(), and useLangue() usable from any descendant component—the equivalent of the three provideXxx() of [app.config.ts] on the [Angular] side (providers: [...]), but expressed here as a nesting of components rather than as a list;
  • Line 7: import 'bootstrap/dist/css/bootstrap.min.css'; [Vite] allows you to import a stylesheet directly from a TypeScript file; As for [Angular], it is angular.json (styles array: [...]) that serves this purpose—same result, different mechanism;
  • line 9: import './i18n'; — imported solely for its side effect (initializing [i18next]; see i18n.ts below): nothing from this import is used, but executing it is necessary before [useTranslation()] works in the components.

<StrictMode> is a component specific to [React] that affects only development (no effect in production): it helps detect certain poorly written side effects by running certain renderings twice—there is no direct equivalent in [Angular], where the compiler detects these types of errors at compile time instead.

6.4.3. src/i18n.ts

import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import HttpBackend from 'i18next-http-backend';

i18n
  .use(HttpBackend)
  .use(initReactI18next)
  .init({
    lng: 'fr',
    fallbackLng: 'fr',
    interpolation: {
      escapeValue: false,
    },
    backend: {
      loadPath: '/i18n/{{lng}}.json',
    },
  });

export default i18n;

Let’s comment on this code:

  • line 6: .use(HttpBackend) — loads each dictionary via a query HTTP GET, on the URL formed by [backend.loadPath] (line 15): [/i18n/fr.json] or [/i18n/en.json], EXACTEMENT is the same URL as the one constructed by [TranslateHttpLoader] on the [Angular] side (prefix: '/i18n/', suffix: '.json'), so that [public/i18n/fr.json] and [public/i18n/en.json] (see below) can be reused as-is, without any modifications;
  • line 7: .use(initReactI18next) — branches from [i18next] to [React]: provides the useTranslation() hook used in every component in features/, and the React hook [Context] (internal to the library, not visible here), which triggers a full re-render of the entire interface when the language changes—the equivalent of what, on the [Angular] side, the [TranslatePipe] decorator combined with [provideTranslateService(...)];
  • line 9: lng: 'fr', — startup language; the hook [useLangue()] (see core/context/language.context.tsx below) immediately replaces it with the selection stored in localStorage, if there is one—exactly like lang: 'fr' in [app.config.ts] on the [Angular] side;
  • lines 11–13: interpolation: { escapeValue: false, },[React] already escapes HTML itself (protection against XSS vulnerabilities): there’s no need for [i18next] to do it a second time.

In the original document (2014), the client AngularJS 1.x relied on the angular-translate library for this same need—the predecessor, within the AngularJS 1.x ecosystem, of [@ngx-translate/core] (the [Angular] variant from this course) and, here, of [i18next]. The modern DEUX libraries ([ngx-translate] on the [Angular] side, [i18next] on the [React] side) load JSON dictionaries via a simple HTTP GET request, using the same “one file per language, one section per screen” schema.

6.4.4. src/index.css

1
2
3
4
5
6
7
body {
  background-color: #f5f7fa;
}

.creneau-libre:hover {
  background-color: #e9f7ef;
}

Let’s comment on this code:

  • line 1: body { background-color: #f5f7fa; } — copied exactly from src/styles.css on the [Angular] side: a very light gray rather than the default pure white, so that the [Bootstrap] cards (white background, .card) stand out slightly from the page background;
  • lines 5–7: .creneau-libre:hover — applied to the rows in the calendar table representing an available slot (see Agenda.tsx, className={creneauAgenda.rv === null ? 'free-slot': undefined} discussed later): the background turns slightly green on hover. The hand-shaped cursor (cursor: pointer as shown in [Angular]) is applied directly to the “Book” button itself (a true <button>), rather than to the entire row of the table.

This file contains global styles, as opposed to the “local” styles defined in each component’s .css file (login.css, agenda.css…), which apply only to the [JSX] of that specific component—the same principle as in [Angular] (CSS files per component). App.css, the style sheet for the root component, remains empty: [Bootstrap] and index.css are sufficient for everything this document implements.

6.5. The core/models layer

Image

6.5.1. src/app/core/models/rdv.models.ts

export interface Reponse<T> {
  status: number;
  data: T | null;
}

export interface Medecin {
  id: number;
  titre: string;
  nom: string;
  prenom: string;
}

export interface Client {
  id: number;
  titre: string;
  nom: string;
  prenom: string;
}

export interface CreneauJson {
  id: number;
  hDebut: number;
  mDebut: number;
  hFin: number;
  mFin: number;
}

export interface RvJson {
  id: number;
  jour: string;
  client: Client | null;
  creneau: CreneauJson | null;
}

export interface CreneauAgenda {
  creneau: CreneauJson;
  rv: RvJson | null;
}

export interface AgendaMedecinJour {
  medecin: Medecin;
  jour: string;
  creneaux: CreneauAgenda[];
}

export type Role = 'ADMIN' | 'USER';

export interface LoginResultat {
  accessToken: string;
  login: string;
  nom: string;
  role: Role;
}

Let’s comment on this code:

  • This file is a direct copy-and-paste of the file with the same name from the [Angular] variant of this course: The TypeScript interfaces describing the data exchanged with the server do not depend on any interface library, such as [React] or [Angular] - This is “pure” TypeScript, which corresponds term-for-term to the [Reponse]<T> class and the [TypeORM] entities of the [NestJS] server (Chapter 3);
  • [Client] refers here—as it does on the [Angular] side and in the original document—to a PATIENT from the medical practice—not to be confused with “client HTTP”;
  • Role / LoginResultat — certified copies of the server types of the same name (src/entities/user.entity.ts, auth/login-resultat.model.ts, Chapter 3).

In AngularJS 1.x (2014), the pure JavaScript did not allow the expected data format to be described in this way. With TypeScript—used by both [Angular] and [React]—a typo in a field name is flagged even before the program is run, right at compile time.

6.6. The core/context layer

Image

This folder has no equivalent in the [Angular] variant of this course (see the previous chapter, “Sharing Logic”): it combines the three [Context] and [React], which, in this context, fulfill the role played by the injectable services ([SettingsService], [AuthService], and part of [LanguageService]) on the [Angular] side.

6.6.1. src/app/core/context/settings.context.tsx

import { createContext, useContext, useState, type ReactNode } from 'react';

interface SettingsContextValue {
  apiBaseUrl: string;
  setApiBaseUrl: (url: string) => void;
}

const SettingsContext = createContext<SettingsContextValue | null>(null);

export function SettingsProvider({ children }: { children: ReactNode }) {
  const [apiBaseUrl, setApiBaseUrl] = useState('http://localhost:8080');
  return (
    <SettingsContext.Provider value={{ apiBaseUrl, setApiBaseUrl }}>
      {children}
    </SettingsContext.Provider>
  );
}

export function useSettings(): SettingsContextValue {
  const contexte = useContext(SettingsContext);
  if (contexte === null) {
    throw new Error('useSettings() doit être appelé sous un <SettingsProvider>');
  }
  return contexte;
}

Let's comment on this code:

  • line 11: const [apiBaseUrl, setApiBaseUrl] = useState('http://localhost:8080'); — Replaces [readonly apiBaseUrl = signal('http://localhost:8080');] exactly with [SettingsService] on the [Angular] side: the same default value, the same ability to modify it from the “URL from the server” field on the home view of the original document;
  • lines 12–16: <SettingsContext.Provider value={{ apiBaseUrl, setApiBaseUrl }}>{children}</SettingsContext.Provider> — publishes the (value, setter) pair to all descendants, exactly the role that [providedIn: 'root'] played on the [Angular] side;
  • lines 19–25: export function useSettings(): SettingsContextValue { — the hook that components call (const settings = useSettings();), a direct equivalent of inject(SettingsService).

6.6.2. src/app/core/context/auth.context.tsx

Port of [AuthService]: manages the user’s connection, calls [POST /login], stores the received JWT token (and the accompanying identity/role), and ensures it survives a page reload using [localStorage]. This is the only place in the application, along with [useRdvService()] (see below), that communicates with the server via HTTP.

import { createContext, useContext, useMemo, useState, type ReactNode } from 'react';
import type { LoginResultat, Reponse, Role } from '../models/rdv.models';
import { useSettings } from './settings.context';

interface SessionStockee {
  accessToken: string;
  login: string;
  nom: string;
  role: Role;
}

const CLE_STOCKAGE = 'rdvmedecins.session';

function lireSessionStockee(): SessionStockee | null {
  try {
    const brut = localStorage.getItem(CLE_STOCKAGE);
    return brut ? (JSON.parse(brut) as SessionStockee) : null;
  } catch {
    return null;
  }
}

interface AuthContextValue {
  estConnecte: boolean;
  login: string | null;
  nom: string | null;
  role: Role | null;
  estAdmin: boolean;
  accessToken: string | null;
  seConnecter: (login: string, password: string) => Promise<LoginResultat>;
  seDeconnecter: () => void;
}

const AuthContext = createContext<AuthContextValue | null>(null);

export function AuthProvider({ children }: { children: ReactNode }) {
  const { apiBaseUrl } = useSettings();
  const [session, setSession] = useState<SessionStockee | null>(lireSessionStockee);

  async function seConnecter(login: string, password: string): Promise<LoginResultat> {
    const reponseHttp = await fetch(`${apiBaseUrl}/login`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ login, password }),
    });
    const enveloppe = (await reponseHttp.json()) as Reponse<LoginResultat>;
    if (enveloppe.status !== 0 || enveloppe.data === null) {
      throw new Error('Échec de connexion');
    }
    const resultat = enveloppe.data;
    setSession(resultat);
    localStorage.setItem(CLE_STOCKAGE, JSON.stringify(resultat));
    return resultat;
  }

  function seDeconnecter(): void {
    setSession(null);
    localStorage.removeItem(CLE_STOCKAGE);
  }

  const value = useMemo<AuthContextValue>(
    () => ({
      estConnecte: session !== null,
      login: session?.login ?? null,
      nom: session?.nom ?? null,
      role: session?.role ?? null,
      estAdmin: session?.role === 'ADMIN',
      accessToken: session?.accessToken ?? null,
      seConnecter,
      seDeconnecter,
    }),
    [session, apiBaseUrl],
  );

  return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}

export function useAuth(): AuthContextValue {
  const contexte = useContext(AuthContext);
  if (contexte === null) {
    throw new Error('useAuth() doit être appelé sous un <AuthProvider>');
  }
  return contexte;
}

Let’s break down this code:

  • Lines 14–21: function lireSessionStockee(): SessionStockee | null { — protected by a try/catch block, exactly like on the [Angular] side: [localStorage] may be unavailable (very restrictive private browsing) or contain a corrupted value—in this case, we simply assume there is no session;
  • line 35: const [session, setSession] = useState<SessionStockee | null>(lireSessionStockee);[useState(fonction)] executes this function only UNE SEULE times, during the very first rendering (not on every rendering): exactly the role played by the initializer [signal(lireSessionStockee())] on the [Angular] side. If the user reloads the page after logging in, they remain logged in;
  • lines 40–54: async function seConnecter(login: string, password: string): Promise<LoginResultat> { — equivalent to [POST /login, corps JSON { login, password } ;], unlike [AuthService.seConnecter()] on the [Angular] side (which returns an Observable and saves the session in a separate tap() operator), the [fetch]/async-await version naturally chains the steps from top to bottom, without a dedicated operator;
  • lines 56–59: function seDeconnecter(): void { — purely local (no server call): a JWT token is not “revoked” on the server side in this port; it expires on its own after JWT_EXPIRES_IN (see the .env server, Chapter 3);
  • lines 61–73: const value = useMemo<AuthContextValue>(() => ({ … }), [session, apiBaseUrl]);[useMemo]() prevents a new `value` object from being recreated each time [AuthProvider] is rendered when neither [session] nor [apiBaseUrl] has changed: without it, any component using [useAuth()] would be redrawn unnecessarily every time [AuthProvider] is rendered - a performance issue that has no direct equivalent on the [Angular] side, where `computed()` only recalculates what actually depends on a modified signal anyway.

6.6.3. src/app/core/context/language.context.tsx

Port of part of [LanguageService]: centralizes the interface language switch (French/English) on top of the [useTranslation()] hook provided by [react-i18next].

// src/app/core/context/language.context.tsx

import { createContext, useContext, useEffect, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';

const CLE_STOCKAGE = 'rdvmedecins.langue';

export type Langue = 'fr' | 'en';

interface LanguageContextValue {
  langueCourante: Langue;
  changerLangue: (langue: Langue) => void;
}

const LanguageContext = createContext<LanguageContextValue | null>(null);

export function LanguageProvider({ children }: { children: ReactNode }) {
  const { i18n } = useTranslation();

  useEffect(() => {
    const langueMemorisee = localStorage.getItem(CLE_STOCKAGE) as Langue | null;
    if (langueMemorisee && langueMemorisee !== i18n.language) {
      i18n.changeLanguage(langueMemorisee);
      document.documentElement.lang = langueMemorisee;
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  function changerLangue(langue: Langue): void {
    localStorage.setItem(CLE_STOCKAGE, langue);
    i18n.changeLanguage(langue);
    document.documentElement.lang = langue;
  }

  const value: LanguageContextValue = {
    langueCourante: i18n.language as Langue,
    changerLangue,
  };

  return <LanguageContext.Provider value={value}>{children}</LanguageContext.Provider>;
}

export function useLangue(): LanguageContextValue {
  const contexte = useContext(LanguageContext);
  if (contexte === null) {
    throw new Error('useLangue() doit être appelé sous un <LanguageProvider>');
  }
  return contexte;
}

Let’s comment on this code:

  • line 18: const { i18n } = useTranslation(); [react-i18next] already exposes the i18n instance (and its current language, i18n.language) via this hook: there’s no need to create a separate state for it, unlike with [SettingsContext]/[AuthContext]; this is the same concept as with [Angular], where LanguageService simply republishes this.translate.currentLang under a different name;
  • lines 20–27: useEffect(() => { … }, []); — an empty dependencies array ([]) means “execute this effect only once, when the component is mounted”—the direct equivalent of the constructor() in [LanguageService] on the [Angular] side: if the user had already selected a language during a previous visit, it is restored; otherwise, the default language set in [i18n.ts] (French) is used;
  • lines 29–33: function changerLangue(language: Language): void { — called by the two buttons FR/EN on the navigation bar (see [App.tsx] below); [i18n.changeLanguage(...)] loads (if necessary) the corresponding [public/i18n/<langue>.json] file, then switches the current language—unlike [translate.use(langue)] on the [Angular] side (an Observable, see previous chapter)—here, the code chooses not to wait for the Promise (it resolves very quickly, since the file is cached after the very first load);
  • Line 32: document.documentElement.lang = language; — updates the <html lang="..."> attribute of the page (useful for accessibility), which neither library handles on its own since [index.html] is a static page loaded even before the application starts—exactly the same requirement and the same solution as for [Angular].

Equivalent to the “FR / EN” button in the original AngularJS 1.x client, which at the time relied on the [angular-translate] library —the predecessor, within the AngularJS 1.x ecosystem, of [ngx-translate] ([Angular]) and then [i18next] ([React]).

6.7. Translation dictionaries (public/i18n/)

Image

[i18next-http-backend] (configured in i18n.ts, see above) loads one of these two files via a [HTTP GET /i18n/<langue>.json] request whenever the language changes (or is used for the first time). [react-i18next] then resolves each key ("LOGIN.TITLE", "AGENDA.FREE"…) via the [useTranslation() (fonction t(...))] hook used in all components in the features/ directory (see later in this chapter).

6.7.1. public/i18n/fr.json

{
  "APP": {
    "TITLE": "RdvMedecins - portage NestJS / React",
    "SERVER_URL_LABEL": "URL du serveur",
    "LOGOUT": "Se déconnecter"
  },
  "LOGIN": {
    "TITLE": "Connexion",
    "LOGIN_LABEL": "Login",
    "...": "..."
  },
  "DOCTOR_DAY_PICKER": { "...": "..." },
  "AGENDA": { "...": "..." },
  "BOOKING_DIALOG": { "...": "..." }
}

Let’s comment on this code:

  • This file is taken as-is from the [Angular] variant of this course (same keys, same texts), with one exception: line 3, ["TITLE"], which now refers to [React] instead of [Angular]—the only difference between the two clients that actually affects text displayed to the user;
  • a nested JSON object, with one section per component (APP, LOGIN, DOCTOR_DAY_PICKER, AGENDA, BOOKING_DIALOG); [i18next] flattens it into a flat dictionary, where each complete key ("LOGIN.TITLE") is formed by concatenating the path with dots—exactly like [TranslateService] next to [Angular].

6.7.2. public/i18n/en.json

1
2
3
4
5
6
{
  "APP": {
    "TITLE": "RdvMedecins - NestJS / React port",
    "...": "..."
  }
}

Let’s break down this code:

  • line 3: "TITLE": "RdvMedecins - NestJS / React port", — only the subtitle changes: “[RdvMedecins]” remains unchanged in both languages; it is the application’s proper name.

Both files must remain structurally identical (same keys in both): this ensures that a key exists in both languages. Whatever is translated in PAS remains unchanged compared to the [Angular] variant of this course: error messages returned by the server [NestJS] remain in French regardless of the language selected on the client side, and the names of doctors and patients come directly from the database.

6.8. The core/interceptors layer

Image

6.8.1. src/app/core/interceptors/auth.interceptor.ts

[React], being merely an interface library, does not have a built-in interceptor concept like HTTP, as found in [HttpClient]/withInterceptors ([...]) on the [Angular] side. This file reimplements the same concept as a hook that encapsulates [fetch]: this is the modern equivalent of the $http interceptors from AngularJS and 1.x, which were already discussed in the original document (chapter “Example 6”).

import { useAuth } from '../context/auth.context';

export function useAuthFetch() {
  const auth = useAuth();

  return async function authFetch(input: RequestInfo | URL, init: RequestInit = {}): Promise<Response> {
    const headers = new Headers(init.headers);
    if (auth.accessToken) {
      headers.set('Authorization', `Bearer ${auth.accessToken}`);
    }
    const reponse = await fetch(input, { ...init, headers });
    if (reponse.status === 401) {
      auth.seDeconnecter();
    }
    return reponse;
  };
}

Let’s break down this code:

  • line 3: export function useAuthFetch() { — unlike [authInterceptor] on the [Angular] side (a function registered once and for all in [app.config.ts], which is then automatically applied to every call to [HttpClient]), [useAuthFetch()] is a hook that each caller (here, useRdvService(), see below) must explicitly call and use in place of fetch()—a difference in mechanism between the two libraries, not in final behavior;
  • Lines 6–10: if (auth.accessToken) { headers.set('Authorization', …); } — the direct equivalent of [request.clone({ setHeaders: { Authorization: … } })] on the [Angular] side. Unlike a [HttpClient] request (which is immutable), the options for [fetch] are simply a JavaScript object: there’s no need to create a “cloned” copy of it; you just need to construct the [headers] object before the call;
  • lines 12–14: if (reponse.status === 401) { auth.seDeconnecter(); } — same principle as on the [Angular] side (catchError + HttpErrorResponse.status === 401): a 401 during a session means the token is no longer valid, so the client is gracefully logged out; here, [fetch] does not reject its Promise with a HTTP status as an error (unlike an Observable [HttpClient])—which is why this test targets [reponse.status] directly, without a try/catch block;
  • line 15: return response; — the response (potentially 401) is nevertheless returned as-is to the caller, who remains responsible for handling it (see useRdvService(), extract<T>(), below) — the interceptor must not hide the error from the calling code.

We could manually add [Authorization: Bearer ...] to each of the methods in [useRdvService()]. A shared hook avoids this repetition, offering the same benefit as a [Angular] interceptor: the question “How do we authenticate a request?” is answered in a single location.

6.9. The core/services layer

Image

6.9.1. src/app/core/services/rdv.service.ts

Porting of [RdvService]: the part of the application (SEUL, along with [core/context/auth.context.tsx] for the connection itself) that communicates with the server (HTTP and [NestJS]). This is the direct equivalent of the [dao] service presented in the chapter “Example 6: the HTTP services” in the original document (which used the AngularJS and $http services).

import { useCallback } from 'react';
import { useAuthFetch } from '../interceptors/auth.interceptor';
import { useSettings } from '../context/settings.context';
import type { AgendaMedecinJour, Client, Medecin, Reponse, RvJson } from '../models/rdv.models';

async function extraire<T>(reponseHttp: Response): Promise<T> {
  const enveloppe = (await reponseHttp.json()) as Reponse<T>;
  if (enveloppe.status !== 0) {
    throw new Error(`Le serveur a répondu avec le statut d'erreur ${enveloppe.status}`);
  }
  return enveloppe.data as T;
}

export function useRdvService() {
  const authFetch = useAuthFetch();
  const { apiBaseUrl } = useSettings();

  const getAllMedecins = useCallback(async (): Promise<Medecin[]> => {
    const reponseHttp = await authFetch(`${apiBaseUrl}/getAllMedecins`);
    return extraire<Medecin[]>(reponseHttp);
  }, [authFetch, apiBaseUrl]);

  const getAllClients = useCallback(async (): Promise<Client[]> => {
    const reponseHttp = await authFetch(`${apiBaseUrl}/getAllClients`);
    return extraire<Client[]>(reponseHttp);
  }, [authFetch, apiBaseUrl]);

  const getAgendaMedecinJour = useCallback(
    async (idMedecin: number, jour: string): Promise<AgendaMedecinJour> => {
      const reponseHttp = await authFetch(
        `${apiBaseUrl}/getAgendaMedecinJour/${idMedecin}/${jour}`,
      );
      return extraire<AgendaMedecinJour>(reponseHttp);
    },
    [authFetch, apiBaseUrl],
  );

  const ajouterRv = useCallback(
    async (jour: string, idClient: number, idCreneau: number): Promise<RvJson> => {
      const reponseHttp = await authFetch(`${apiBaseUrl}/ajouterRv`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ jour, idClient, idCreneau }),
      });
      return extraire<RvJson>(reponseHttp);
    },
    [authFetch, apiBaseUrl],
  );

  const supprimerRv = useCallback(
    async (idRv: number): Promise<void> => {
      const reponseHttp = await authFetch(`${apiBaseUrl}/supprimerRv`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ idRv }),
      });
      await extraire<null>(reponseHttp);
    },
    [authFetch, apiBaseUrl],
  );

  return { getAllMedecins, getAllClients, getAgendaMedecinJour, ajouterRv, supprimerRv };
}

Let’s comment on this code:

  • line 6: async function extract<T>(reponseHttp: Response): Promise<T> { — rather than repeating, in each of the public functions, the check “if status !== 0, it’s an error,” we centralize it here just once—exactly the role of [RdvService.extraire()] on the [Angular] side, but declared outside the hook (a regular function, so it doesn’t need to be recreated on every render);
  • line 14: export function useRdvService() { — a custom hook that reads and is used exactly like [inject(RdvService)] on the [Angular] side: it’s called once at the top of a component, then the functions it returns are used;
  • lines 18–21: const getAllMedecins = useCallback(async () => { … }, [authFetch, apiBaseUrl]);[useCallback]() caches this function between renders, as long as [authFetch] and [[apiBaseUrl]] do not change: this optimization is necessary here because this hook is called every time the component that uses it is rendered (App.tsx)—without it, a new function [getAllMedecins] would be recreated on every render, which would unnecessarily trigger certain effects again (useEffect(…, [rdv]), see App.tsx below); this technical detail has no equivalent on the [Angular] side, where [RdvService] exists only once for the entire application (providedIn: 'root');
  • each function corresponds exactly to one of the eleven routes in the [NestJS] controller (Chapter 3)—in fact, the same names appear on both sides (getAllMedecins, ajouterRv, supprimerRv…), which makes it easier to compare the server and client sides, just as with [Angular].

Unlike [AuthService.seConnecter()] (previous chapter), none of the methods in this hook need to store anything after the call: it is the calling component (App.tsx) that decides what to do with the result (setMedecins(...), setAgenda(...)…) - the same separation of responsibilities as on the [Angular] side, where [RdvService] knows nothing about the state displayed on the screen.

6.10. The features layer: the four components

Image

6.10.1. src/app/features/login/Login.tsx

Port of [login.component.ts/.html]: the login screen, equivalent to view [login.html] from the original client AngularJS 1.x.

import { useState, type FormEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { useAuth } from '../../core/context/auth.context';
import './login.css';

export function Login() {
  const { t } = useTranslation();
  const auth = useAuth();

  const [login, setLogin] = useState('');
  const [password, setPassword] = useState('');
  const [enCours, setEnCours] = useState(false);
  const [erreur, setErreur] = useState<string | null>(null);

  async function onValider(evenement?: FormEvent): Promise<void> {
    evenement?.preventDefault();
    if (login.trim() === '' || password === '') {
      return;
    }
    setErreur(null);
    setEnCours(true);
    try {
      await auth.seConnecter(login, password);
      setEnCours(false);
    } catch {
      setEnCours(false);
      setErreur('LOGIN.ERROR');
    }
  }

  return (
    <div className="row justify-content-center">
      <div className="col-sm-8 col-md-6 col-lg-4">
        <form className="card p-4" onSubmit={onValider}>
          <h5 className="card-title mb-3">{t('LOGIN.TITLE')}</h5>
          {erreur && (
            <div className="alert alert-danger py-2" role="alert">
              {t(erreur)}
            </div>
          )}
          <div className="mb-3">
            <label className="form-label" htmlFor="input-login">
              {t('LOGIN.LOGIN_LABEL')}
            </label>
            <input id="input-login" type="text" className="form-control"
              value={login} onChange={(e) => setLogin(e.target.value)} />
          </div>
          <div className="mb-3">
            <label className="form-label" htmlFor="input-password">
              {t('LOGIN.PASSWORD_LABEL')}
            </label>
            <input id="input-password" type="password" className="form-control"
              value={password} onChange={(e) => setPassword(e.target.value)} />
          </div>
          <button type="submit" className="btn btn-primary w-100" disabled={enCours}>
            {enCours ? t('LOGIN.SUBMITTING') : t('LOGIN.SUBMIT')}
          </button>
        </form>
      </div>
    </div>
  );
}

Let’s comment on this code:

  • line 6: export function Login() { — a standalone component [Angular] declares, within its decorator [@Component], the pipes it uses (imports: [TranslatePipe]); a [React] component has nothing equivalent to declare: [useTranslation()]] (line 7) is simply imported and called, just like any other function;
  • lines 10–13: const [login, setLogin] = useState(''); … — a state that is purely local to the form (what the user is currently typing), equivalent to [signal()] local variables of the [Angular] component;
  • lines 15–16: async function onValider(event?: FormEvent): Promise<void> { event?.preventDefault();a deliberate difference from the [Angular] variant in this course: rather than listening (keyup.enter) to each field separately, this component uses a real <form> element (line 32) and its native [onSubmit] event—both the Enter key and a click on the “Log In” button (type="submit") then trigger [onValider()], without any additional code; [evenement?.preventDefault()] prevents the browser’s default behavior (reloading the page), which would violate the very principle of a Single Page Application;
  • lines 23–24: await auth.seConnecter(login, password); setEnCours(false); — no further processing is needed here: [AuthContext] has already stored the session (state + localStorage)—it is App.tsx that calls useAuth().estConnecte, which will respond by stopping the display of <Login />;
  • lines 24–27: catch { … setErreur('LOGIN.ERROR'); } — an invalid username/password pair causes [seConnecter()] to fail with a JavaScript exception (thrown by [auth.context.tsx], see above): "LOGIN.ERROR" is displayed to the user, without attempting to distinguish between a nonexistent username and an incorrect password—exactly the same approach as in [Angular]. Storing a translation key rather than an already-resolved text has the same advantage as in [Angular]: if the user switches languages while this message is displayed, it translates itself automatically (t(error), line 37, re-evaluated on each render).

This component combines in the UN and SEUL .tsx files what [Angular] splits into two (login.component.ts for the logic, login.component.html for the template): the [JSX] returned by the Login() function EST the template, combined with the code TypeScript that drives it. Therefore, there is no separate .html file to comment on, unlike the equivalent chapter for the [Angular] variant of this course.

6.10.2. src/app/features/doctor-day-picker/DoctorDayPicker.tsx

Port of [doctor-day-picker.component.ts/.html]: the component that allows you to select a doctor and a day, then request to view their schedule. Conceptually equivalent to examples 7 through 10 of the original AngularJS 1.x client.

import { useState, type ChangeEvent } from 'react';
import { useTranslation } from 'react-i18next';
import type { Medecin } from '../../core/models/rdv.models';
import './doctor-day-picker.css';

interface DoctorDayPickerProps {
  medecins: Medecin[];
  onRechercher: (criteres: { idMedecin: number; jour: string }) => void;
}

export function DoctorDayPicker({ medecins, onRechercher }: DoctorDayPickerProps) {
  const { t } = useTranslation();
  const [idMedecinSelectionne, setIdMedecinSelectionne] = useState<number | null>(null);
  const [jourSelectionne, setJourSelectionne] = useState<string>(
    new Date().toISOString().slice(0, 10),
  );

  function onChangementMedecin(evenement: ChangeEvent<HTMLSelectElement>): void {
    const valeur = evenement.target.value;
    setIdMedecinSelectionne(valeur === '' ? null : Number(valeur));
  }

  function onClicRechercher(): void {
    if (idMedecinSelectionne === null) {
      return;
    }
    onRechercher({ idMedecin: idMedecinSelectionne, jour: jourSelectionne });
  }

  return (
    <div className="card p-3 mb-3">
      <div className="row g-2 align-items-end">
        <div className="col-sm-5">
          <label className="form-label" htmlFor="select-medecin">
            {t('DOCTOR_DAY_PICKER.DOCTOR_LABEL')}
          </label>
          <select id="select-medecin" className="form-select" onChange={onChangementMedecin}>
            <option value="">{t('DOCTOR_DAY_PICKER.CHOOSE_DOCTOR')}</option>
            {medecins.map((medecin) => (
              <option key={medecin.id} value={medecin.id}>
                {medecin.titre} {medecin.prenom} {medecin.nom}
              </option>
            ))}
          </select>
        </div>
        <div className="col-sm-4">
          <input id="input-jour" type="date" className="form-control"
            value={jourSelectionne} onChange={(e) => setJourSelectionne(e.target.value)} />
        </div>
        <div className="col-sm-3">
          <button type="button" className="btn btn-primary w-100"
            disabled={idMedecinSelectionne === null} onClick={onClicRechercher}>
            {t('DOCTOR_DAY_PICKER.VIEW_AGENDA')}
          </button>
        </div>
      </div>
    </div>
  );
}

Let’s comment on this code:

  • line 11: export function DoctorDayPicker({ doctors, onRechercher }: DoctorDayPickerProps) { — a [input.required<Medecin[]>()] [Angular] simply becomes a field of the `props` object (`medecins`); an `output<...>()` becomes a prop that contains a callback function provided by the parent `[(onRechercher)]`—`[App.tsx]` will call it exactly as it subscribed to the `search` event of the `[Angular]` template;
  • lines 34–38: {medecins.map((doctor) => ( <option key={medecin.id} …>…</option> ))} — .map(...), with [key={medecin.id}], is the [React] equivalent of [@for (medecin of medecins(); track medecin.id)] on the [Angular] side: in both cases, a stable identifier specifies how to recognize a doctor that has already been displayed if it is redrawn;
  • line 37: onChange={onChangementMedecin} — unlike the “login” field of [Login.tsx] (which uses [onChange] with each keystroke), a <select> only notifies when the selection actually changes—the same event HTML [change] that was already being used by [(change)="onChangementMedecin($event)] on the [Angular] side.

6.10.3. src/app/features/agenda/Agenda.tsx

Port of [agenda.component.ts/.html]: displays a doctor’s schedule for a given day. Equivalent to examples 8 and 9 from the original AngularJS 1.x client. Since the addition of authentication, this component also accepts [peutModifier]: when set to false (role USER), the “Book”/“Cancel” buttons disappear—the schedule remains viewable, but in read-only mode.

import { useTranslation } from 'react-i18next';
import type { AgendaMedecinJour, CreneauAgenda, CreneauJson, RvJson } from '../../core/models/rdv.models';
import './agenda.css';

interface AgendaProps {
  agenda: AgendaMedecinJour | null;
  peutModifier: boolean;
  onReserver: (creneau: CreneauJson) => void;
  onAnnuler: (rv: RvJson) => void;
}

function formaterHeure(h: number, m: number): string {
  return `${h}:${m.toString().padStart(2, '0')}`;
}

export function Agenda({ agenda, peutModifier, onReserver, onAnnuler }: AgendaProps) {
  const { t } = useTranslation();

  function onClicCreneau(creneauAgenda: CreneauAgenda): void {
    if (!peutModifier) {
      return;
    }
    if (creneauAgenda.rv === null) {
      onReserver(creneauAgenda.creneau);
    } else {
      onAnnuler(creneauAgenda.rv);
    }
  }

  if (agenda === null) {
    return <p className="text-muted">{t('AGENDA.EMPTY_STATE')}</p>;
  }

  return (
    <div className="card p-3">
      <h5>
        {t('AGENDA.TITLE_PREFIX')} {agenda.jour} - {agenda.medecin.titre} {agenda.medecin.prenom} {agenda.medecin.nom}
      </h5>
      <table className="table table-hover align-middle">
        <thead>
          <tr><th>{t('AGENDA.COLUMN_SLOT')}</th><th>{t('AGENDA.COLUMN_STATUS')}</th><th></th></tr>
        </thead>
        <tbody>
          {agenda.creneaux.map((creneauAgenda) => (
            <tr key={creneauAgenda.creneau.id}
              className={creneauAgenda.rv === null ? 'creneau-libre' : undefined}>
              <td>
                {formaterHeure(creneauAgenda.creneau.hDebut, creneauAgenda.creneau.mDebut)} -{' '}
                {formaterHeure(creneauAgenda.creneau.hFin, creneauAgenda.creneau.mFin)}
              </td>
              <td>
                {creneauAgenda.rv === null ? (
                  <span className="badge text-bg-success">{t('AGENDA.FREE')}</span>
                ) : (
                  <span className="badge text-bg-secondary">
                    {creneauAgenda.rv.client?.titre} {creneauAgenda.rv.client?.prenom} {creneauAgenda.rv.client?.nom}
                  </span>
                )}
              </td>
              <td>
                {peutModifier && (
                  <button type="button" className={'btn btn-sm ' +
                    (creneauAgenda.rv === null ? 'btn-outline-success' : 'btn-outline-danger')}
                    onClick={() => onClicCreneau(creneauAgenda)}>
                    {creneauAgenda.rv === null ? t('AGENDA.BOOK') : t('AGENDA.CANCEL_APPOINTMENT')}
                  </button>
                )}
              </td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}

Let’s comment on this code:

  • lines 30–32: if (agenda === null) { return <p …>{t('AGENDA.EMPTY_STATE')}</p>; } — an early return replaces [@if (agenda(); as monAgenda) { … } @else { … }] on the [Angular] side: a component function [React] can return any [JSX] at any point in its body, including before the end—here, as long as no search has been performed (or the agenda is empty), the function stops after displaying the empty status message, without even constructing the array;
  • line 7: peutModifier: boolean; — a new feature introduced by authentication, similar to the [Angular] side: [App.tsx] passes [auth.estAdmin] to it (see below). When set to false (role USER), the “Reserve”/“ Cancel” buttons do not even exist in DOM (see line 61, [{peutModifier && (…)}])—they are not merely grayed out or disabled, but entirely absent;
  • lines 46–56: {creneauAgenda.rv === null ? ( <span …>Available</span> ) : ( <span …>{patient name}</span> )} — the ternary operator replaces [@if (…) { … } @else { … }] on the [Angular] side: green [Bootstrap] badge (“Available”/“ Free”) or gray (patient’s name), depending on the slot’s status;
  • line 61: {peutModifier && (…)} — the && operator displays its second operand only if the first is true (and nothing at all otherwise)—the shortest form of conditional rendering in [JSX], used here instead of the ternary operator because there is nothing to display otherwise;
  • line 65: {creneauAgenda.rv === null ? t('AGENDA.BOOK' : t('AGENDA.CANCEL_APPOINTMENT')} — as on the [Angular] side (where a pipe can be used in any template expression), t(...) is a standard JavaScript function: it can be used directly in a ternary operator without any special syntax.

This component does not make any HTTP calls itself: it displays data received via props and notifies its parent (App.tsx) of the user’s intentions via [onReserver/onAnnuler]—exactly the same separation of responsibilities as on the [Angular] side (book/cancel, via output()).

6.10.4. src/app/features/booking-dialog/BookingDialog.tsx

Port of [booking-dialog.component.ts/.html]: the (modal) window that allows you to select a patient to book an available slot. Equivalent to Example 9 of the original AngularJS 1.x client.

import { useState, type ChangeEvent } from 'react';
import { useTranslation } from 'react-i18next';
import type { Client, CreneauJson } from '../../core/models/rdv.models';
import './booking-dialog.css';

interface BookingDialogProps {
  ouvert: boolean;
  creneau: CreneauJson | null;
  clients: Client[];
  onConfirmer: (choix: { idClient: number }) => void;
  onFermer: () => void;
}

export function BookingDialog({ ouvert, creneau, clients, onConfirmer, onFermer }: BookingDialogProps) {
  const { t } = useTranslation();
  const [idClientSelectionne, setIdClientSelectionne] = useState<number | null>(null);

  if (!ouvert) {
    return null;
  }

  function onChangementClient(evenement: ChangeEvent<HTMLSelectElement>): void {
    const valeur = evenement.target.value;
    setIdClientSelectionne(valeur === '' ? null : Number(valeur));
  }

  function onClicConfirmer(): void {
    if (idClientSelectionne === null) {
      return;
    }
    onConfirmer({ idClient: idClientSelectionne });
    setIdClientSelectionne(null);
  }

  return (
    <>
      <div className="modal-backdrop fade show"></div>
      <div className="modal fade show d-block" tabIndex={-1} role="dialog" aria-modal="true">
        <div className="modal-dialog modal-dialog-centered">
          <div className="modal-content">
            <div className="modal-header">
              <h5 className="modal-title">{t('BOOKING_DIALOG.TITLE')}</h5>
              <button type="button" className="btn-close"
                aria-label={t('BOOKING_DIALOG.CLOSE_ARIA')} onClick={onFermer}></button>
            </div>
            <div className="modal-body">
              {creneau && (
                <p>
                  {t('BOOKING_DIALOG.SLOT_FROM')} {creneau.hDebut}:{creneau.mDebut.toString().padStart(2, '0')}{' '}
                  {t('BOOKING_DIALOG.SLOT_TO')} {creneau.hFin}:{creneau.mFin.toString().padStart(2, '0')}
                </p>
              )}
              <select id="select-client" className="form-select" onChange={onChangementClient}>
                <option value="">{t('BOOKING_DIALOG.CHOOSE_PATIENT')}</option>
                {clients.map((client) => (
                  <option key={client.id} value={client.id}>
                    {client.titre} {client.prenom} {client.nom}
                  </option>
                ))}
              </select>
            </div>
            <div className="modal-footer">
              <button type="button" className="btn btn-secondary" onClick={onFermer}>
                {t('BOOKING_DIALOG.CANCEL')}
              </button>
              <button type="button" className="btn btn-primary"
                disabled={idClientSelectionne === null} onClick={onClicConfirmer}>
                {t('BOOKING_DIALOG.CONFIRM')}
              </button>
            </div>
          </div>
        </div>
      </div>
    </>
  );
}

Let’s comment on this code:

  • lines 18–20: if (!open) { return null; } — a [React] component can return null to display nothing at all: this is the exact equivalent of [@if (ouvert()) { … }] on the [Angular] side (nothing is even loaded into the DOM as long as `ouvert` is false), and of [ngIf/ngShow], AngularJS, and 1.x;
  • Line 37: <div className="modal-backdrop fade show"></div> — the semi-transparent background that darkens the rest of the page—hard-coded here, whereas [Bootstrap] usually inserts it itself via JavaScript when the modal is displayed;
  • line 38: <div className="modal fade show d-block" …> — d-block replaces the role normally filled by JavaScript from [Bootstrap] (add [display:block] upon opening)—here, it is directly the conditional rendering from line 17 ([if (!ouvert) return null;]) that plays this role;
  • line 44: aria-label={t('BOOKING_DIALOG.CLOSE_ARIA')} — unlike [attr.aria-label="'BOOKING_DIALOG.CLOSE_ARIA' | translate"] on the [Angular] side (an attribute binding distinct from the {{ }} interpolation), [JSX] makes no distinction between a HTML attribute and the content of an element: {expression} is used identically in both places.

Like the [Angular] side, this component uses the VRAIES classes of a modal, but its visibility is controlled by [React] (a conditional rendering based on the [ouvert] property) rather than by the JavaScript of [Bootstrap] ([bootstrap.bundle.js, new bootstrap.Modal(...)])—to prevent [Bootstrap] and [React] from each independently managing the same information (is the modal open?), a common source of subtle bugs. The original document (2014) used a component from the angular-ui-bootstrap library (a “turnkey” modal AngularJS 1.x), which was already designed according to this same principle.

6.11. The root App component: orchestrator and authentication guardian

Image

This component fulfills the role played by the “main controller” of the original AngularJS 1.x application, and by the App component on the [Angular] side: it maintains the application’s global state and responds to events from the features/ components to call [useRdvService()] at the right time. Since authentication was added, it has taken on a second role: that of a “gatekeeper” on the presentation layer, which decides whether to display the login screen or the rest of the application. A [React] component combines into a single .tsx file what [Angular] splits into three ([app.ts, app.html, app.css]): the [JSX] returned by App(), EST, and the template ([App.css] is imported below solely as a side effect).

6.11.1. src/app/App.tsx

import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useAuth } from './core/context/auth.context';
import { useSettings } from './core/context/settings.context';
import { useLangue } from './core/context/language.context';
import { useRdvService } from './core/services/rdv.service';
import type { AgendaMedecinJour, Client, CreneauJson, Medecin, RvJson } from './core/models/rdv.models';
import { DoctorDayPicker } from './features/doctor-day-picker/DoctorDayPicker';
import { Agenda } from './features/agenda/Agenda';
import { BookingDialog } from './features/booking-dialog/BookingDialog';
import { Login } from './features/login/Login';
import './App.css';

export function App() {
  const { t } = useTranslation();
  const auth = useAuth();
  const settings = useSettings();
  const langue = useLangue();
  const rdv = useRdvService();

  const [medecins, setMedecins] = useState<Medecin[]>([]);
  const [clients, setClients] = useState<Client[]>([]);
  const [agenda, setAgenda] = useState<AgendaMedecinJour | null>(null);
  const [erreur, setErreur] = useState<string | null>(null);
  const [creneauEnReservation, setCreneauEnReservation] = useState<CreneauJson | null>(null);

  const dernierIdMedecin = useRef<number | null>(null);
  const dernierJour = useRef<string | null>(null);

  useEffect(() => {
    if (auth.estConnecte) {
      chargerListesInitiales();
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [auth.estConnecte]);

  function chargerListesInitiales(): void {
    rdv.getAllMedecins().then(setMedecins)
      .catch((err: unknown) => setErreur(String((err as Error).message ?? err)));
    rdv.getAllClients().then(setClients)
      .catch((err: unknown) => setErreur(String((err as Error).message ?? err)));
  }

  function onDeconnexion(): void {
    auth.seDeconnecter();
    setMedecins([]); setClients([]); setAgenda(null);
    setErreur(null); setCreneauEnReservation(null);
    dernierIdMedecin.current = null; dernierJour.current = null;
  }

  function onRechercherAgenda(criteres: { idMedecin: number; jour: string }): void {
    setErreur(null);
    dernierIdMedecin.current = criteres.idMedecin;
    dernierJour.current = criteres.jour;
    chargerAgenda();
  }

  function onDemandeReservation(creneau: CreneauJson): void {
    setCreneauEnReservation(creneau);
  }

  function onConfirmerReservation(choix: { idClient: number }): void {
    if (creneauEnReservation === null || dernierJour.current === null) {
      return;
    }
    rdv.ajouterRv(dernierJour.current, choix.idClient, creneauEnReservation.id)
      .then(() => { setCreneauEnReservation(null); chargerAgenda(); })
      .catch((err: unknown) => setErreur(String((err as Error).message ?? err)));
  }

  function onAnnulerRv(rv: RvJson): void {
    rdv.supprimerRv(rv.id).then(() => chargerAgenda())
      .catch((err: unknown) => setErreur(String((err as Error).message ?? err)));
  }

  function chargerAgenda(): void {
    if (dernierIdMedecin.current === null || dernierJour.current === null) {
      return;
    }
    rdv.getAgendaMedecinJour(dernierIdMedecin.current, dernierJour.current).then(setAgenda)
      .catch((err: unknown) => setErreur(String((err as Error).message ?? err)));
  }

  return (
    <div className="container py-4">
      <nav className="navbar navbar-expand-sm navbar-dark bg-primary rounded mb-4 px-3">
        <span className="navbar-brand mb-0">{t('APP.TITLE')}</span>
        <div className="d-flex align-items-center gap-2">
          <div className="btn-group btn-group-sm" role="group" aria-label="FR / EN">
            <button type="button" onClick={() => langue.changerLangue('fr')}
              className={'btn ' + (langue.langueCourante === 'fr' ? 'btn-light' : 'btn-outline-light')}>FR</button>
            <button type="button" onClick={() => langue.changerLangue('en')}
              className={'btn ' + (langue.langueCourante === 'en' ? 'btn-light' : 'btn-outline-light')}>EN</button>
          </div>
          {auth.estConnecte && (
            <>
              <span className="badge text-bg-light text-primary">{auth.role}</span>
              <span className="text-white">{auth.nom}</span>
              <button type="button" className="btn btn-sm btn-outline-light" onClick={onDeconnexion}>
                {t('APP.LOGOUT')}
              </button>
            </>
          )}
        </div>
      </nav>

      <div className="row g-2 align-items-center mb-4">
        <div className="col-auto">
          <label className="form-label mb-0" htmlFor="input-url-serveur">{t('APP.SERVER_URL_LABEL')}</label>
        </div>
        <div className="col-sm-4">
          <input id="input-url-serveur" type="text" className="form-control form-control-sm"
            defaultValue={settings.apiBaseUrl} onChange={(e) => settings.setApiBaseUrl(e.target.value)} />
        </div>
      </div>

      {erreur && <div className="alert alert-danger" role="alert">{erreur}</div>}

      {!auth.estConnecte ? (
        <Login />
      ) : (
        <>
          <DoctorDayPicker medecins={medecins} onRechercher={onRechercherAgenda} />
          <Agenda agenda={agenda} peutModifier={auth.estAdmin}
            onReserver={onDemandeReservation} onAnnuler={onAnnulerRv} />
          <BookingDialog ouvert={creneauEnReservation !== null} creneau={creneauEnReservation}
            clients={clients} onConfirmer={onConfirmerReservation} onFermer={() => setCreneauEnReservation(null)} />
        </>
      )}
    </div>
  );
}

Let’s comment on this code:

  • lines 27–28: const dernierIdMedecin = useRef<number | null>(null); const dernierJour = useRef<string | null>(null); — we store the last search (doctor + day) so we can refresh the calendar after a booking or cancellation. A [useRef](), not a [useState](): these two values are never used directly for display (unlike “calendar” or “error”), so there’s no need to redraw the component when they change—exactly the same reasoning that, on the [Angular] side, kept [dernierIdMedecin/dernierJour] as simple private fields rather than signals; Modifying a [useRef]() (.current = …) does not redraw the component, unlike a [setXxx()] from [useState]();
  • lines 30–35: useEffect(() => { if (auth.estConnecte) { chargerListesInitiales(); } }, [auth.estConnecte]); — re-executes its body every time [auth.estConnecte] changes—the direct equivalent of [effect(() => { if (this.auth.estConnecte()) { … } })] on the [Angular] side. It also runs once when the component is mounted: if a session was already stored in [localStorage] (page reload), the lists load immediately upon startup, without waiting for a login;
  • lines 89–94: <div className="btn-group btn-group-sm" …> …FR…EN… </div> — the language selector is visible (including on the login screen); the role badge, name, and logout button, however, are only displayed if [auth.estConnecte] (lines 97–104, [{auth.estConnecte && (…)})]—exactly the same logic as on the [Angular] and [(@if (auth.estConnecte()) { … })] sides;
  • lines 119–130: {!auth.estConnecte ? ( <Login /> ): ( <>…</> )} — as long as [useAuth().estConnecte] equals false, this component displays only <Login />; since all server routes are now protected by [JwtAuthGuard] (Chapter 3), it would be pointless anyway (and cause 401 errors) to load doctors/clients/calendar before logging in—exactly the same reasoning as for [Angular];
  • lines 123–127: <DoctorDayPicker doctors={doctors} onRechercher={onRechercherAgenda} /> … — the three “business logic” components from features/ are assembled here, each receiving its props (data + callback functions) — the exact equivalent of the [app.html] template on the [Angular] side, where the same three components appeared with [medecins]="medecins()" ([rechercher)="onRechercherAgenda($event)"]….

State ([useState/useRef]), effect ([useEffect]), and event handlers (the onXxx functions): these are exactly the four components already presented in the previous chapter, applied here to the entire root component—nothing new has been introduced in this file; it has simply been assembled.

6.12. Step-by-Step Guide to Using the Application

The following screenshots were obtained using exactly the same sequence of actions as the [Angular] variant of this course, on this [React] client: The server’s URL setting was left at its default value (http://localhost:8080), with demo accounts admin/admin and user/user.

6.12.1. 1. Login screen

When the page first loads, [useAuth().estConnecte] is equal to false (no session in localStorage): only <Login /> is displayed.

Image

Login screen

An attempt with an invalid username/password combination displays the error message stored by [Login.tsx]:

Image

Login error

The FR/EN selector, which is always visible, also translates this screen (and the stored error message, which is retranslated without additional code; see [Login.tsx] above):

Image

Login screen in English

6.12.2. 2. Logging in as an administrator (role ADMIN, full access)

Once logged in with admin/admin, [App.tsx] stops displaying <Login /> and loads doctors/clients ([chargerListesInitiales()], triggered by [useEffect(…, [auth.estConnecte])] on line 29):

Image

Home, once logged in via ADMIN

Image

Home page, after logging in via ADMIN, in English

After selecting a doctor and a date in <DoctorDayPicker />, the calendar is displayed with the “Book”/“Cancel” buttons (peutModifier is set to true for role ADMIN):

Image

Calendar, view ADMIN (Book/Cancel buttons visible)

Clicking “Reserve” opens <BookingDialog /> ([ouvert] changes to true; see [onDemandeReservation] in [App.tsx]):

Image

Reservation window (Bootstrap modal)

After confirmation ([onConfirmerReservation] calls [rdv.ajouterRv(...)], then [chargerAgenda()] refreshes the display), the time slot appears as booked:

Image

Calendar after booking

Clicking “Cancel” ([onAnnulerRv] calls [rdv.supprimerRv(...))]) makes the time slot available again:

Image

Calendar after cancellation

6.12.3. 3. Log in as a user (role USER, read-only)

After logging out (onDeconnexion), logging in with the user/user account displays a different badge: “USER”:

Image

Home page, once logged in as USER

The calendar remains viewable, but no “Reserve”/“Cancel” buttons appear: the actions column is empty for each time slot (see [Agenda.tsx, {peutModifier && (…)}]). A direct request to the server (for example, using Postman and providing the user’s token) would still receive a 403 Forbidden response—the hiding of the buttons is merely a display convenience; the actual restriction is enforced by [RolesGuard] on the server side (Chapter 3):

Image

Calendar, USER view (read-only, no action buttons)

6.13. What remains out of scope or deferred

The “debug” mode (display of the raw template for the current view) of the original AngularJS 1.x client is a feature deliberately omitted from this port, just as in the [Angular] variant of this course—it will not appear in a later stage of the course (see Chapter 6, Conclusion, for the rationale behind this decision). The setting of an artificial network delay, however, is indeed deferred to a later stage in both variants.

Two additional differences, specific to this [React] port, are worth mentioning here:

  • unit tests (which are absent from this project as well as from the [Angular] project in this course) would follow a different approach in [React]: rather than TestBed (specific to [Angular]), the [React] ecosystem typically relies on [React Testing Library]—not presented in this document;
  • type checking for templates ([Angular] strictTemplates, see previous chapter) has no equivalent that needs to be configured separately on the [React] side: it is the TypeScript compiler itself that checks the [JSX], just like any other .tsx file—a simplification of the configuration, at the cost, in theory, of slightly less deep integration between the template language and the type system (in practice, on this project, no observable difference).