Skip to content

8. Communicating with the server

The examples in this chapter query the [RdvMedecins] JSON server: It must be running ([npm run start:dev] in the [rdvmedecins-nestjs-json] folder) on port 8080, at the same time as the [Vite] development server on port 5173.

8.1. Example 22: A first HTTP request

8.1.1. The development server’s proxy

The page in this example comes from http://localhost:5173 (the [Vite] development server); API is located at http://localhost:8080. To a browser, these are two different origins (the port is part of the origin). However, for security reasons, a browser does not allow a page to read responses from another origin unless the server explicitly permits it (the mechanism known as CORS). And a request to another origin does not carry cookies (and therefore not the token), unless the code requests it ([credentials: 'include']) AND the server accepts it, again via CORS.

Rather than opening the server to other origins, we route API requests through the [Vite] development server. This is the proxy declared in the [vite.config.ts] file in the examples (see Example 01):

1
2
3
    proxy: {
      '/api': 'http://localhost:8080',
    },

The browser sends a request to [GET http://localhost:5173/api/captcha]; the development server forwards the request to [http://localhost:8080/api/captcha], then sends the response back to the browser. From the browser’s perspective, the page and API have the same origin:

Image

In production, there will no longer be a development server: the JSON server itself will serve the compiled client files (see the “Deployment” chapter). The page and API will still have the same origin.

8.1.2. The request

[React] does not have its own HTTP client: the browser’s [fetch] function is used, or a library ([axios]...). [fetch] is more than sufficient.

// =========================================================================
// 22-fetch/src/App.tsx - a first HTTP request to the server
// -------------------------------------------------------------------------
// The RdvMedecins JSON server must be started (port 8080). We ask it for
// a captcha: GET /api/captcha -> { "id": "...", "svg": "<svg ...>" }.
//
//   - fetch(url): the browser function that sends an HTTP request.
//     It returns a PROMISE of the response; response.json() a promise
//     of the body, converted from JSON. React has no HTTP client of its own;
//   - the address is RELATIVE (/api/captcha): the request goes to the
//     server of the page, Vite's (port 5173), which passes it on to the
//     JSON server (port 8080) thanks to its proxy (cf. vite.config.ts);
//   - three states to display: in progress, succeeded, failed.
// =========================================================================

import { useEffect, useState } from 'react';

// what GET /api/captcha returns
interface Captcha {
  id: string;
  svg: string;
}

const captchaStyle = { display: 'inline-block', border: '1px solid #dee2e6', borderRadius: '0.375rem' };

export default function App() {
  const [captcha, setCaptcha] = useState<Captcha | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);
  const [requestCount, setRequestCount] = useState(0);
  // each click on "Another image" changes this number, which restarts the effect
  const [demand, setDemand] = useState(0);

  useEffect(() => {
    // AbortController: the means to CANCEL a request in progress
    const cancellation = new AbortController();
    setLoading(true);
    setError(null);
    fetch('/api/captcha', { signal: cancellation.signal })
      .then(async (response) => {
        // fetch does NOT reject an error response (4xx, 5xx): ok is tested
        if (!response.ok) {
          throw new Error(`the server answered ${response.status} ${response.statusText}`);
        }
        const c = (await response.json()) as Captcha;
        setCaptcha(c);
        setRequestCount((n) => n + 1);
        setLoading(false);
      })
      .catch((e: Error) => {
        if (e.name === 'AbortError') return; // request cancelled: nothing to display
        setError(e.message);
        setCaptcha(null);
        setLoading(false);
      });
    // the cleanup CANCELS the request if the effect is restarted or if the
    // component disappears before the response. In development, StrictMode
    // executes the effect twice at startup: the first request is
    // cancelled (Network tab: "canceled"), only the second one counts.
    return () => cancellation.abort();
  }, [demand]);

  return (
    <div className="container py-4" style={{ maxWidth: 640 }}>
      <h1 className="h3 mb-3">Example 22 - a first HTTP request</h1>
      <div className="card p-3">
        {loading && <p className="text-muted">Loading...</p>}
        {error && (
          <div className="alert alert-danger mb-3">
            Unable to get a captcha: {error}.<br />
            Is the RdvMedecins JSON server started (port 8080)?
          </div>
        )}
        {captcha && !loading && (
          <>
            {/* dangerouslySetInnerHTML: inserting a string as HTML */}
            <div className="mb-2"><div style={captchaStyle} dangerouslySetInnerHTML={{ __html: captcha.svg }} /></div>
            <p className="small text-muted">id: <code>{captcha.id}</code></p>
          </>
        )}
        <button className="btn btn-primary align-self-start" disabled={loading} onClick={() => setDemand((d) => d + 1)}>
          Another image
        </button>
        <p className="small text-muted mt-2 mb-0">
          {requestCount} successful request(s) - open the Network tab of the developer tools (F12).
        </p>
      </div>
    </div>
  );
}
  • lines 19–22: what [GET /api/captcha] returns;
  • lines 27–30: three states to display: in progress, successful (the CAPTCHA), error;
  • line 32: a request number: each click on “Another image” increments it, which triggers the effect again;
  • lines 34–61: the request is an effect: it synchronizes the component with the server, upon initial display and then with each new request:
    • line 36: an [AbortController] allows you to cancel a pending request: its [signal] is passed to [fetch];
    • line 39: [fetch(url)] sends a request (GET) and returns a promise for the response. The address is RELATIVE: the request is sent to the page’s server, [Vite], which forwards it to the JSON server;
    • lines 42–44: WARNING: [fetch] does NOT reject if it receives an error response (4xx, 5xx); its promise is rejected only if NO response arrives (network outage). Therefore, [response.ok] is checked (true for a 2xx code);
    • Line 45: [response.json()] reads the body and converts it from JSON (another promise). [as Captcha]: We tell [TypeScript] the format of the received data; it cannot verify this;
    • lines 50–55: an error; [AbortError]: the request was canceled; there is nothing to display;
    • Line 60: The cleanup CANCELS the request if the effect is re-triggered or if the component disappears before the response. In development, [<StrictMode>] runs the effect twice at startup: the first request is canceled (the Network tab shows it as “canceled”); only the second one counts;
  • line 77: the image is a snippet of SVG, a string to be inserted like HTML. [{captcha.svg}] would display the text [<svg ...>], escaped. [dangerouslySetInnerHTML] inserts the string as-is: its name warns of the danger. Use it ONLY for safe content: this SVG is generated by OUR server. With user input, this would open the door to script injection;
  • Line 81: a new request; the effect is triggered again.

Image

If the JSON server is down, the [Vite] proxy returns a 500 error: “Unable to get a captcha: the server answered 500 Internal Server Error.”

