Le bandeau de recherche
Notre page affiche la liste des logements disponibles. Et le bouton "Réserver" fonctionne.
Mais regardons ce qui se cache en haut de notre fichier App.jsx :
// Les dates du séjour recherché. Elles viendront du bandeau de recherche plus tard.
const searched = Stay.parse({ from: "2024-06-02", to: "2024-06-04" }).value;"Plus tard", c'est maintenant.
Notre Layout possède un bandeau avec quatre champs. Aucun n'est branché sur quoi que ce soit.
Ce sont des images de champs de saisie.
Ce que le bandeau doit produire
Reprenons le schéma du chapitre 22 : le loader charge les données de la page.
Jusqu'ici, notre loader n'avait aucun paramètre. Il retournait toujours la même chose.
Désormais, il en a : la période recherchée.
async function loader(criteria) {
// ...
}Et voilà que surgit une question de conception, la même qu'au chapitre 21 : sous quelle forme ?
Un objet Stay ? Deux chaînes ISO ? Deux objets Date ?
La réponse tient en une phrase, et elle vaut pour toutes les frontières de votre application :
L'UI manipule des chaînes. Le domaine manipule des valeurs. La conversion se fait à l'entrée du domaine, une fois, et elle peut échouer.
Donc notre état de page contient des chaînes. C'est Stay.parse qui décide si elles forment un séjour.
Un cadeau du HTML
Ouvrons une parenthèse réjouissante.
Remplaçons les <input type="text" placeholder="Arrivée" /> du chapitre 25 par des <input type="date" />.
Que vaut event.target.value sur un champ de type date ?
"2024-06-02"Exactement le format que CalendarDay.parse attend. Ni un objet Date, ni un timestamp, ni une chaîne localisée.
Ce n'est pas un hasard : la spécification HTML impose le format ISO-8601 dans la valeur, indépendamment de ce que le navigateur affiche à l'utilisateur (qui verra 02/06/2024 en France et 6/2/2024 aux États-Unis).
Le navigateur a fait le même choix que nous au chapitre 21. C'est plutôt rassurant.
L'état de la recherche
Ajoutons donc un état à notre page :
const [criteria, setCriteria] = useState({
from: "2024-06-02",
to: "2024-06-04",
adults: 2,
children: 3,
});Et faisons descendre ces valeurs dans le Layout, avec de quoi les modifier :
function Layout({ children, loading, error, criteria, onChange }) {
return (
<div className="layout">
<header className="header">
<div className="search-bar">
<input type="date" value={criteria.from}
onChange={(e) => onChange({ ...criteria, from: e.target.value })} />
<input type="date" value={criteria.to}
onChange={(e) => onChange({ ...criteria, to: e.target.value })} />
<input type="number" min="1" value={criteria.adults}
onChange={(e) => onChange({ ...criteria, adults: Number(e.target.value) })} />
<input type="number" min="0" value={criteria.children}
onChange={(e) => onChange({ ...criteria, children: Number(e.target.value) })} />
</div>
</header>
<main className="main-content">
{error && <div className="error">{error}</div>}
{loading ? <div className="loading">Loading...</div>
: <div className="accommodations-list">{children}</div>}
</main>
</div>
);
}Un détail qui coûte cher quand on l'oublie : Number(e.target.value).
La valeur d'un <input> est toujours une chaîne, même avec type="number". Sans cette conversion, adults vaut "2", et Occupancy.of refuse de le construire — Number.isInteger("2") est false.
C'est un bon refus. Notre Value Object vient d'attraper un bug que le code aurait avalé sans broncher : "2" + 3 vaut "23".
Le loader qui échoue
Notre loader reçoit maintenant des chaînes venues d'un humain. Donc il peut échouer.
async function loader({ from, to }) {
const stay = Stay.parse({ from, to });
if (stay.isError()) {
return { accommodations: [], error: stay.error.message };
}
const accommodations =
await app.dependencies.bookings.getAvailableAccommodations(stay.value);
return { accommodations, error: null };
}Remarquez que nous n'avons rien à valider nous-mêmes.
Pas de if (from > to), pas de regex sur le format, pas de test du 31 février. Tout cela vit dans Stay et CalendarDay depuis le chapitre 21, testé en quelques millisecondes, et le même code s'exécute ici dans le navigateur.
C'est exactement le bénéfice qu'on attend d'un domaine bien découpé : la même règle, un seul endroit, deux environnements d'exécution.
Le piège du tableau de dépendances
Notre hook doit relancer le loader quand les critères changent.
Première tentative, celle que tout le monde écrit :
function useAccommodations(criteria) {
const [loading, setLoading] = useState(false);
const [accommodations, setAccommodations] = useState([]);
const [error, setError] = useState(null);
const load = async () => {
setLoading(true);
const { accommodations, error } = await loader(criteria);
setAccommodations(accommodations);
setError(error);
setLoading(false);
};
useEffect(() => { load(); }, [criteria]); // <-- attention
return { accommodations, loading, error, refresh: load };
}Cela marche… tant que criteria vient d'un useState.
Mais le jour où un développeur pressé écrira dans le composant :
const { accommodations } = useAccommodations({ from, to });l'application partira en boucle infinie.
Pourquoi ? Parce que React compare les dépendances avec Object.is, c'est-à-dire par référence. L'objet littéral { from, to } est reconstruit à chaque rendu. Il n'est jamais "le même" que celui du rendu précédent, même quand son contenu est identique. Donc l'effet se rejoue, donc il appelle setAccommodations, donc le composant se rend à nouveau, donc l'objet est reconstruit…
D'où la règle, qui n'a l'air de rien mais qui vous épargnera plusieurs soirées :
Ne mettez que des primitives dans un tableau de dépendances.
useEffect(() => { load(); }, [criteria.from, criteria.to]);Deux chaînes. Comparées par valeur. Le problème disparaît.
Et remarquez que cette règle est une raison de plus de garder des chaînes dans l'état de la page plutôt qu'un objet Stay : un Stay est un objet, donc une référence, donc un piège de plus.
Le Value Object appartient au domaine. L'état de l'UI reste plat.
Deux requêtes en vol
Il reste un défaut, plus discret, et il concerne tout le monde — pas seulement React.
L'utilisateur modifie la date de départ, puis presque aussitôt la date d'arrivée. Deux loaders partent. Rien ne garantit qu'ils reviennent dans l'ordre.
Si le premier est plus lent que le second, il arrive en dernier, et écrase le résultat du second. L'écran affiche alors la réponse à une question que l'utilisateur ne pose plus.
La correction tient en trois lignes, et c'est le rôle exact de la fonction de nettoyage retournée par useEffect :
useEffect(() => {
let obsolete = false;
(async () => {
setLoading(true);
const { accommodations, error } = await loader({ from: criteria.from, to: criteria.to });
if (obsolete) return; // une recherche plus récente est partie : on jette
setAccommodations(accommodations);
setError(error);
setLoading(false);
})();
return () => { obsolete = true; }; // React appelle ceci avant de rejouer l'effet
}, [criteria.from, criteria.to]);React appelle la fonction de nettoyage avant chaque nouvelle exécution de l'effet, et au démontage du composant. Le loader parti pour rien continue de s'exécuter, mais son résultat n'est plus écrit nulle part.
(Avec fetch, on remplacera ce drapeau par un AbortController, qui a le bon goût d'annuler aussi la requête réseau. Nous y reviendrons au chapitre 41.)
Notez au passage que nous nous permettons de jeter un résultat sans le moindre remords. C'est une requête. Le chapitre 4 nous l'avait dit :
Rejeu : rafraîchir l'écran est sans risque. Relancer une commande en cours peut donner un résultat incertain.
Faites la même chose avec une commande de réservation et vous facturez deux fois.
L'URL, ou l'état qu'on peut envoyer par SMS
Dernière question, et elle est plus importante qu'elle n'en a l'air.
Un utilisateur trouve trois logements pour le pont du 8 mai. Il envoie le lien à sa sœur.
Que voit-elle ?
Avec notre code : les dates par défaut, écrites en dur. Pas les siennes.
Une recherche est un état qui mérite d'être partagé, mis en favori, rechargé après un F5. Cet état-là a un endroit prévu depuis trente ans : l'URL.
function readCriteria() {
const params = new URLSearchParams(window.location.search);
return {
from: params.get("from") ?? "2024-06-02",
to: params.get("to") ?? "2024-06-04",
adults: Number(params.get("adults") ?? 2),
children: Number(params.get("children") ?? 0),
};
}
function writeCriteria(criteria) {
const params = new URLSearchParams(criteria);
window.history.replaceState(null, "", `?${params}`);
}Et dans la page :
function HomePage() {
const [criteria, setCriteria] = useState(readCriteria);
const onChange = (next) => {
setCriteria(next);
writeCriteria(next);
};
// ...
}Deux remarques.
useState(readCriteria) — la fonction, pas son appel. Passée ainsi, React ne l'exécute qu'une fois, au premier rendu. Écrire useState(readCriteria()) relit l'URL à chaque rendu pour rien.
replaceState et non pushState : chaque frappe dans un champ de date créerait sinon une entrée dans l'historique, et le bouton "retour" du navigateur deviendrait inutilisable.
Voici le critère, valable bien au-delà de React :
Un état qui décrit ce que l'utilisateur regarde va dans l'URL. Un état qui décrit comment l'écran s'anime (un spinner, un menu ouvert) reste dans le composant.
loading reste dans le composant. from et to vont dans l'URL.
Et le nombre de voyageurs ?
Nous stockons adults et children, nous les envoyons à la commande book… mais notre loader n'en fait rien. Une famille de six voit encore les studios.
C'est normal : getAvailableAccommodations ne connaît que la période.
La tentation serait de filtrer dans le composant, avec un petit accommodations.filter(a => a.capacity >= adults + children). Trois secondes de travail.
Résistons. Ce serait la règle métier "un logement ne peut pas accueillir plus de personnes que prévu", écrite dans un composant React, où aucun test du domaine ne la verra jamais — et où le serveur, lui, ne l'appliquera pas.
Cette règle est un invariant. Elle a sa place dans le domaine, et elle est sur notre liste depuis le chapitre 14.
Le champ "destination", lui, reste décoratif : le filtrer supposerait une requête que personne ne nous a demandée. Il attendra qu'un test l'exige.
Retournons donc dans le domaine régler la question de la capacité.