Mes réservations
Le parcours utilisateur du chapitre 20 comportait cinq étapes. Nous en avons quatre.
se connecter à la plateforme✅un bandeau avec une date de début / fin✅voir les logements disponibles à ces dates✅réserver en 1 clic✅- voir sa réservation dans une page "mes réservations"
Il nous manque une page. Donc, pour la première fois, un moyen de passer d'une page à l'autre.
Le routeur en trois minutes
Premier réflexe, et il est faux :
const [page, setPage] = useState("home");
// ...
{page === "home" ? <HomePage /> : <MyBookingsPage />}Trois lignes, ça marche, et cela reproduit exactement le défaut que nous avons corrigé au chapitre 27 : l'état de ce que l'utilisateur regarde n'est nulle part dans l'URL.
Conséquences immédiates : le bouton "retour" du navigateur quitte l'application, un F5 renvoie à l'accueil, et personne ne peut mettre "mes réservations" en favori.
L'adresse d'une page est la page. Alors utilisons-la :
function useRoute() {
const [path, setPath] = useState(window.location.pathname);
useEffect(() => {
const onPopState = () => setPath(window.location.pathname);
window.addEventListener("popstate", onPopState);
return () => window.removeEventListener("popstate", onPopState);
}, []);
const navigate = (to) => {
window.history.pushState(null, "", to);
setPath(to);
};
return [path, navigate];
}Quinze lignes, et un vrai routeur — au sens où l'historique du navigateur fonctionne.
Trois points à relever.
L'événement popstate est déclenché par le navigateur quand l'utilisateur fait "précédent" ou "suivant". Sans cet abonnement, votre application ignore le bouton retour et affiche la mauvaise page. C'est le bug numéro un des routeurs maison.
useEffect retourne une fonction de nettoyage qui retire l'écouteur. Le chapitre 24 disait qu'un effet sert à synchroniser le composant avec quelque chose qui vit en dehors de lui : ici un écouteur global. Ce qu'on branche au montage, on le débranche au démontage. Sinon les écouteurs s'accumulent, et chaque rendu en ajoute un de plus.
pushState cette fois, et non replaceState comme au chapitre 27 : changer de page doit créer une entrée dans l'historique. Modifier un critère de recherche, non.
Et un composant <Link> de six lignes, pour que les liens restent des liens :
function Link({ to, children, navigate }) {
return (
<a href={to} onClick={(e) => { e.preventDefault(); navigate(to); }}>
{children}
</a>
);
}Le href est indispensable même si preventDefault l'annule : c'est lui qui donne le clic-milieu "ouvrir dans un nouvel onglet", le menu contextuel, l'aperçu de l'adresse en bas de l'écran, et le lien suivi par un moteur de recherche. Un <div onClick> n'est pas un lien, c'est un dessin de lien.
Quand faut-il abandonner ce routeur maison ? Dès la troisième page, ou dès le premier paramètre d'URL (/bookings/:id). Les routeurs sérieux gèrent l'imbrication, le chargement anticipé, le rendu côté serveur. Notre but ici n'était pas de les remplacer, mais de montrer qu'il n'y a aucune magie derrière.
Le loader qui ne sait pas quoi charger
Passons à la page. Son loader semble évident, la requête existe depuis le chapitre 20 :
const bookings = await app.dependencies.bookings.listBookingsForTenantId(tenantId);Regardons ce qu'elle retourne :
{
tenantId: "tenant-1",
accommodationId: "accommodation-1",
guests: Occupancy { adults: 2, children: 3 },
stay: Stay { from: CalendarDay("2024-06-02"), to: CalendarDay("2024-06-04") },
}Et ce que l'écran doit afficher :
Villa 6 pièces avec piscine — Saint-Rémy-de-Provence du 2 au 4 juin 2024 · 2 nuits · 5 voyageurs · 460 €
Le nom, la ville, la photo, le prix : rien de tout cela n'est dans la réservation. Il faut aller chercher chaque logement.
Le réflexe, encore une fois, serait :
const bookings = await app.dependencies.bookings.listBookingsForTenantId(tenantId);
const detailed = await Promise.all(
bookings.map(async (booking) => ({
...booking,
accommodation: await app.dependencies.accommodations.findById(booking.accommodationId),
}))
);Ça marche. Et ça s'appelle le problème N+1 : une requête pour la liste, puis une requête par ligne. Douze réservations, treize allers-retours. En mémoire, c'est instantané. Sur une base de données distante, c'est treize fois la latence réseau — et personne ne s'en aperçoit avant la mise en production, parce qu'en développement la base est sur la même machine.
Le chapitre 20 avait annoncé la solution en une phrase :
une requête qui croise deux sources appartient plutôt à un service de lecture dédié.
Le moment est venu de le créer.
Le service de lecture
Ajoutons un troisième compartiment au container, à côté de users, accommodations et bookings :
// infra/MemoryQueries.js
export class MemoryQueries {
constructor(bookings, accommodations) {
this._bookings = bookings;
this._accommodations = accommodations;
}
/** Tout ce qu'il faut à la page "mes réservations", et rien d'autre. */
async bookingsOfTenant(tenantId) {
const bookings = await this._bookings.listBookingsForTenantId(tenantId);
const accommodations = await this._accommodations.all();
const byId = new Map(accommodations.map((a) => [a.id, a]));
return bookings
.map((booking) => {
const accommodation = byId.get(booking.accommodationId);
return {
accommodationId: booking.accommodationId,
name: accommodation.name,
location: accommodation.location,
imageUrl: accommodation.imageUrl,
from: booking.stay.from.toString(), // "2024-06-02"
to: booking.stay.to.toString(),
nights: booking.stay.nights,
guests: booking.guests.total,
price: accommodation.price * booking.stay.nights,
};
})
.sort((a, b) => a.from.localeCompare(b.from));
}
}Quatre remarques, et chacune est un principe.
Cette requête ne retourne aucun objet du domaine. Pas de Stay, pas d'Occupancy : des chaînes et des nombres. C'est délibéré. Une vue est un contrat avec un écran, pas avec le métier. Le jour où ce résultat devra traverser le réseau en JSON (chapitre 41), il n'y aura rien à convertir.
Elle calcule le prix. Une multiplication qui n'existait nulle part. Est-ce une règle métier qui devrait vivre dans le domaine ? Aujourd'hui, non : il n'y a pas de paiement (chapitre 19), ce montant est une information affichée. Le jour où il servira à débiter quelqu'un, il devra migrer dans le domaine, avec les taxes, les remises et les frais de ménage — et il aura ses tests. Une valeur qu'on affiche et une valeur qu'on facture ne sont pas la même chose, même quand la formule est identique.
Elle a le droit d'être efficace. Une Map construite une fois, aucune requête dans une boucle. Le chapitre 4 l'avait établi : les requêtes ont des exigences de performance que les commandes n'ont pas. En SQL, cette méthode deviendra une jointure ; ici, elle est déjà écrite comme une jointure.
Elle nomme un écran. bookingsOfTenant, ce n'est pas "tout sur les réservations" : c'est ce dont cette page a besoin. Un service de lecture qui essaie d'être générique redevient un ORM, avec la même conséquence — un composant qui déclenche vingt requêtes sans le savoir.
C'est la version modeste de ce qu'on appelle CQRS : les commandes traversent le domaine et ses invariants, les requêtes prennent le chemin court. Nul besoin de deux bases de données ni d'un bus d'événements pour en tirer le bénéfice.
Une requête qui doit savoir qui la pose
Reste le point le plus important du chapitre, et c'est une faille.
bookingsOfTenant(tenantId) prend un identifiant en paramètre. Si le frontend le fournit, alors n'importe qui peut demander les réservations de n'importe qui.
C'est exactement le raisonnement du chapitre 9 :
C'est en général une mauvaise pratique de laisser l'appelant déclarer lui-même qui est à l'origine de la demande.
Nous l'avions appliqué aux commandes. Nos requêtes, elles, court-circuitent complètement App.run : elles vont taper directement dans app.dependencies. Aucun contexte, aucune session, aucune autorisation.
Cette faille a un nom — Insecure Direct Object Reference — et elle est, année après année, l'une des plus répandues du web. Elle ne demande aucune compétence pour être exploitée : il suffit de changer un identifiant dans une URL.
Faisons donc passer les requêtes privées par le même péage que les commandes :
// domain/usecases/listMyBookings.js
export function listMyBookings() {
return async function (dependencies, context) {
const user = context.loggedUser;
if (!user) {
return context.withError(shouldBeLogged());
}
const bookings = await dependencies.queries.bookingsOfTenant(user.id);
return context.withData(bookings);
};
}L'identifiant ne vient plus du paramètre. Il vient du contexte, c'est-à-dire du jeton, c'est-à-dire du serveur.
Il faut pour cela une case de plus dans le contexte, la symétrique de withError :
withData(data) {
this.data = data;
return this;
}Et côté page :
async function loader(session) {
const context = await app.run([authenticate(session.token), listMyBookings()]);
return { bookings: context.data ?? [], error: context.error?.message ?? null };
}Une objection légitime : le chapitre 4 nous a expliqué que commandes et requêtes ont des enjeux différents, et nous voilà à les faire passer par le même tuyau.
La réponse tient en une distinction. Le chemin d'exécution est commun — authentifier, autoriser, court-circuiter en cas d'erreur — parce que ces trois besoins-là sont identiques. L'implémentation, elle, reste différente : notre requête ne charge aucune entité, ne construit aucun Value Object, ne vérifie aucun invariant. Elle lit et met en forme.
On partage le péage, pas la route.
Et toutes les requêtes n'en ont pas besoin : la liste des logements disponibles est publique, elle continue de s'appeler directement. Une donnée publique n'a pas besoin de savoir qui la demande.
Afficher des dates à un humain
Notre vue retourne "2024-06-02". Personne n'écrit ça sur un écran.
Le chapitre 21 avait laissé une promesse en suspens :
Le fuseau ne réapparaîtra qu'au moment d'afficher "votre séjour commence dans 3 jours" — c'est-à-dire dans l'UI, pas dans le domaine.
Nous y sommes. Et l'endroit exact où le fuseau revient est une fonction de formatage, dans le frontend :
// webapp/src/format.js
const dayFormat = new Intl.DateTimeFormat("fr-FR", {
day: "numeric", month: "long", year: "numeric", timeZone: "UTC",
});
export function formatDay(iso) {
return dayFormat.format(new Date(`${iso}T00:00:00Z`));
}Le timeZone: "UTC" n'est pas décoratif, il est indispensable. Nous construisons volontairement l'instant à minuit UTC ; si le formateur le rendait ensuite dans le fuseau du navigateur, un utilisateur à Mexico lirait 1 juin. Nous reconvertissons dans le même fuseau que celui de construction, et le jour reste le jour.
C'est le seul endroit de toute l'application où un new Date() a le droit d'exister à partir d'une donnée métier. Il est là parce qu'Intl réclame un instant — pas parce que notre domaine en manipule.
Créez ce fichier format.js dès le premier écran, et n'écrivez jamais de formatage dans un composant. Sinon vous aurez douze variantes de l'affichage d'une date, et le jour où le client demandera "2 – 4 juin 2024" au lieu de "du 2 juin 2024 au 4 juin 2024", vous les chercherez toutes.
La page
function MyBookingsPage({ session, navigate }) {
const { bookings, loading, error } = useMyBookings(session);
if (loading) return <div className="loading">Chargement…</div>;
if (error) return <p role="alert" className="error">{error}</p>;
if (bookings.length === 0) {
return (
<div className="empty">
<p>Vous n'avez aucune réservation.</p>
<Link to="/" navigate={navigate}>Chercher un logement</Link>
</div>
);
}
return (
<ul className="bookings">
{bookings.map((booking) => (
<li key={`${booking.accommodationId}-${booking.from}`}>
<img src={booking.imageUrl} alt="" />
<h2>{booking.name}</h2>
<p>{booking.location}</p>
<p>Du {formatDay(booking.from)} au {formatDay(booking.to)}</p>
<p>{booking.nights} nuits · {booking.guests} voyageurs · {booking.price} €</p>
</li>
))}
</ul>
);
}Deux détails que les listes réservent toujours.
L'état vide est un écran, pas un oubli. Un utilisateur qui vient de créer son compte arrive forcément ici. Lui montrer une page blanche, c'est le laisser croire que l'application est cassée. Un message et un lien vers l'action suivante coûtent quatre lignes.
La key. Nous n'avons pas d'identifiant de réservation — la clé est donc bricolée à partir du logement et de la date. Ça tient parce que deux réservations du même logement ne peuvent pas commencer le même jour, mais c'est un raisonnement fragile, et le genre de dette qui se rappelle à vous au pire moment.
Ce manque est en réalité un signal, et il annonce le chapitre suivant : nos réservations n'ont pas d'identité. Le chapitre 3 nous avait pourtant appris à les repérer.
Tant qu'on ne fait que les créer et les lister, on ne s'en aperçoit pas. Le jour où il faut en désigner une pour agir dessus, le manque devient bloquant.
Et justement, l'expert métier vient d'appeler : un client veut annuler.