// =========================================================================
// 23-login/src/App.tsx - logging in to the RdvMedecins server
// -------------------------------------------------------------------------
//   1. at startup: GET /api/auth/me - are we already logged in?
//      (after an F5, the client has forgotten everything; the server reads the cookie);
//   2. login: POST /api/auth/login, JSON body { login, password,
//      captchaId, captcha }. On success, the server sets the httpOnly
//      cookie of the JWT token: our code NEVER sees it (try
//      document.cookie in the console: it is not there);
//   3. protected request: GET /api/doctors. The browser attaches the cookie
//      to it on its own (same origin);
//   4. logout: POST /api/auth/logout - the server deletes the cookie.
//
// The form uses the ACTIONS of React 19: <form action={function}>.
// Instead of handling onSubmit and each field, we receive all the values
// of the form (FormData), and useActionState keeps the result of the
// last action (here, an error message) and tells whether it is in progress.
//
// A cookie belongs to a host (localhost) and to a path (/api): it
// is therefore common to ALL the examples. Once logged in here, examples
// 24 and 25 are too.
// =========================================================================

import { useActionState, useEffect, useState } from 'react';
import { useFormStatus } from 'react-dom';

interface User {
  login: string;
  name: string;
  role: 'ADMIN' | 'DOCTOR' | 'USER';
}
interface Doctor {
  id: number;
  title: string;
  firstName: string;
  lastName: string;
}
interface Captcha {
  id: string;
  svg: string;
}
// the shape of ALL the errors of the API
interface ApiError {
  statusCode: number;
  key: string;
  params?: Record<string, string>;
}

// the few messages this example needs (example 25 will use i18next)
const MESSAGES: Record<string, string> = {
  'LOGIN.ERROR': 'Incorrect login or password.',
  'ERRORS.WRONG_CAPTCHA': 'The characters you typed do not match the image.',
  'ERRORS.TOO_MANY_ATTEMPTS': 'Too many failed attempts. Try again later.',
};

// a GET request, whose JSON response is typed; an error if it is not a 2xx
async function read<T>(url: string): Promise<T> {
  const response = await fetch(url);
  if (!response.ok) {
    throw response; // the response itself is "thrown": its status will tell what to do
  }
  return (await response.json()) as T;
}

// the submit button: useFormStatus() tells whether the form THAT CONTAINS IT
// is being sent (it must therefore be a separate component, placed inside)
function LoginButton() {
  const { pending } = useFormStatus();
  return <button className="btn btn-primary" disabled={pending}>{pending ? 'Logging in...' : 'Log in'}</button>;
}

// the result of the last login attempt
interface LoginState {
  error: string | null;
  login: string; // the typed login, to display it again
}

export default function App() {
  const [user, setUser] = useState<User | null>(null);
  const [doctors, setDoctors] = useState<Doctor[]>([]);
  const [captcha, setCaptcha] = useState<Captcha | null>(null);
  const [ready, setReady] = useState(false); // is the initial check over?
  const [serverError, setServerError] = useState<string | null>(null);

  async function loadCaptcha() {
    setCaptcha(await read<Captcha>('/api/captcha'));
  }

  async function loadDoctors() {
    setDoctors(await read<Doctor[]>('/api/doctors'));
  }

  // 1. who is logged in? 200 -> the user; 401 -> nobody
  useEffect(() => {
    async function checkSession() {
      try {
        setUser(await read<User>('/api/auth/me'));
        await loadDoctors();
      } catch (e) {
        if (!(e instanceof Response) || e.status !== 401) {
          setServerError('Is the RdvMedecins JSON server started (port 8080)?');
        } else {
          await loadCaptcha();
        }
      }
      setReady(true);
    }
    checkSession();
  }, []);

  // 2. the login: an ACTION, which receives the previous state and the
  // data of the form, and returns the new state
  async function logIn(_previous: LoginState, data: FormData): Promise<LoginState> {
    const login = String(data.get('login'));
    const response = await fetch('/api/auth/login', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' }, // the body is JSON
      body: JSON.stringify({ login, password: data.get('password'), captchaId: captcha?.id, captcha: data.get('captcha') }),
    });
    if (!response.ok) {
      // the JSON body of the error response
      const error = (await response.json().catch(() => null)) as ApiError | null;
      const key = error?.key ?? 'ERRORS.SERVER_UNREACHABLE';
      await loadCaptcha(); // each image is only used once
      return { error: `${response.status} - ${MESSAGES[key] ?? key}`, login };
    }
    setUser((await response.json()) as User);
    await loadDoctors();
    return { error: null, login };
  }
  const [state, loginAction] = useActionState(logIn, { error: null, login: 'admin' });

  // 4. the logout
  async function logOut() {
    await fetch('/api/auth/logout', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
    setUser(null);
    setDoctors([]);
    await loadCaptcha();
  }

  let content;
  if (!ready) {
    content = <p className="text-muted">Checking the session...</p>;
  } else if (user) {
    // logged in
    content = (
      <div className="card p-3">
        <p>
          Logged in: <strong>{user.name}</strong> ({user.login}), role{' '}
          <span className="badge text-bg-primary">{user.role}</span>
        </p>
        <h2 className="h6">GET /api/doctors</h2>
        <ul>
          {doctors.map((m) => <li key={m.id}>{m.title} {m.firstName} {m.lastName}</li>)}
        </ul>
        <p className="small text-muted">Reload the page (F5): you remain logged in.</p>
        <button className="btn btn-outline-secondary align-self-start" onClick={logOut}>Log out</button>
      </div>
    );
  } else if (!serverError) {
    // not logged in: the form. The fields are NOT controlled: name
    // designates them in FormData; after each action, React resets them to
    // their initial value (defaultValue)
    content = (
      <form className="card p-3" action={loginAction}>
        <div className="mb-2">
          <label className="form-label" htmlFor="login">Login</label>
          <input id="login" name="login" className="form-control" autoComplete="username" defaultValue={state.login} />
        </div>
        <div className="mb-2">
          <label className="form-label" htmlFor="password">Password</label>
          <input id="password" name="password" type="password" className="form-control" autoComplete="current-password" />
        </div>
        <div className="mb-3">
          <label className="form-label" htmlFor="captcha">Type the characters of the image</label>
          <div className="d-flex align-items-center gap-3 mb-2">
            <div dangerouslySetInnerHTML={{ __html: captcha?.svg ?? '' }} />
            <button type="button" className="btn btn-link btn-sm" onClick={loadCaptcha}>Another image</button>
          </div>
          <input id="captcha" name="captcha" className="form-control" autoComplete="off" />
        </div>
        <LoginButton />
        <p className="small text-muted mt-2 mb-0">Accounts: admin, pelissier, martin... (password = login).</p>
        {state.error && <div className="alert alert-danger mt-3 mb-0">{state.error}</div>}
      </form>
    );
  }

  return (
    <div className="container py-4" style={{ maxWidth: 640 }}>
      <h1 className="h3 mb-3">Example 23 - logging in to the server</h1>
      {content}
      {serverError && <div className="alert alert-danger mt-3">{serverError}</div>}
    </div>
  );
}

The flow (lines 4–12): Upon startup, the server is queried to determine which one is connected; then comes the connection, a secure request, and logout.

  • Lines 27–47: a few API types, including the shape of ALL the errors;
  • lines 50–54: the few messages this example needs to translate the server’s keys (Example 25 will use [i18next]);
  • lines 57–63: a small generic function for GET requests: the JSON response, with a type; and, if it is not a 2xx, we “trigger” the response itself: its status will indicate what to do;
  • lines 67–70: the form submission button. [useFormStatus()] (from [react-dom]) indicates whether the form CONTAINING IT is currently being submitted: so this must be a separate component placed within the form;
  • Lines 94–109: On startup, [GET /api/auth/me]: Are we already logged in? After a F5, the client has forgotten everything; the server, however, reads the cookie. 200: the user; 401: no one (a CAPTCHA is then loaded); anything else: the server does not respond. A [async] function cannot be the effect itself (an effect returns its cleanup, not a promise): we declare it within the effect, then call it;
  • lines 113–130: the connection, written as an action of [React] 19 (see below). It receives the previous state and the form data ([FormData]), and returns the new state:
    • line 114: [data.get(name)] reads a field from the form, identified by its attribute [name];
    • lines 115–119: [POST /api/auth/login], with a JSON body: [method], the header [Content-Type], and the body, generated by [JSON.stringify]. If successful, the server sets the [httpOnly] cookie from the JWT token: our code NEVER sees it (try [document.cookie] in the console: it’s not there);
    • lines 120–126: a failure: the body of the error response, JSON, provides the message key; each CAPTCHA image is used only once: another one is loaded;
    • lines 127–129: a success: the user, followed by a protected request, [GET /api/doctors]: the browser automatically includes the cookie (same origin);
  • line 131: [useActionState(action, initialState)] ([React] 19) returns the status generated by the last execution of the action, and a version of the action to be passed to the form;
  • lines 134–139: logout; [POST /api/auth/logout]: the server deletes the cookie. Every POST must have a JSON body (the server rejects others; see the previous chapter): ['{}'];
  • line 165: [<form action={function}>]: starting with [React] 19, a form’s [action] attribute can accept a function. Upon submission, [React] prevents the browser from submitting the form, consolidates the fields into a [FormData], and calls the function, during which [useFormStatus] displays “in progress.” [onSubmit], [preventDefault], and a per-field status are no longer needed;
  • Lines 168–180: The fields are not controlled: they are identified by their [name]. After each action, [React] resets the form: each field reverts to its initial value ([defaultValue]). The password and CAPTCHA fields are therefore cleared; the login field reverts to the value stored in the state by the action ([state.login]), that is, the one that was just entered;
  • line 184: the error message generated by the last action.

A cookie belongs to a machine (localhost) and a path ([/api]): it is therefore shared by ALL the examples. Once logged in here, examples 24 and 25 are also logged in.

Image

An incorrect password: the login is retained, and a new image is displayed:

Image

Logged in as [admin]; you can reload the page (F5): you remain logged in:

Image

8.3. Example 24: An API access layer

Example 23 repeated the same steps for each request: prefix [/api], JSON header, check for [response.ok], read the error body... Examples 24 and 25 group these together in the [common/] folder, and the case study will follow this same approach.

8.3.1. The types

// =========================================================================
// common/types.ts - the JSON objects exchanged with the RdvMedecins API
// (cf. the chapter on the black box, which describes each route)
// =========================================================================

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

// POST /api/auth/login, GET /api/auth/me
export interface User {
  login: string;
  name: string;
  role: Role;
}

// GET /api/captcha
export interface Captcha {
  id: string;
  svg: string;
}

// a doctor or a client (GET /api/doctors, GET /api/clients...)
export interface Person {
  id: number;
  version: number;
  title: string;
  firstName: string;
  lastName: string;
  login?: string; // only for the ADMIN
}

export interface Identity {
  title: string;
  firstName: string;
  lastName: string;
}

// a row of GET /api/schedule
export interface ScheduleSlot {
  id: number;
  startHour: number;
  startMinute: number;
  endHour: number;
  endMinute: number;
  free: boolean;
  patient: Identity | null;
  mine: boolean;
  bookable: boolean;
  appointmentId: number | null;
}

// GET /api/schedule?doctorId=...&day=...
export interface Schedule {
  doctor: Person;
  day: string;
  slots: ScheduleSlot[];
  info: string | null;
}

The translation, in [TypeScript] interfaces, of the API described in the previous chapter. A line in the calendar ([ScheduleSlot]) describes a time slot AND what the logged-in user is authorized to do with it.

8.3.2. Errors

// =========================================================================
// common/api-error.ts - ONE class for all the errors of the API
// -------------------------------------------------------------------------
// All the error responses of the RdvMedecins server have the same shape:
//   { "statusCode": 409, "key": "ERRORS.LOGIN_TAKEN", "params": {...}, "fields": {...} }
// But a request can also fail OTHERWISE: no response at all
// (network down: fetch throws a TypeError), or a response that does
// not come from our server (an error page of Vite's proxy when the
// JSON server is stopped). All this is converted into ONE class,
// ApiError, which carries the translation key sent by the server (and,
// if any, its parameters and the error of each field).
// =========================================================================

export class ApiError extends Error {
  readonly statusCode: number; // 0: the server did not respond
  readonly key: string; // e.g.: 'ERRORS.LOGIN_TAKEN'
  readonly params: Record<string, string>;
  readonly fields: Record<string, string>;

  constructor(statusCode: number, key: string, params: Record<string, string> = {}, fields: Record<string, string> = {}) {
    super(key);
    this.name = 'ApiError';
    this.statusCode = statusCode;
    this.key = key;
    this.params = params;
    this.fields = fields;
  }
}

// an error response (4xx, 5xx) -> an ApiError
export async function fromResponse(response: Response): Promise<ApiError> {
  // the JSON body of the error, if it exists and has the right shape
  let body: { key?: string; params?: Record<string, string>; fields?: Record<string, string> } = {};
  try {
    const json: unknown = await response.json();
    if (typeof json === 'object' && json !== null) {
      body = json;
    }
  } catch {
    // not JSON: it is not our server that answered
  }
  // no key: the proxy or the network answered in its place
  const key = body.key ?? (response.status >= 500 ? 'ERRORS.SERVER_UNREACHABLE' : 'ERRORS.SERVER_ERROR');
  return new ApiError(response.status, key, body.params, body.fields);
}
  • Lines 14–28: ONE class for all API errors: the HTTP status (0: no response), the translation key sent by the server, its parameters, and the error for each field in a rejected form. It inherits from [Error]: it can be launched ([throw]) and recognized ([instanceof ApiError]). The “property parameters” of [TypeScript] ([constructor(public readonly key: string)]) are not used: the [erasableSyntaxOnly] option of [tsconfig.json] disables them, because they generate code;
  • Lines 31–45: An error response becomes an [ApiError]. The body is not always from our server’s JSON: when the JSON server is down, the [Vite] proxy responds without a JSON body. No key: a status code of 500 or higher means “server unreachable.”

8.3.3. Requests

// =========================================================================
// common/api.ts - requests to the API, in one line
// -------------------------------------------------------------------------
// In example 23, each request repeated the same gestures: /api
// prefix, JSON header, test of response.ok, reading of the error body... They
// are grouped here:
//
//   const doctors = await api.get<Person[]>('/doctors');
//   await api.post('/appointments', { day, slotId, clientId });
//
//   - on success, the promise returns the JSON body, TYPED;
//   - on failure, it is REJECTED with an ApiError.
// All the requests go through the request function: it is the place
// where a problem COMMON to all of them would be handled (cf. the case study and the
// 401 response). Used by examples 24 and 25.
// =========================================================================

import { fromResponse, ApiError } from './api-error';

export async function request<T>(method: string, url: string, body?: unknown): Promise<T> {
  let response: Response;
  try {
    response = await fetch(`/api${url}`, {
      method,
      // a JSON body (the server rejects a POST that is not)
      headers: body === undefined ? {} : { 'Content-Type': 'application/json' },
      body: body === undefined ? undefined : JSON.stringify(body),
    });
  } catch {
    // fetch got NO response (network, server stopped)
    throw new ApiError(0, 'ERRORS.SERVER_UNREACHABLE');
  }
  if (!response.ok) {
    throw await fromResponse(response);
  }
  return (await response.json()) as T;
}

export const api = {
  get: <T>(url: string) => request<T>('GET', url),
  // a POST always has a JSON body: {} by default
  post: <T>(url: string, body: unknown = {}) => request<T>('POST', url, body),
  put: <T>(url: string, body: unknown) => request<T>('PUT', url, body),
  delete: <T>(url: string, body?: unknown) => request<T>('DELETE', url, body),
};
  • lines 20–37: ALL requests pass through this generic function:
    • lines 23–28: the prefix [/api], and, if there is a body, the header and JSON;
    • lines 29–32: no response: an [ApiError] with status 0;
    • lines 33–35: an error response: an [ApiError];
    • line 36: a success: the body, typed;
  • lines 39–45: shortcuts, a function per HTTP method. A POST always has a JSON body: [{}] by default.

This is where a COMMON issue would be handled for all requests: the case study will address the 401 response (session expired) here.

8.3.4. A login bar

// =========================================================================
// common/LoginBar.tsx - login / logout, in a bar
// -------------------------------------------------------------------------
// The form of example 23, reduced to a bar and rewritten with api.ts.
// The component calls onUserChange with the logged-in user (or
// null) each time it changes - including at startup, after
// having queried GET /api/auth/me.
// =========================================================================

import { useEffect, useEffectEvent, useState, type FormEvent } from 'react';
import { api } from './api';
import { ApiError } from './api-error';
import type { Captcha, User } from './types';
import './LoginBar.css';

interface Props {
  onUserChange: (user: User | null) => void;
}

export default function LoginBar({ onUserChange }: Props) {
  const [user, setUser] = useState<User | null>(null);
  const [captcha, setCaptcha] = useState<Captcha | null>(null);
  const [login, setLogin] = useState('admin');
  const [password, setPassword] = useState('');
  const [captchaAnswer, setCaptchaAnswer] = useState('');
  const [error, setError] = useState<string | null>(null);

  function changeUser(u: User | null) {
    setUser(u);
    onUserChange(u);
  }

  async function newCaptcha() {
    setCaptcha(await api.get<Captcha>('/captcha'));
    setCaptchaAnswer('');
  }

  // at startup: are we already logged in?
  const start = useEffectEvent(async () => {
    try {
      changeUser(await api.get<User>('/auth/me'));
    } catch {
      changeUser(null); // 401: nobody is logged in
      await newCaptcha().catch(() => setError('Is the JSON server started (port 8080)?'));
    }
  });
  useEffect(() => {
    start();
  }, []);

  async function logIn(e: FormEvent<HTMLFormElement>) {
    e.preventDefault();
    setError(null);
    try {
      const u = await api.post<User>('/auth/login', { login, password, captchaId: captcha?.id, captcha: captchaAnswer });
      setPassword('');
      changeUser(u);
    } catch (err) {
      setError(err instanceof ApiError ? `${err.statusCode} ${err.key}` : String(err));
      await newCaptcha();
    }
  }

  async function logOut() {
    await api.post('/auth/logout');
    changeUser(null);
    await newCaptcha();
  }

  return (
    <div className="alert alert-secondary d-flex flex-wrap align-items-center gap-2 py-2">
      {user ? (
        <>
          <span>
            Logged in: <strong>{user.name}</strong> <span className="badge text-bg-primary ms-1">{user.role}</span>
          </span>
          <button className="btn btn-sm btn-outline-secondary ms-auto" onClick={logOut}>Log out</button>
        </>
      ) : (
        <form className="d-flex flex-wrap align-items-center gap-2 w-100" onSubmit={logIn}>
          <input className="form-control form-control-sm w-auto" placeholder="login" autoComplete="username" value={login}
                 onChange={(e) => setLogin(e.target.value)} />
          <input type="password" className="form-control form-control-sm w-auto" placeholder="password"
                 autoComplete="current-password" value={password} onChange={(e) => setPassword(e.target.value)} />
          {/* the SVG image of the captcha, built by OUR server (cf. example 22) */}
          <div className="captcha-bar" dangerouslySetInnerHTML={{ __html: captcha?.svg ?? '' }} />
          <input className="form-control form-control-sm w-auto" placeholder="captcha" autoComplete="off" size={8}
                 value={captchaAnswer} onChange={(e) => setCaptchaAnswer(e.target.value)} />
          <button className="btn btn-sm btn-primary">Log in</button>
          {error && <span className="text-danger small">{error}</span>}
        </form>
      )}
    </div>
  );
}

The form from Example 23, reduced to a bar and rewritten using [api.ts] (and with validated fields, for comparison):

  • line 17: the component notifies its parent whenever the logged-in user changes;
  • lines 39–46 and 47–49: on startup, [GET /api/auth/me]. The function called by the effect notifies the parent: [useEffectEvent] avoids making this a dependency (see Example 13);
  • line 55: a one-line request; if it fails, an [ApiError];
  • line 86: the CAPTCHA image, scaled to the height of the bar using a small stylesheet:
1
2
3
4
5
6
7
8
/* the SVG image of the captcha, inserted as is into the page: it is reduced
   to the height of the bar. An imported style sheet applies to the
   WHOLE page: the .captcha-bar class limits it to our element. */
.captcha-bar svg {
  display: block;
  height: 32px;
  width: auto;
}

8.3.5. The access layer to the API

// =========================================================================
// 24-api-layer/src/rdvmedecins.ts - the API access layer
// -------------------------------------------------------------------------
// One typed function per route of the API. The components know neither
// the URLs, nor fetch, nor the JSON format: they call functions.
// It is the client-side equivalent of the DAO layer on the server side: if the API
// changes (a URL, a field name), only this file is modified.
// =========================================================================

import { api } from '../../common/api';
import type { Schedule, Person } from '../../common/types';

export const rdvmedecins = {
  getDoctors: () => api.get<Person[]>('/doctors'),
  getClients: () => api.get<Person[]>('/clients'),
  // URLSearchParams builds "doctorId=1&day=2030-01-15", encoding what needs to be
  getSchedule: (doctorId: number, day: string) =>
    api.get<Schedule>(`/schedule?${new URLSearchParams({ doctorId: String(doctorId), day })}`),
  book: (day: string, slotId: number, clientId?: number) =>
    api.post<{ id: number }>('/appointments', { day, slotId, clientId }),
  cancelAppointment: (appointmentId: number) => api.delete<Record<string, never>>(`/appointments/${appointmentId}`),
};

A function per route of the API, typed. The components do not recognize URL, [fetch], or the JSON format: they call functions. This is the client-side equivalent of the DAO layer on the server side: if the API changes (a URL, a field name), only this file is modified. [URLSearchParams] (line 18) generates the [doctorId=1&day=2030-01-15] portion of an address by encoding the necessary data.

8.3.6. TanStack Query

Reading data from a server into an effect, as in Example 22, requires handling everything: the “in progress” status, errors, stale responses, cancellations, duplicate requests sent twice, and data already received that could be reused... [TanStack Query] (formerly [React Query]), the most widely used library for this purpose, handles all of this.

// =========================================================================
// 24-api-layer/src/main.tsx
// -------------------------------------------------------------------------
// TanStack Query (formerly React Query): the most used library
// to READ data from a server in a React application.
// The QueryClient keeps in memory (in "cache") the responses already received;
// <QueryClientProvider> provides it to the whole application (a context,
// cf. example 14).
// =========================================================================

import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import 'bootstrap/dist/css/bootstrap.min.css';
import App from './App';

const queryClient = new QueryClient({
  // on failure, no new attempt: we want to see the error
  defaultOptions: { queries: { retry: false } },
});

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <QueryClientProvider client={queryClient}>
      <App />
    </QueryClientProvider>
  </StrictMode>,
);
  • line 17: the [TanStack Query] client, which stores previously received responses in memory (in the “cache”); [retry: false]: in case of failure, no retry (by default, it attempts three);
  • line 24: it is provided to the entire application via a context (see Example 14).

8.3.7. The page

// =========================================================================
// 24-api-layer/src/App.tsx - a schedule, with the API access layer
// -------------------------------------------------------------------------
// Log in (top bar), choose a doctor and a day: the schedule
// is displayed. ADMIN and DOCTOR can book for a patient and cancel;
// a patient (martin...) books for themselves. Each action ends with
// a reload of the schedule: it is the SERVER that is authoritative.
//
// useQuery({ queryKey, queryFn }): a READING of data, described by
//   - queryFn: the function that gets them (a promise);
//   - queryKey: what IDENTIFIES these data: ['schedule', doctorId, day].
// TanStack Query calls queryFn at the start, then each time the key
// CHANGES (another doctor, another day). It returns an object: data (the
// response), isPending, isFetching, error... and refetch() to read again.
// It also handles what must be written by hand with useEffect: cancelling
// a stale response, not sending the same request twice, keeping
// the responses in cache.
// =========================================================================

import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import LoginBar from '../../common/LoginBar';
import { ApiError } from '../../common/api-error';
import type { ScheduleSlot, User } from '../../common/types';
import { rdvmedecins } from './rdvmedecins';

function tomorrow(): string {
  const d = new Date(Date.now() + 24 * 3600 * 1000);
  return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}

function timeRange(c: ScheduleSlot): string {
  const hm = (h: number, m: number) => `${h}:${String(m).padStart(2, '0')}`;
  return `${hm(c.startHour, c.startMinute)} - ${hm(c.endHour, c.endMinute)}`;
}

// an error -> a text (its key, for lack of a dictionary here)
function errorText(e: unknown): string {
  return e instanceof ApiError ? `${e.statusCode} ${e.key}` : String(e);
}

export default function App() {
  const [user, setUser] = useState<User | null>(null);
  const [doctorId, setDoctorId] = useState<number | null>(null);
  const [day, setDay] = useState(tomorrow);
  const [clientId, setClientId] = useState<number | null>(null);
  const [message, setMessage] = useState<{ type: 'success' | 'danger'; text: string } | null>(null);

  const loggedIn = user !== null;
  const isManager = loggedIn && user.role !== 'USER';

  // the doctors, then (ADMIN, DOCTOR) the clients: read again when
  // the user changes (it is part of the key)
  const doctors = useQuery({
    queryKey: ['doctors', user?.login],
    queryFn: rdvmedecins.getDoctors,
    enabled: loggedIn, // false: no request (nobody logged in)
  });
  const clients = useQuery({
    queryKey: ['clients', user?.login],
    queryFn: rdvmedecins.getClients,
    enabled: isManager, // a USER would receive 403
  });

  // the schedule: the key contains the doctor and the day
  const schedule = useQuery({
    queryKey: ['schedule', doctorId, day, user?.login],
    queryFn: () => rdvmedecins.getSchedule(doctorId!, day),
    enabled: loggedIn && doctorId !== null,
  });

  // a new choice of doctor or of day clears the previous message
  function chooseDoctor(id: number) {
    setDoctorId(id);
    setMessage(null);
  }
  function chooseDay(d: string) {
    setDay(d);
    setMessage(null);
  }

  async function book(c: ScheduleSlot) {
    try {
      await rdvmedecins.book(day, c.id, isManager ? (clientId ?? undefined) : undefined);
      setMessage({ type: 'success', text: `Slot ${timeRange(c)} booked.` });
    } catch (e) {
      setMessage({ type: 'danger', text: errorText(e) });
    }
    schedule.refetch();
  }

  async function cancel(c: ScheduleSlot) {
    try {
      await rdvmedecins.cancelAppointment(c.appointmentId!);
      setMessage({ type: 'success', text: `Appointment of ${timeRange(c)} cancelled.` });
    } catch (e) {
      setMessage({ type: 'danger', text: errorText(e) });
    }
    schedule.refetch();
  }

  const a = schedule.data;
  return (
    <div className="container py-4">
      <h1 className="h3 mb-3">Example 24 - an API access layer</h1>
      <LoginBar onUserChange={setUser} />

      {loggedIn && (
        <>
          <div className="row g-2 mb-3">
            <div className="col-sm-5">
              <select className="form-select" value={doctorId ?? ''} onChange={(e) => chooseDoctor(Number(e.target.value))}>
                <option value="" disabled>-- choose a doctor --</option>
                {doctors.data?.map((m) => <option key={m.id} value={m.id}>{m.title} {m.firstName} {m.lastName}</option>)}
              </select>
            </div>
            <div className="col-sm-3">
              <input type="date" className="form-control" value={day} onChange={(e) => chooseDay(e.target.value)} />
            </div>
            {isManager && (
              <div className="col-sm-4">
                <select className="form-select" value={clientId ?? ''}
                        onChange={(e) => setClientId(e.target.value === '' ? null : Number(e.target.value))}>
                  <option value="">-- patient to book for --</option>
                  {clients.data?.map((c) => <option key={c.id} value={c.id}>{c.title} {c.firstName} {c.lastName}</option>)}
                </select>
              </div>
            )}
          </div>

          {message && <div className={`alert alert-${message.type}`}>{message.text}</div>}
          {schedule.error && <div className="alert alert-danger">{errorText(schedule.error)}</div>}
          {schedule.isFetching && <p className="small text-muted">loading the schedule...</p>}

          {a && (
            <>
              {/* info: the key sent by the server when a patient already has an upcoming appointment */}
              {a.info && <div className="alert alert-info">{a.info}</div>}
              <table className="table table-sm align-middle">
                <caption className="caption-top">
                  Schedule of {a.day} - {a.doctor.title} {a.doctor.firstName} {a.doctor.lastName}
                </caption>
                <tbody>
                  {a.slots.map((c) => (
                    <tr key={c.id}>
                      <td>{timeRange(c)}</td>
                      <td>
                        {c.free ? (
                          <span className="badge text-bg-success">Free</span>
                        ) : c.patient ? (
                          <span className={`badge ${c.mine ? 'text-bg-primary' : 'text-bg-secondary'}`}>
                            {c.patient.title} {c.patient.firstName} {c.patient.lastName}
                          </span>
                        ) : (
                          <span className="badge text-bg-secondary">Booked</span>
                        )}
                      </td>
                      <td className="text-end">
                        {/* the buttons follow the rights computed by the server */}
                        {c.bookable && <button className="btn btn-sm btn-outline-success" onClick={() => book(c)}>Book</button>}
                        {c.appointmentId !== null && <button className="btn btn-sm btn-outline-danger" onClick={() => cancel(c)}>Cancel</button>}
                      </td>
                    </tr>
                  ))}
                </tbody>
              </table>
            </>
          )}
        </>
      )}
    </div>
  );
}
  • lines 54–58: [useQuery({ queryKey, queryFn, enabled })] describes a READ of data:
    • [queryFn]: the function that retrieves it, a promise;
    • [queryKey]: what IDENTIFIES the data—an array. [TanStack Query] calls [queryFn] first, then each time the key CHANGES; two components that request the same key share the same request and the same response. The user is added to it: the list is reloaded when the user changes;
    • [enabled: false]: no request (no one is logged in);
    • The result is an object: [data] (the response, [undefined] until it arrives), [isPending], [isFetching] (a request is in progress), [error], and [refetch()] to be re-read;
  • lines 59–63: the patient list, only for ADMIN and DOCTOR (a USER would receive a 403);
  • lines 66–70: the schedule: the key contains the doctor and the day; changing either one sends a new request. An expired response is automatically ignored;
  • lines 82–90: a reservation: [POST /api/appointments], then [schedule.refetch()]: the calendar is re-read. The SERVER is the authoritative one;
  • line 106: the connection bar notifies the page;
  • line 114: the doctors, if they have arrived ([?.]: nothing if [data] is [undefined]);
  • line 133: a calendar request is in progress;
  • lines 160–161: the buttons follow the permissions calculated by the server;
  • line 138: [info]: the key sent by the server when a patient already has an upcoming appointment (displayed raw: no dictionary here).

Logged in as [admin], we select Ms. PELISSIER, then a patient, and book the first time slot:

Image

Logged in as [martin], we book a time slot: the patient sees their own name, but the name of another patient is never sent to them; the “Book” button is no longer available (only one upcoming appointment):

Image

For transaction processing (booking, canceling), [TanStack Query] also provides [useMutation], which handles the “in progress” status and errors from modifications. We won’t use those here: a [async] function and a [try / catch] are sufficient.

8.4. Example 25: Errors Returned by the Server

Patient management ([admin] form). This form performs no validation in the browser: everything is sent to the server, which responds—in the event of a rejection—with a JSON error:

1
2
3
400 { key: 'ERRORS.INVALID_FORM', fields: { lastName: 'ERRORS.LASTNAME_REQUIRED' } }
409 { key: 'ERRORS.PATIENT_ALREADY_EXISTS', fields: { firstName: ..., lastName: ... } }
409 { key: 'ERRORS.CONCURRENT_UPDATE' }         (optimistic lock)

8.4.1. Dictionaries

{
  "FORM": {
    "TITLE_ADD": "Ajouter un client",
    "TITLE_EDIT": "Modifier un client",
    "TITLE": "Titre",
    "FIRSTNAME": "Prénom",
    "LASTNAME": "Nom",
    "LOGIN": "Login",
    "PASSWORD": "Mot de passe",
    "SAVE": "Enregistrer",
    "CANCEL": "Annuler",
    "EDIT": "Modifier",
    "DELETE": "Supprimer",
    "DELETE_CONFIRM": "Supprimer le client {{name}}, son compte et ses rendez-vous ?",
    "ADDED": "Le client {{name}} a été ajouté (login : {{login}}).",
    "UPDATED": "Le client {{name}} a été modifié.",
    "DELETED": "Le client {{name}} a été supprimé.",
    "ADMIN_ONLY": "Connectez-vous avec le compte admin : seul le rôle ADMIN gère les clients."
  },
  "ERRORS": {
    "INVALID_FORM": "Le formulaire contient des erreurs.",
    "INVALID_TITLE": "Choisissez un titre dans la liste.",
    "FIRSTNAME_REQUIRED": "Le prénom est obligatoire.",
    "FIRSTNAME_TOO_LONG": "Le prénom ne doit pas dépasser 20 caractères.",
    "LASTNAME_REQUIRED": "Le nom est obligatoire.",
    "LASTNAME_TOO_LONG": "Le nom ne doit pas dépasser 20 caractères.",
    "INVALID_LOGIN": "30 caractères au plus : lettres minuscules non accentuées, chiffres, point, tiret, souligné.",
    "PASSWORD_TOO_LONG": "Le mot de passe ne doit pas dépasser 72 caractères.",
    "LOGIN_TAKEN": "Ce login est déjà utilisé par un autre compte.",
    "PATIENT_ALREADY_EXISTS": "Un client portant ce prénom et ce nom existe déjà.",
    "PATIENT_NOT_FOUND": "Ce client n'existe pas (ou plus).",
    "CONCURRENT_UPDATE": "Cette fiche a été modifiée entre-temps par un autre utilisateur. Rechargez-la avant de recommencer.",
    "FORBIDDEN": "Votre rôle ne vous permet pas cette action.",
    "AUTH_REQUIRED": "Connectez-vous d'abord.",
    "SESSION_EXPIRED": "Votre session a expiré : reconnectez-vous.",
    "SERVER_UNREACHABLE": "Le serveur ne répond pas.",
    "SERVER_ERROR": "Une erreur inattendue s'est produite."
  }
}

The keys [ERRORS.*] are those returned by the server. The English dictionary, [en.json], has the same keys. The configuration of [i18next] is the same as in Example 21, but simpler:

// =========================================================================
// 25-server-errors/src/i18n/i18n.ts - i18next, as in example 21
// =========================================================================

import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import fr from './fr.json';
import en from './en.json';

i18n.use(initReactI18next).init({
  resources: { fr: { translation: fr }, en: { translation: en } },
  lng: 'en',
  fallbackLng: 'en',
  interpolation: { escapeValue: false },
});
// =========================================================================
// 25-server-errors/src/main.tsx
// -------------------------------------------------------------------------
//   - i18next (cf. example 21);
//   - the confirmation dialog of example 16, reused as is.
// =========================================================================

import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import 'bootstrap/dist/css/bootstrap.min.css';
import './i18n/i18n';
import App from './App';
import { ConfirmationProvider } from '../../16-confirmation/src/confirmation/confirmation';

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

The confirmation window from Example 16 is reused as-is: its provider is imported.

8.4.2. The page

// =========================================================================
// 25-server-errors/src/App.tsx - displaying the errors of the SERVER
// -------------------------------------------------------------------------
// The management of the clients (admin account). This form does
// DELIBERATELY no validation in the browser: everything is sent
// to the server, which answers, in case of refusal, with a JSON error (converted
// into an ApiError by common/api.ts):
//
//   400 { key: 'ERRORS.INVALID_FORM', fields: { lastName: 'ERRORS.LASTNAME_REQUIRED' } }
//   409 { key: 'ERRORS.PATIENT_ALREADY_EXISTS', fields: { firstName: ..., lastName: ... } }
//   409 { key: 'ERRORS.CONCURRENT_UPDATE' }         (optimistic lock)
//
// The error of EACH field (ApiError.fields) is stored in the state and
// displayed under the field; its message is the KEY sent by the server,
// which the component translates (i18next). Modifying a field clears its error.
// Try: an empty last name, a title "Dr", a namesake (Alice DURAND), a
// login already taken (martin), the same record edited in two tabs...
// =========================================================================

import { useState, useTransition, type FormEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { api } from '../../common/api';
import LoginBar from '../../common/LoginBar';
import { ApiError } from '../../common/api-error';
import type { Person, User } from '../../common/types';
import { useConfirmation } from '../../16-confirmation/src/confirmation/confirmation';

interface ClientInput {
  title: string;
  firstName: string;
  lastName: string;
  login: string;
  password: string;
  version: number;
}
type Field = keyof ClientInput;

const EMPTY: ClientInput = { title: 'Mr', firstName: '', lastName: '', login: '', password: '', version: 0 };

interface Message {
  key: string;
  params?: Record<string, string>;
}

export default function App() {
  const { t, i18n } = useTranslation();
  const confirm = useConfirmation();

  const [user, setUser] = useState<User | null>(null);
  const [clients, setClients] = useState<Person[]>([]);
  const isAdmin = user?.role === 'ADMIN';

  // the form: editedId === null -> creation, otherwise update
  const [editedId, setEditedId] = useState<number | null>(null);
  const [input, setInput] = useState<ClientInput>(EMPTY);
  // the error of each field (a translation KEY), returned by the server
  const [errors, setErrors] = useState<Partial<Record<Field, string>>>({});
  // the general message of the last error, and that of a success
  const [pageError, setPageError] = useState<ApiError | null>(null);
  const [success, setSuccess] = useState<Message | null>(null);
  // useTransition: isPending is true during the execution of the action
  // passed to startTransition - here, the sending to the server
  const [sending, startTransition] = useTransition();

  async function reload() {
    setClients(await api.get<Person[]>('/clients'));
  }

  async function onUserChange(u: User | null) {
    setUser(u);
    setClients([]);
    if (u?.role === 'ADMIN') {
      await reload();
    }
  }

  function clearMessages() {
    setErrors({});
    setPageError(null);
    setSuccess(null);
  }

  function newForm() {
    setEditedId(null);
    setInput(EMPTY);
    clearMessages();
  }

  function edit(c: Person) {
    setEditedId(c.id);
    // the VERSION read with the record goes back with the update: if someone
    // has modified the record in the meantime, the server will answer 409 CONCURRENT_UPDATE
    setInput({ title: c.title, firstName: c.firstName, lastName: c.lastName, login: c.login ?? '', password: '', version: c.version });
    clearMessages();
  }

  // a modified field: its value changes, its error disappears
  function change(field: Field, value: string) {
    setInput((s) => ({ ...s, [field]: value }));
    setErrors((e) => ({ ...e, [field]: undefined }));
  }

  function save(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    clearMessages();
    // an ASYNCHRONOUS transition (React 19): sending remains true until
    // the end of the function
    startTransition(async () => {
      try {
        const client =
          editedId === null
            ? await api.post<Person>('/clients', input)
            : await api.put<Person>(`/clients/${editedId}`, input);
        newForm(); // form emptied, messages cleared...
        // ... except this one
        setSuccess({
          key: editedId === null ? 'FORM.ADDED' : 'FORM.UPDATED',
          params: { name: `${client.firstName} ${client.lastName}`, login: client.login ?? '' },
        });
        await reload();
      } catch (e) {
        if (!(e instanceof ApiError)) {
          throw e;
        }
        setPageError(e); // the general message
        setErrors(e.fields); // the error of each field
      }
    });
  }

  async function remove(c: Person) {
    const name = `${c.firstName} ${c.lastName}`;
    if (!(await confirm(t('FORM.DELETE_CONFIRM', { name })))) {
      return;
    }
    clearMessages();
    try {
      await api.delete(`/clients/${c.id}`);
      setSuccess({ key: 'FORM.DELETED', params: { name } });
    } catch (e) {
      setPageError(e instanceof ApiError ? e : null);
    }
    await reload();
  }

  // an input field, with its translated error
  function field(name: 'firstName' | 'lastName' | 'login' | 'password', type = 'text') {
    const error = errors[name];
    return (
      <>
        <label className="form-label" htmlFor={name}>{t(`FORM.${name.toUpperCase()}`)}</label>
        <input id={name} type={type} className={`form-control ${error ? 'is-invalid' : ''}`} value={input[name]}
               autoComplete={type === 'password' ? 'new-password' : 'off'} onChange={(e) => change(name, e.target.value)} />
        {/* the message of a server error is a KEY: it is translated */}
        {error && <div className="invalid-feedback">{t(error)}</div>}
      </>
    );
  }

  return (
    <div className="container py-4">
      <div className="d-flex justify-content-between align-items-center mb-3">
        <h1 className="h3 mb-0">Example 25 - the errors returned by the server</h1>
        <select className="form-select form-select-sm w-auto" value={i18n.language} onChange={(e) => i18n.changeLanguage(e.target.value)}>
          <option value="fr">FR</option>
          <option value="en">EN</option>
        </select>
      </div>
      <LoginBar onUserChange={onUserChange} />
      {user && !isAdmin && <p className="alert alert-warning">{t('FORM.ADMIN_ONLY')}</p>}

      {isAdmin && (
        <div className="row g-4">
          <div className="col-lg-6">
            <table className="table table-sm align-middle">
              <tbody>
                {clients.map((c) => (
                  <tr key={c.id}>
                    <td>{c.title} {c.firstName} {c.lastName}</td>
                    <td><code>{c.login}</code></td>
                    <td className="text-end text-nowrap">
                      <button className="btn btn-sm btn-outline-primary me-1" onClick={() => edit(c)}>{t('FORM.EDIT')}</button>
                      <button className="btn btn-sm btn-outline-danger" onClick={() => remove(c)}>{t('FORM.DELETE')}</button>
                    </td>
                  </tr>
                ))}
              </tbody>
            </table>
          </div>

          <div className="col-lg-6">
            <form className="card p-3" noValidate onSubmit={save}>
              <h2 className="h5">{t(editedId === null ? 'FORM.TITLE_ADD' : 'FORM.TITLE_EDIT')}</h2>
              <div className="mb-2">
                <label className="form-label" htmlFor="title">{t('FORM.TITLE')}</label>
                {/* an option "Dr" that the server will reject: to see ERRORS.INVALID_TITLE */}
                <select id="title" className={`form-select ${errors.title ? 'is-invalid' : ''}`} value={input.title}
                        onChange={(e) => change('title', e.target.value)}>
                  <option>Mr</option><option>Mme</option><option>Melle</option><option>Dr</option>
                </select>
                {errors.title && <div className="invalid-feedback">{t(errors.title)}</div>}
              </div>
              <div className="row g-2 mb-2">
                <div className="col-sm-6">{field('firstName')}</div>
                <div className="col-sm-6">{field('lastName')}</div>
              </div>
              <div className="row g-2 mb-3">
                <div className="col-sm-6">{field('login')}</div>
                <div className="col-sm-6">{field('password', 'password')}</div>
              </div>
              <div className="d-flex gap-2">
                <button className="btn btn-primary" disabled={sending}>{t('FORM.SAVE')}</button>
                <button type="button" className="btn btn-secondary" onClick={newForm}>{t('FORM.CANCEL')}</button>
              </div>
              {success && <div className="alert alert-success mt-3 mb-0">{t(success.key, success.params)}</div>}
              {/* the general message, and the HTTP status, to understand */}
              {pageError && (
                <div className="alert alert-danger mt-3 mb-0">
                  {t(pageError.key, pageError.params)}{' '}
                  <span className="small text-muted">({pageError.statusCode} {pageError.key})</span>
                </div>
              )}
            </form>
          </div>
        </div>
      )}
    </div>
  );
}
  • lines 54–55: the form: [editedId === null] for creating a patient, or [editedId] for modifying a patient;
  • line 57: the error for each field, a translation KEY, returned by the server;
  • line 63: [useTransition] returns a “in progress” flag and a function [startTransition(action)]. Starting with [React] 19, the action can be [async]: the indicator remains true until the end of the function, without requiring any manual handling;
  • lines 89–95: modifying a patient. The VERSION read from the master record reflects the modification: if someone has modified the master record in the meantime, the server will respond with a 409 [ERRORS.CONCURRENT_UPDATE] (optimistic lock);
  • lines 98–101: a modified field: its value changes, and its error disappears;
  • lines 103–129: sending:
    • [POST /api/clients] (creation) or [PUT /api/clients/:id] (update);
    • if successful: the form is cleared, and a message is translated, along with its parameters;
    • in the case of [ApiError] (lines 125–126): the general message and the error for EACH field; the row as it appears in the report: [e.fields] has exactly the desired format;
  • lines 131–144: deletion, after confirmation. The confirmation message is translated by [t], with a parameter;
  • lines 147–158: a function that creates an input field with its label and error message: [t(error)] translates the KEY sent by the server;
  • line 164: language selection: the errors already displayed are retranslated, since they are keys;
  • line 199: a “Dr” option that the server will reject, resulting in [ERRORS.INVALID_TITLE];
  • line 212: the button is disabled during transmission;
  • line 219: the general message and the HTTP status, for context.

Logged in as [admin], with the title “Dr,” an empty name, and an invalid login:

Image

A namesake (Alice Durand, who already exists) and a username already in use ([martin]): 409, and the two affected fields are flagged:

Image

The same page in French: the server messages are translated back:

Image

A successful addition:

Image

Then its deletion:

Image

Image

To see the optimistic lock, open the same record in two tabs, save it in the first tab, then in the second: the server responds with a 409 error ([ERRORS.CONCURRENT_UPDATE]).