Module 6 · React côté web

React web ↔ React Native : les ponts

C'est la charnière du guide. Jusqu'ici on a construit les fondations du web comme si tu partais de zéro ; à partir de maintenant, tu ne pars plus de zéro du tout. Ce module fait l'inventaire honnête de ton bagage : ce qui se transfère tel quel (beaucoup), ce qui change de nom, ce qui change de comportement — c'est là que sont les pièges — et ce qui n'existe tout simplement pas de l'autre côté.

💡 L'idée en une phrase

Tu ne changes pas de framework, tu changes de cible. React Native et React web ne sont pas deux React : c'est le même React — même moteur d'état, mêmes hooks, même réconciliation — branché sur deux organes de sortie différents. Ce que tu apprends ici, ce n'est pas une technologie : c'est un vocabulaire de sortie et une poignée de différences de comportement. Compte quelques jours, pas quelques mois.

Le même React, deux cibles

Reprenons l'image du module Le modèle mental de React : React est une machine à fabriquer des écrans. Cette machine a deux moitiés très distinctes, et c'est cette séparation qui explique tout le module.

La première moitié, c'est le cœur (le paquet react) : composants, état, hooks, contexte, réconciliation, phases render et commit, flux unidirectionnel. Ce cœur ne sait absolument rien de ce à quoi il parle — ni les div, ni les UIView d'iOS. Il manipule des objets de description et calcule une liste d'ordres : « crée cet élément », « change cette propriété », « supprime celui-là », « déplace ce nœud ».

La seconde moitié, c'est le renderer (le « moteur de rendu »), l'organe qui exécute cette liste dans un monde concret. react-dom la traduit en manipulations du DOM du navigateur ; react-native, en instructions pour les vues natives iOS et Android. D'autres renderers existent, plus exotiques : un pour le terminal, un pour le PDF, un pour une scène 3D. Tous branchés sur le même cœur.

Où le chemin bifurque, et où il ne bifurque pas
reactle cœur — composants, props, état, hooks, contexte, réconciliation
react-domle renderer du navigateur
<div> <p> <button> <input>des nœuds du DOM
react-nativele renderer natif
View Text Pressable TextInputdes vues iOS et Android
Tout ce que tu écris dans une journée — un useState, un contexte, une key, un hook personnalisé — vit dans le nœud du haut, celui qui ne bifurque jamais. La bifurcation n'arrive qu'au dernier étage : les balises. C'est pour ça que le reste du module tient en une petite liste — elle est petite parce que l'étage du dessus est grand.
🔗 Pont — react-dom et react-native : deux prises sur le même moteur

Pense à une chaîne hi-fi. L'ampli, c'est React : il reçoit le signal, le traite, décide du volume et des graves. Les enceintes, ce sont les renderers. Tu peux brancher des enceintes de salon (react-dom) ou un casque (react-native) : l'ampli ne change pas d'un iota, le son sort juste par un autre organe. Quand ton package.json mobile liste react et react-native, et qu'un projet web liste react et react-dom, tu vois littéralement cette séparation : le paquet react est identique dans les deux cas, même version, même code. Seule la deuxième ligne change. C'est ça, « React est un modèle, pas une cible » — l'idée sur laquelle se terminait le module « React Native : les briques natives » (React Native : les briques).

Conséquence directe : ce qui se transfère sans aucune modification couvre à peu près tout ce que tu écris dans une journée. Le JSX et ses règles (élément racine unique, fragments <>...</>, expressions entre accolades) ; les composants et les props, descendantes et en lecture seule, children compris ; l'état et sa règle d'or (« on ne mute jamais, on remplace ») ; tous les hooks et leurs règles d'appel ; le contexte contre le prop drilling ; tes hooks personnalisés, copiables tels quels tant qu'ils ne touchent que des données pures ; la réconciliation, les key, les causes d'un re-render. Tout ça vit dans le paquet react, donc à l'identique dans les deux mondes.

Ce qui change, en face, tient sur une ligne : les balises que tu écris dans le JSX, le nom de certaines props, et une poignée de comportements du navigateur. Le reste du module détaille cette petite liste — elle est petite précisément parce que l'inventaire ci-dessus est grand.

🧭 Une nuance honnête sur le React Compiler

Halterofit active le React Compiler, ce qui te dispense d'écrire useMemo et useCallback à la main. Ne suppose pas que c'est le cas partout : sur un projet web le compilateur est souvent absent, d'où la mémoïsation manuelle que tu verras partout dans le code d'entreprise. Ce n'est pas une différence web / mobile, mais une différence de configuration de projet.

🧠 Quiz éclair

Tu ouvres un projet React web tout neuf et tu y colles ces trois fichiers, copiés tels quels depuis Halterofit. Lesquels compilent sans y toucher une seule ligne ?

// A — hooks/useDebounce.ts
export function useDebounce(value, delay) {
  const [debounced, setDebounced] = useState(value);
  useEffect(() => {
    const id = setTimeout(() => setDebounced(value), delay);
    return () => clearTimeout(id);
  }, [value, delay]);
  return debounced;
}

// B — components/Badge.tsx
export function Badge({ label }) {
  return <View style={styles.badge}><Text>{label}</Text></View>;
}

// C — lib/formatDuration.ts
export const formatDuration = (min) => `${min} min`;

A et C. Le hook useDebounce n'importe que useState et useEffect — du paquet react, rigoureusement identique des deux côtés — plus setTimeout, qui appartient au langage et existe partout. formatDuration est du JavaScript pur, il ne sait même pas que React existe. B est le seul à toucher au renderer : View, Text et styles viennent de react-native, un paquet absent du projet web — l'import échoue, et même s'il passait, JSX chercherait des composants introuvables. La ligne de partage n'est donc pas « hook ou composant », c'est « est-ce que ce fichier nomme une primitive de sortie ? ». En pratique, tes dossiers hooks/ et lib/ traversent presque intégralement ; ton dossier components/, presque pas.

La table de traduction des primitives

Voici le cœur pratique du module : la table de correspondance, dans le sens mobile → web cette fois. Tu as appris l'aller dans « React Native : les briques natives » ; voici le retour. Lis la troisième colonne attentivement — c'est là que vivent les nuances qui font qu'une traduction naïve produit un résultat bancal.

React Native React web Ce qui change vraiment
<View> <div> — ou mieux, une balise sémantique Sur le web, div n'est que la boîte neutre. Si la boîte a un rôle, préfère section, article, nav, form (module « HTML sémantique & accessibilité »).
<Text> <p>, <span>, <h1>… ou rien du tout Le point le plus déroutant : sur le web, le texte nu est parfaitement légal. <div>Bonjour</div> marche. En RN, ça plante.
<Pressable>, <TouchableOpacity> <button> (ou <a> si ça navigue) onPress devient onClick. Et surtout : le vrai button apporte gratuitement le focus clavier, Entrée/Espace, et l'annonce « bouton » au lecteur d'écran.
<TextInput> <input> / <textarea> onChangeText devient onChange, et il te donne un événement, pas une chaîne. Le type de l'input change le clavier mobile.
<Image> / expo-image <img src alt /> (puis next/image) La prop s'appelle src, pas source, et l'attribut alt est obligatoire. Next.js fournira plus tard un next/image optimisé.
<ScrollView> rien — le document défile tout seul Sur le web, la page entière défile par défaut. Tu n'enveloppes rien. On ne crée une zone défilante interne (overflow-y: auto) que si on la veut vraiment.
<FlatList>, <FlashList> un simple {items.map(...)} Pas de data/renderItem : tu écris la boucle toi-même. La virtualisation devient une optimisation optionnelle, pas le réflexe par défaut.
<SafeAreaView> rien Pas d'encoche, pas de barre système à éviter. Ce composant n'a aucun équivalent parce que le problème n'existe pas.
<Modal> <dialog> (natif) ou une bibliothèque Le <dialog> HTML gère le focus, la touche Échap et le fond bloquant, gratuitement.
<ActivityIndicator> un spinner maison (CSS) Le web n'a pas de spinner natif. On l'anime en CSS, ou on utilise le composant de sa bibliothèque d'UI.
<KeyboardAvoidingView> rien Le navigateur fait défiler le champ focalisé dans le champ de vision tout seul.

Trois lignes de ce tableau méritent qu'on s'y attarde, parce qu'elles ne sont pas de simples renommages.

Le texte, d'abord. La règle non négociable de React Native — « tout texte vit dans un <Text>, sinon l'app plante » — n'a aucun équivalent sur le web : <div>Bonjour</div> est parfaitement légal. La raison est généalogique. Le HTML est né comme un format de documents texte, où le texte est citoyen de première classe et les balises de simples annotations autour de lui ; les systèmes natifs sont nés comme des systèmes de vues, où afficher du texte demande un objet particulier (un UILabel). Conséquence pratique : cette liberté ne veut pas dire que tout se vaut. Un <p> pour un paragraphe, un <h2> pour un titre — pas pour éviter un crash, mais pour le sens, l'accessibilité et le SEO. Le web te fait confiance ; à toi de mériter cette confiance.

Le défilement, ensuite. En RN, un écran trop long est simplement coupé, et c'est le ScrollView qui rend le défilement. Sur le web, c'est l'inverse : le document défile par défaut, et ce depuis 1993 — le « composant qui rend ma page défilable » n'existe pas. La question web est symétrique : « ai-je besoin d'une zone qui défile indépendamment du reste ? ». Si oui, overflow-y: auto et une hauteur sur cette boîte-là. Sinon, tu ne fais rien.

Les listes, enfin. Sur mobile, la virtualisation — ne rendre que les éléments visibles et recycler leurs vues au défilement — est presque un réflexe : 800 exercices dans un ScrollView, et l'app rame. Le navigateur, lui, encaisse des milliers de nœuds DOM sans broncher : écris un .map() tout bête, et ne virtualise que si tu as mesuré un problème. Les bibliothèques existent (TanStack Virtual, react-window) mais elles coûtent cher — hauteurs à connaître, accessibilité à surveiller, Ctrl+F qui ne trouve plus le texte hors écran.

📅 Dans RendezVous

La liste des services de la clinique — consultation générale, physiothérapie, vaccination, prise de sang — fait quatre éléments. En RN, tu aurais peut-être sorti une FlashList par habitude. Sur le web, ce serait une faute de goût : quatre <li> produits par un .map() dans un <ul>, point final. La virtualisation d'une liste de quatre éléments coûte plus cher qu'elle ne rapporte, et elle casse la recherche navigateur. Choisis l'outil à la taille du problème — c'est un réflexe d'ingénieur, pas un réflexe web.

Les événements : là où ça trébuche vraiment

On arrive au piège numéro un du passage mobile → web, celui qui fait perdre vingt minutes à tout le monde au moins une fois. Le renommage onPressonClick est trivial : ton éditeur t'engueule si tu te trompes. Le vrai piège est ailleurs, et il est silencieux.

En React Native, onChangeText te donne directement la chaîne saisie — une commodité de l'équipe React Native, d'où l'écriture minimaliste onChangeText={setEmail}. Sur le web, onChange te donne un objet événement : quel élément a été touché, quel type d'événement, à quel moment, quelles touches modificatrices étaient enfoncées. La valeur du champ est cachée dedans, dans event.target.valuetarget, « la cible », étant l'élément du DOM sur lequel l'événement s'est produit.

// ─────────── React Native ───────────
// onChangeText reçoit DIRECTEMENT la chaîne saisie.
<TextInput value={fullName} onChangeText={setFullName} />

// ─────────── React web ───────────
// onChange reçoit un ÉVÉNEMENT ; la valeur est dedans.
<input value={fullName} onChange={(e) => setFullName(e.target.value)} />

// ⛔ Le piège : ceci « marche » sans erreur, mais range
//    l'objet événement entier dans l'état au lieu du texte.
<input value={fullName} onChange={setFullName} />

La dernière ligne est le réflexe mobile mal transposé. TypeScript t'en protège souvent, mais en JavaScript pur elle passe sans un mot : ton état contient un objet, ton champ affiche [object Object] ou reste vide, et tu cherches pendant vingt minutes.

Ce qu'une frappe traverse, des deux côtés
React Native — onChangeText
  1. Tu tapes « M »dans le TextInput
  2. onChangeText('M')la chaîne arrive telle quelle rien à extraire
  3. setFullName('M')l'état contient « M »
React web — onChange
  1. Tu tapes « M »dans l'<input>
  2. onChange(e)e est un événement, pas une chaîne
  3. e.target.value → 'M'on va chercher la valeur dans l'événement l'étape en plus — celle qu'on oublie
  4. setFullName('M')l'état contient « M »
Les deux chemins finissent au même endroit, mais celui du web compte une étape de plus, et c'est exactement là qu'est le bug. Écrire onChange={setFullName}, c'est sauter la troisième marche : ce n'est pas la chaîne qui part dans l'état, c'est l'événement lui-même. Rien ne proteste, parce que ranger un objet dans un état est parfaitement légal.

Sous toutes ces écritures, le patron ne change pas : c'est Le nom d'événement et son callback, isolé au module Le navigateur : DOM, événements, rendu. En vanilla JS tu écrivais element.addEventListener('input', callback) ; ici tu écris onChange={callback}. React pose l'écouteur à ta place et enveloppe l'événement, mais le couple — un nom d'événement, une fonction à rappeler — n'a pas bougé d'un millimètre. Ce qui a bougé, et c'est tout le piège ci-dessus, c'est ce que ce callback reçoit en argument.

⚠️ Piège fréquent — croire que onChange donne la valeur

Grave-le : onChange donne un événement, pas une valeur. Va la chercher avec e.target.value — ou e.target.checked pour une case à cocher, qui est un booléen et non une chaîne. Et ne t'arrête pas à e.target tout seul : setFullName(e.target) range un élément du DOM dans ton état React, à peu près la pire chose qu'on puisse y mettre. Méfie-toi enfin du nom : onChange en HTML pur ne se déclenche qu'à la perte du focus, alors que le onChange de React se déclenche à chaque frappe — React l'a uniformisé sur oninput. C'est bien l'équivalent de ton onChangeText, malgré le nom trompeur.

Les événements synthétiques, expliqués simplement

Le e que tu reçois n'est pas l'événement natif du navigateur : c'est un SyntheticEvent, une enveloppe que React met autour, avec la même interface (target, preventDefault(), stopPropagation()…). Pourquoi ? Raison historique : les navigateurs ont longtemps eu des implémentations divergentes, et l'enveloppe les normalise. Raison architecturale : React n'attache pas un écouteur par bouton, il en attache quelques-uns à la racine et se sert de la propagation — la remontée des événements dans l'arbre du DOM, détaillée dans « Le navigateur : DOM, événements, rendu ». Une page avec mille boutons a une poignée d'écouteurs, pas mille.

La conséquence pratique la plus visible, c'est preventDefault(). Certains éléments HTML ont un comportement par défaut câblé dans le navigateur : un <form> soumis recharge la page, un <a> cliqué navigue, une case à cocher se coche. e.preventDefault() dit « n'applique pas ce comportement par défaut, je m'en occupe ». Sur mobile, cette notion n'existe carrément pas — aucune primitive React Native n'a de comportement par défaut à annuler. C'est du pur web, et tu vas l'écrire des centaines de fois.

📅 Dans RendezVous

Le formulaire de coordonnées de la clinique illustre les deux idées d'un coup. Remarque que le gestionnaire est sur le <form> (via onSubmit) et pas sur le bouton : c'est le formulaire qui se soumet, pas le bouton qui agit. Bénéfice gratuit : appuyer sur Entrée dans un champ soumet le formulaire, exactement comme un utilisateur s'y attend.

<form onSubmit={(e) => {
  // Sans ceci, le navigateur RECHARGE la page et ton React
  // est réinitialisé : état perdu, requête jamais partie.
  e.preventDefault();
  submitRequest({ fullName, email, service });
}}>
  <label htmlFor="nom">Nom complet</label>
  <input
    id="nom"
    value={fullName}
    onChange={(e) => setFullName(e.target.value)}
  />

  {/* type="submit" : c'est LUI qui déclenche onSubmit du form. */}
  <button type="submit">Confirmer le rendez-vous</button>
</form>

Trois détails web à repérer : onSubmit sur le form, e.preventDefault() en toute première ligne, et htmlFor sur le label (on y revient au section « Le style : de StyleSheet aux classes CSS »). Les modules « Formulaires 1 — contrôlé ou non » et « Formulaires 2 — validation & erreurs » démontent ce patron en détail.

React NativeReact webCe que reçoit ton gestionnaire
onPressonClickun événement (souvent ignoré)
onLongPressonContextMenu (approximatif)un événement
onChangeTextonChangeun événemente.target.value
onSubmitEditingonSubmit (sur le form)un événement → e.preventDefault()
onFocus / onBluronFocus / onBluridentiques (mais bien plus importants sur le web)
n'existe pasonMouseEnter, onMouseLeavele survol — un concept sans équivalent tactile
n'existe pasonKeyDown, onKeyUple clavier physique
✍️ Exercice de lecture

Un collègue a traduit ce composant de Halterofit vers le web. La version du bas — la traduction — contient trois erreurs. Trouve-les avant d'ouvrir le corrigé : aucune ne provoque un message d'erreur évident.

// ─────────── L'ORIGINAL (React Native) ───────────
function NoteField({ note, setNote }) {
  return (
    <View style={styles.field}>
      <Text>Note de séance</Text>
      <TextInput value={note} onChangeText={setNote} />
    </View>
  );
}

// ─────────── LA TRADUCTION (React web) ───────────
function NoteField({ note, setNote }) {
  return (
    <div class="field">
      <Text>Note de séance</Text>
      <input value={note} onChangeText={setNote} />
    </div>
  );
}
Voir le corrigé

Erreur 1 — class au lieu de className. En JSX, l'attribut s'écrit className (le pourquoi est au section « Le style : de StyleSheet aux classes CSS »). Écrit class, React ignore purement et simplement l'attribut : ton style ne s'applique pas et aucune erreur ne s'affiche. Tu cherches dans le CSS un bug qui est dans le JSX. Même famille : htmlFor et non for.

Erreur 2 — <Text> n'existe pas sur le web. Il vient de react-native, absent ici. La balise commençant par une majuscule, JSX la traite comme un composant et cherche une variable Text — introuvable, plantage à l'exécution. La bonne traduction est une vraie balise HTML : <label>, puisque c'est l'étiquette du champ. Note le renversement : écrire le texte nu dans le div aurait aussi été légal.

Erreur 3 — onChangeText n'existe pas sur le web. La plus vicieuse : React ne connaît pas cette prop, la traite comme un attribut inconnu, l'ignore et ne plante pas. Le champ ne se met jamais à jour, il paraît « gelé », et rien n'explique pourquoi. Il faut onChange={(e) => setNote(e.target.value)}. Bonus qualité : la version correcte relierait le label à l'input via htmlFor et id (« HTML sémantique & accessibilité ») — pas un bug, mais un évaluateur de test technique le remarquera.

🧠 Quiz éclair

Ce champ ne se met jamais à jour, et la console reste muette. Que contient fullName après la première frappe, et qu'affiche le champ ?

const [fullName, setFullName] = useState('');

// Traduit d'un <TextInput value={fullName} onChangeText={setFullName} />
<input value={fullName} onChange={setFullName} />

setFullName reçoit l'objet événement, pas le texte : après la première frappe, ton état contient un SyntheticEvent. Ce même objet repart ensuite dans value, que React convertit en chaîne pour l'écrire dans le DOM — le champ affiche [object Object] et n'accepte plus rien de cohérent. Aucune erreur n'est levée : en JavaScript pur, ranger un objet dans un état est parfaitement légal. Correctif : onChange={(e) => setFullName(e.target.value)}. C'est la seule prop d'événement du web qui ne se traduit pas par un simple renommage — onChangeText te livrait la chaîne, onChange te livre l'événement et te laisse aller chercher la valeur dedans.

Le style : de StyleSheet aux classes CSS

Tes deux façons de styler sur mobile — les objets de StyleSheet.create et les classes NativeWind — existent toutes deux sur le web, mais l'équilibre s'inverse. La voie principale du web, c'est la classe CSS. Ce n'est pas une question de goût : elle seule donne accès aux media queries, aux pseudo-classes (:hover, :focus, :disabled), aux animations, aux variables CSS et à la cascade. Un objet de style en ligne ne sait exprimer qu'un état statique.

💡 Pourquoi className et pas class ?

La raison est purement mécanique. Le JSX n'est pas du HTML : c'est du sucre pour des appels de fonction JavaScript. <div class="x"> se compilerait en un objet dont une propriété s'appellerait class… un mot réservé du langage. React a donc emprunté le nom que le DOM utilise déjà : element.className. Même histoire pour for, le mot-clé de la boucle, devenu htmlFor. Ce sont les deux seules exceptions notables : tout le reste garde son nom HTML, en camelCase (tabIndex, maxLength, readOnly, autoComplete).

Le style en ligne existe aussi, sous la forme que tu connais : la prop style prend un objet JavaScript en camelCase. Une seule vraie différence, les unités — un nombre nu est interprété en pixels ({{ marginTop: 16 }} devient margin-top: 16px) et toute autre unité s'écrit en chaîne ({{ width: '50%' }}, {{ fontSize: '1.25rem' }}), là où les nombres de RN sont des unités indépendantes de la densité de l'écran.

// Style EN LIGNE (web) : objet camelCase.
// Nombre nu = pixels ; toute autre unité = chaîne de caractères.
<div style={{ marginTop: 16, width: '50%', fontSize: '1.25rem' }} />

// Style par CLASSE : la voie normale du web.
<div className="carte carte--active" />

// Classes conditionnelles : c'est juste du JavaScript qui
// fabrique une chaîne — même astuce que sur mobile.
<button className={`btn ${isActive ? 'btn--actif' : ''}`} />

Le style en ligne est réservé aux valeurs calculées (une largeur de barre de progression, une couleur venue de données). Tout le reste passe par des classes : elles seules donnent accès à :hover, aux media queries et à la cascade.

Et Tailwind ? NativeWind est Tailwind porté à React Native : mêmes noms de classes, même échelle d'espacement, même modèle mental. Ton bagage se transfère presque intégralement. Ce que tu gagnes en plus, ce sont les préfixes d'état et de largeur qui n'avaient aucun sens sur un téléphone : hover:bg-blue-600, focus:ring-2, md:flex-row, dark:bg-slate-900. Chacun correspond à une pseudo-classe ou une media query du module « CSS 2 — grid, responsive, variables ». Tu ne réapprends pas Tailwind : tu débloques la moitié qui dormait.

🧭 Un dernier détail qui pique

En React Native, la mise en page est déjà en Flexbox, en colonne par défaut. Sur le web, un <div> est en display: block, et dès que tu écris display: flex, la direction par défaut est la ligne. Tu écris flex en pensant « empiler verticalement » et tout se met côte à côte : sur le web, colonne = flex flex-col, toujours explicite (module « CSS 1 — modèle de boîte & flexbox »).

display: flex — les mêmes trois enfants, deux directions
display: flex — le défaut du web
Nom
Courriel
Envoyer
display: flex + flex-direction: column
Nom
Courriel
Envoyer
Rien ne change entre les deux scènes sauf flex-direction. Le réflexe mobile est de lire display: flex comme « empiler », parce qu'en React Native la colonne est le défaut ; sur le web c'est la ligne. D'où la règle : la colonne s'écrit toujours, elle ne se suppose jamais. Et attention au cran d'avant : un <div> auquel tu n'as rien déclaré n'est pas flex du tout — il est display: block, et ses enfants prennent chacun toute la largeur, tout en restant les uns sous les autres.
🧠 Quiz éclair

La barre de progression devait occuper la moitié de la largeur de sa carte. À l'écran, elle en occupe cinquante pixels — un trait minuscule. Où est l'erreur ?

<div className="jauge">
  <div style={{ width: 50, height: 8, backgroundColor: 'teal' }} />
</div>

Sur le web, un nombre nu dans un objet de style est interprété en pixels : width: 50 devient width: 50px. Toute autre unité doit s'écrire en chaîne de caractères — ici width: '50%'. Le height: 8, lui, est correct : on voulait bien huit pixels. Le piège est silencieux parce que ton réflexe mobile produit exactement la même écriture : en React Native aussi 50 n'est pas un pourcentage, mais il y exprime des points indépendants de la densité de l'écran, alors qu'ici c'est un pixel CSS, fixe. Et la leçon de la section par-dessus : une largeur relative n'a de toute façon rien à faire en style en ligne — c'est le genre de valeur qui vit dans une classe, là où les media queries pourront la reprendre.

Ce qui n'existe que d'un côté

Jusqu'ici on a traduit. Cette section-ci parle de ce qui n'a pas de traduction, dans les deux sens. C'est souvent là que les entrevues creusent : ça révèle si tu as compris la plateforme ou seulement mémorisé un tableau.

Ce qui n'existe que sur le web

Le HTML sémantique. En RN, tout est View ; le sens vit dans le nom de tes composants et dans ta tête. Sur le web, la balise elle-même porte le sens, et le navigateur en tire des conséquences réelles : navigation par titres pour les lecteurs d'écran, régions repérables, comportements clavier gratuits (« HTML sémantique & accessibilité »).

L'URL, et c'est énorme. Sur mobile, la navigation est une pile d'écrans qui vit dans la mémoire de l'app et meurt avec elle. Sur le web, chaque état de l'interface qui mérite d'exister a une adresse : elle se copie, se colle dans un message, se met en favori, s'indexe par Google, revit intacte demain sur un autre ordinateur. C'est un état partageable et persistant — la caractéristique la plus profonde du web.

🔗 Pont — l'URL comme état, la pile comme mémoire

Dans Halterofit, quand tu ouvres le détail d'un exercice, Expo Router empile un écran. Cet état — « je regarde le développé couché » — n'existe que dans la mémoire du téléphone. Tu ne peux pas l'envoyer à quelqu'un. Sur le web, le même écran serait /exercices/developpe-couche : une chaîne de caractères que tu peux texter à un ami, et qui reconstruit exactement le même écran chez lui. Retourne la perspective : sur le web, une partie de ton état applicatif vit dans la barre d'adresse. Le filtre actif d'une liste, la page de pagination, l'onglet sélectionné, le terme recherché — tout ça mérite souvent d'être dans l'URL plutôt que dans un useState.

C'est un déplacement mental important, et Next.js en fait une colonne vertébrale : le module « Routing : segments, layouts, navigation » montrera que l'arborescence de tes dossiers devient l'arborescence de tes URL. Le bouton « retour » du navigateur, lui, est le corollaire gratuit : puisque chaque état a une adresse, l'historique sait revenir en arrière — et casser ce bouton est l'un des péchés capitaux du web.

Le clavier et le focus. Sur le web, il y a en permanence un élément qui a le focus, l'utilisateur le déplace avec Tab, et tout un pan de l'accessibilité tient à ce que ce parcours soit logique et visible — un réflexe entièrement neuf pour toi. Le survol : :hover n'a aucun sens sur un écran tactile, mais c'est sur le web un signal d'affordance central (« ceci est cliquable »). Comme un navigateur tourne aussi sur téléphone, la règle est : le survol enrichit, il ne doit jamais être le seul accès à une information.

L'onglet, le SEO, le partage. Ta page a un titre d'onglet, une description que Google lira, une image d'aperçu quand on la partage ; une app mobile a une icône sur un écran d'accueil, et c'est tout. Conséquence architecturale majeure : c'est en grande partie pour le SEO et la vitesse de premier affichage que Next.js a inventé le rendu côté serveur, sujet des modules « La carte de Next.js » et « Server et Client Components ».

Ce qui n'existe que sur mobile

  • Les permissions natives — caméra, micro, contacts, notifications push, localisation en arrière-plan, santé. Le web a des équivalents partiels, bien plus restreints, systématiquement soumis à une action explicite de l'utilisateur.
  • Le cycle de vie de l'application — l'AppState d'Halterofit n'a pas d'équivalent direct. Le web a des cousins (visibilitychange, le focus de la fenêtre), mais un onglet peut être suspendu, fermé d'un coup ou dupliqué, sans cérémonie.
  • Les gestes natifs et les animations hors du fil JavaScript — Reanimated et les gestionnaires de gestes ne se transposent pas : le web a d'excellents outils d'animation, mais pas le même modèle d'exécution.
  • Le stockage local franc — MMKV, SQLite via WatermelonDB, un système de fichiers : ton app possède réellement de l'espace sur l'appareil. Le web a localStorage (petit, synchrone, texte) et IndexedDB (puissant mais verbeux), dans un bac à sable que l'utilisateur vide d'un clic.

Le point d'entrée d'une app React web

Voici une question d'entrevue junior à laquelle beaucoup de gens qui « font du React » ne savent pas répondre, parce que leur outillage la leur a toujours cachée : comment une application React démarre-t-elle, concrètement ? Tout commence par un fichier HTML d'une pauvreté remarquable. Le navigateur ne sait faire qu'une chose — télécharger un document et le transformer en DOM — et n'a jamais entendu parler de React : il lui faut donc un document, et ce document contient essentiellement une boîte vide et un script.

index.html
<!DOCTYPE html>
<html lang="fr">
  <head><title>Clinique RendezVous</title></head>
  <body>
    <!-- La boîte vide. TOUTE l'app vivra là-dedans. -->
    <div id="root"></div>

    <!-- Le script qui va la remplir. -->
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>

Ouvre le code source d'une application React classique dans ton navigateur : tu verras littéralement ça. Une page vide et un script. Tout le reste est fabriqué en JavaScript, après coup — d'où le nom de SPA, « Single Page Application ».

Le script, lui, tient en deux lignes utiles. Il trouve la boîte vide dans le DOM, crée une racine React attachée à cette boîte, et lui demande d'y peindre ton composant principal.

📖 La formule

Le clou que Next.js enfonce à ta place

import { createRoot } from 'react-dom/client';
import App from './App';

// 1. On trouve la boîte vide définie dans index.html
// 2. createRoot en fait un « territoire » géré par React
// 3. render y peint l'arbre décrit par <App />
createRoot(document.getElementById('root')).render(<App />);

C'est tout ce que « monter React » veut dire : désigner un nœud du DOM et dire à React « à partir d'ici, c'est toi qui commandes ». Retiens-la quand même, même si Next.js te la cachera entièrement dès le module « La carte de Next.js » — c'est exactement ce qui te permettra de comprendre ce que Next.js fait à ta place.

Compare avec le mobile : dans une app Expo, tu n'as jamais écrit ces lignes — tu as registerRootComponent(App), souvent caché par Expo Router. Le rôle est rigoureusement le même : désigner le sommet de l'arbre et le brancher sur une surface d'affichage. Seule la surface change. Le vocabulaire aussi appartient à React et pas à une plateforme : monter (le premier rendu, celui qui crée les nœuds), démonter (la disparition, celle qui déclenche le nettoyage des useEffect). Dernier détail : ReactDOM.render(...) sans createRoot, croisé dans un vieux tutoriel, c'est l'API d'avant React 18, dépréciée.

🧠 Quiz éclair

La page est entièrement blanche — pas un pixel, pas un titre. Le serveur a pourtant bien livré l'index.html, et le fichier main.tsx a bien été téléchargé. Que s'est-il passé ?

<!-- index.html -->
<body>
  <div id="app"></div>
  <script type="module" src="/src/main.tsx"></script>
</body>
// src/main.tsx
createRoot(document.getElementById('root')).render(<App />);

Les deux identifiants ne concordent pas : la boîte s'appelle app, le script réclame root. document.getElementById('root') rend donc null, et createRoot(null) lève une erreur — « Target container is not a DOM element ». Rien n'est monté.

Retiens surtout la conséquence, qui dépasse la faute de frappe : dans une SPA, le document livré est une page vide. La moindre erreur avant le premier render ne laisse pas une page dégradée, elle ne laisse rien — contrairement à une page HTML classique, où le contenu était déjà dans le document. C'est l'une des raisons pour lesquelles Next.js envoie du HTML déjà rendu, et une bonne raison de savoir ce qu'il écrit à ta place.

Les listes et les clés

Sur mobile, une liste passe par FlatList ou FlashList, avec ses props data et renderItem. Sur le web, la mécanique est plus nue : tu écris toi-même le .map() qui transforme un tableau de données en tableau d'éléments JSX, et React sait afficher un tableau d'éléments.

📅 Dans RendezVous
const services = [
  { id: 'gen',    name: 'Consultation générale', duration: 20 },
  { id: 'physio', name: 'Physiothérapie',         duration: 45 },
  { id: 'vacc',   name: 'Vaccination',             duration: 15 },
];

// Une liste sémantique (ul/li), pas un empilement de div.
<ul className="services">
  {services.map((s) => (
    <li key={s.id}>
      <h3>{s.name}</h3>
      <p>{s.duration} minutes</p>
      <button type="button" onClick={() => selectService(s.id)}>Choisir</button>
    </li>
  ))}
</ul>

Trois réflexes web d'un coup : la boucle est un .map() ordinaire, la structure est sémantique (ul / li, cf. « HTML sémantique & accessibilité »), et la key porte l'identifiant métier — jamais la position.

La key et la réconciliation, tu les connais depuis « Le modèle mental de React » : une identité stable permet à React de déplacer un nœud existant au lieu de le détruire et d'en recréer un autre. Ce que le web ajoute, c'est l'enjeu. Un nœud du DOM transporte de l'état invisible, que React ne possède pas et ne sait pas reconstruire : le texte tapé dans un <input> non contrôlé — un champ dont la valeur vit dans le DOM et non dans un état React, notion démontée dans « Formulaires 1 — contrôlé ou non » —, la position du curseur, l'élément qui a le focus, la case cochée, la position de défilement, une animation en cours. Sur mobile, cette réserve d'état hors-React est minuscule ; sur le web, elle est partout. D'où le renversement : une mauvaise key ne « ralentit » pas, elle fait sauter des données d'une ligne à l'autre. L'exercice qui suit en est le cas d'école.

✍️ Exercice de lecture

Sur la page d'administration de RendezVous, on affiche les demandes en attente. Chaque ligne a une case à cocher « traitée », et un bouton permet de trier la liste par date ou par nom — donc de réordonner les lignes. Voici le code. Il fonctionne « en apparence ». Quel bug va apparaître, et pourquoi ?

{sortedRequests.map((request, index) => (
  <li key={index}>
    <input type="checkbox" />   {/* case NON contrôlée */}
    <span>{request.name} — {request.service}</span>
  </li>
))}
Voir le corrigé

Le bug : l'utilisateur coche la case de « Marie Tremblay », puis change le tri. Les lignes se réordonnent… mais la coche ne suit pas Marie. Elle reste à la position où elle était, et se retrouve sur la demande de quelqu'un d'autre. Sur une page d'administration de clinique, c'est un vrai problème : on marque comme traitée la mauvaise demande.

Pourquoi : la key vaut index, c'est-à-dire la position. Or après le tri, la position 0 ne désigne plus la même demande. Vu de React, l'élément key=0 existait avant et existe encore : il conclut « c'est le même élément, seul son texte a changé » et se contente de mettre à jour le <span>. Il réutilise le nœud <input> existant — donc son état de coche, qui vit dans le DOM et non dans React puisque la case n'est pas contrôlée. La coche est restée collée à la position alors que l'utilisateur l'associait à la personne.

Le correctif : key={request.id}. Avec une identité stable, React comprend que la ligne de Marie s'est déplacée, il déplace le nœud entier — coche comprise — et tout reste cohérent.

Quand key={index} est-il acceptable ? Uniquement si les trois conditions sont réunies : la liste ne change jamais d'ordre, on n'insère et on ne supprime jamais au milieu, et les éléments ne portent aucun état interne. Autrement dit, une liste statique d'affichage pur. Au moindre doute, utilise un identifiant réel. C'est exactement la même règle sur mobile, avec les mêmes conséquences dans une FlashList.

La coche suit la position, ou elle suit la personne
avant le tri — l'utilisateur coche Marie
[x] Marie Tremblay
[ ] Alex Roy
après le tri, key={index}
[x] Alex Roy
[ ] Marie Tremblay
après le tri, key={request.id}
[ ] Alex Roy
[x] Marie Tremblay
Les trois listes contiennent les mêmes demandes ; seule la key change. Avec l'index, React voit « l'élément nº 0 existait, il existe encore » : il réutilise le nœud <input> de la première ligne, donc sa coche — qui vit dans le DOM, pas dans React, puisque la case n'est pas contrôlée. La coche est restée collée à la position. Avec l'identifiant, React comprend que la ligne de Marie s'est déplacée, il déplace le nœud entier, et la coche part avec elle.

Le rendu conditionnel, et le piège du zéro

Les trois techniques — le && pour « affiche seulement si », le ternaire ? : pour « ceci ou cela », l'early return pour les cas d'exception franche — sont identiques des deux côtés, tu les emploies déjà. Une seule chose est à graver, parce que tout le piège qui suit en découle : React n'affiche rien pour null, undefined, true et false — et rien d'autre n'est ignoré.

⚠️ Piège fréquent — le 0 qui s'affiche tout seul

Regarde cette ligne, apparemment inoffensive :

{freeSlots.length && <p>{freeSlots.length} créneaux libres</p>}

Quand il n'y a aucun créneau, freeSlots.length vaut 0. En JavaScript, 0 est une valeur falsy : l'opérateur && s'arrête et renvoie 0 — pas false, mais le nombre zéro. Or React n'ignore que null, undefined, true et false ; un nombre, il l'affiche. Tu te retrouves donc avec un « 0 » orphelin planté au milieu de ta page, sans explication. Le même piège existe avec la chaîne vide dans certains contextes.

La parade : convertis toujours ta condition en vrai booléen. {freeSlots.length > 0 && ...} est la forme la plus lisible ; {Boolean(freeSlots.length) && ...} ou un ternaire explicite {n > 0 ? <p>…</p> : null} font aussi l'affaire. Prends l'habitude de te méfier dès que la gauche d'un && est un nombre ou une longueur.

Et sur mobile ? Le même code produit un dégât différent — et plus spectaculaire. React Native tente d'afficher le nombre 0 hors d'un <Text>, ce qui viole la règle stricte de la plateforme : l'app plante avec « Text strings must be rendered within a <Text> component ». C'est un bel exemple de la logique de ce module : la cause est la même, la conséquence dépend du renderer. Sur le web, un bug visuel silencieux ; sur mobile, un crash bruyant. Ironie du sort, le crash est plus facile à corriger.

✍️ Exercice de lecture

Voici l'en-tête de la page RendezVous. Trois lignes conditionnelles. Une seule est correcte — dis laquelle, et ce que les deux autres affichent au premier chargement de la page : la clinique est complète (aucun créneau), l'utilisateur n'a pas encore choisi de service, et aucun message d'erreur n'est en cours (message vaut la chaîne vide).

// A
{slots.length && <p>Prochaine disponibilité : {slots[0].time}</p>}

// B
{selectedService && <p>Service : {selectedService.name}</p>}

// C
{message.length && <p className="erreur">{message}</p>}
Voir le corrigé

B est la bonne. selectedService vaut soit un objet, soit null / undefined tant que rien n'est sélectionné. Ces deux valeurs-là, React les ignore complètement : rien ne s'affiche, aucun résidu. C'est l'usage sain du && — à gauche, quelque chose qui est soit un objet, soit null.

A affiche un « 0 » orphelin. slots.length vaut 0, l'expression vaut 0, React affiche le chiffre. Correctif : slots.length > 0 && … — qui protège aussi l'accès slots[0].

C affiche également un « 0 ». Même mécanique : message vaut '', donc message.length vaut 0. Beaucoup « corrigent » en écrivant {message && …} : ça marche ici, React ignorant la chaîne vide, mais l'habitude est mauvaise. Écris {message !== '' && …} : explicite, et ça se lit sans réfléchir.

La règle à retenir en une phrase : à gauche d'un && dans du JSX, mets une comparaison, pas une longueur.

🧠 Quiz éclair

Pour un service sans attente, la ligne s'affiche « Physiothérapie0 ». D'où sort ce zéro, et pourquoi le même code plante-t-il sur mobile ?

function ServiceRow({ name, waitMinutes }) {
  return (
    <li>
      {name}
      {waitMinutes && <span> — {waitMinutes} min d'attente</span>}
    </li>
  );
}

waitMinutes vaut 0. En JavaScript, 0 && … s'arrête net et renvoie 0 — le nombre, pas false. Or React n'ignore que null, undefined, true et false ; un nombre, il l'affiche. D'où le zéro collé au nom du service.

Note bien que le piège n'a rien à voir avec .length : n'importe quel nombre à gauche d'un && le déclenche, et 0 est justement la valeur qui veut dire « rien à montrer ». Correctif : {waitMinutes > 0 && …}. Sur React Native, le même 0 se retrouve hors d'un <Text> et fait planter l'app : cause identique, dégât différent selon le renderer.

Ce que tu dois désapprendre

On a beaucoup parlé de ce qui se transfère. Terminons par la courte liste de ce qui, au contraire, va te trahir — des automatismes tellement ancrés que tu les appliqueras sans même y penser.

1. Les nombres nus ne sont pas les mêmes unités. En RN, width: 200 exprime 200 points indépendants de la densité. Sur le web, 200 veut dire 200 pixels CSS, une unité fixe qui ne s'adapte à rien — ni à la fenêtre, ni aux préférences de taille de police de l'utilisateur. D'où rem (relatif à la police racine, donc respectueux des réglages d'accessibilité), %, vw / vh, ch, et clamp(). Le réflexe : sur le web, tout en pixels fixes est un choix, pas un défaut (module « CSS 2 — grid, responsive, variables »).

2. Rien n'est en flex, et flex est en ligne. Un div est display: block, et un conteneur flex empile en ligne par défaut. Colonne = flex-direction: column, explicite, toujours. 3. Le texte n'a pas besoin d'être enveloppé. Ta discipline mobile va te faire chercher un composant Text qui n'existe pas : écris le texte, et choisis la balise pour son sens (p, h2, label, strong).

4. Le CSS cascade, et ça change tout. C'est la différence de fond, celle qui va au-delà des noms. En React Native, les styles sont isolés : un style ne s'applique qu'à son composant et personne ne peut l'écraser à distance. Sur le web, le CSS est global, à cascade et à héritage : une règle s'applique à tout ce qui correspond à son sélecteur, certaines propriétés descendent aux enfants (color, font-family…), et la spécificité tranche les conflits. Une classe écrite par un collègue à l'autre bout du projet peut donc légalement changer l'apparence de ton composant. Ce n'est pas un défaut, c'est le modèle — et c'est exactement le problème que résolvent les CSS Modules, les composants stylés et Tailwind : recréer de l'isolation dans un système global par nature.

5. Le clavier et le focus existent. Une modale qui ne rend pas le focus au bouton qui l'a ouverte, un menu qu'on ne ferme pas avec Échap, un contour de focus supprimé « parce que c'est moche » : des défauts réels, d'une catégorie neuve pour toi. 6. Le premier rendu compte. Sur mobile, le code est déjà installé ; sur le web, chaque visiteur télécharge ton JavaScript avant de voir quoi que ce soit. Poids du paquet et temps avant premier affichage sont des préoccupations quotidiennes — et la raison d'être d'une bonne partie de Next.js.

📅 Dans RendezVous

Bilan de ce que tu sais déjà écrire pour la page de prise de rendez-vous, maintenant que ce module est fini : les cartes de service sont un .map() avec des key métier ; le formulaire est une poignée de champs contrôlés (value + onChange avec e.target.value) exactement comme tes TextInput ; l'envoi se fait sur onSubmit du <form> avec e.preventDefault(), puis un fetch (module « HTTP & fetch : parler au réseau ») ; l'état de chargement et les erreurs s'affichent avec un ternaire et un && prudent. C'est du React que tu écris déjà. Ce qui reste à apprendre — et qui occupe le reste du guide — ce n'est pas React : c'est ce que Next.js apporte par-dessus.

🎤 En entrevue

« Tu viens du mobile. Qu'est-ce qui change quand tu passes à React web ? »

« Je le découpe en trois. Ce qui ne change pas, d'abord, et c'est l'essentiel : React est un moteur, et le moteur est le même. Composants, props, état, hooks, contexte, réconciliation, flux unidirectionnel — identiques. Seul le renderer change : react-dom au lieu de react-native. »

« Ce qui change de nom, ensuite : View devient div, Text devient p, Pressable devient button, onPress devient onClick. Avec un piège que j'ai retenu : onChangeText me donnait directement la chaîne, alors que onChange me donne un événement — c'est e.target.value qu'il faut lire. »

« Et ce qui change de nature, qui est la partie intéressante : le HTML porte du sens et il faut le respecter, l'URL est un état partageable au lieu d'une pile en mémoire, le clavier et le focus existent vraiment, et le CSS cascade au lieu d'être isolé par composant. Honnêtement, le vocabulaire s'apprend en quelques jours ; ce sur quoi je fais attention, ce sont ces différences de comportement. »

« C'est quoi, la key dans une liste React, et pourquoi c'est important ? »

« C'est l'identité stable d'un élément à travers les re-renders. Quand React réconcilie une liste, il doit décider quel ancien élément correspond à quel nouveau. Sans key, il compare position par position ; avec, il compare par identité, ce qui lui permet de déplacer un nœud existant plutôt que de le détruire et de le recréer. »

« Mais le vrai enjeu n'est pas la vitesse, c'est la correction. Quand React réutilise le bon nœud, il conserve l'état invisible qui y est attaché : le texte tapé dans un champ non contrôlé, le focus, une case cochée, la position de défilement. Avec une mauvaise clé, ces états sautent d'une ligne à l'autre — j'ai vu une case cochée suivre la position au lieu de suivre la personne quand la liste se réordonnait, et ce n'est pas un problème de performance, c'est un bug. »

« Sur l'index : key={index} me va pour une liste qui ne se réordonne jamais, sans insertion au milieu et sans état interne. Dès qu'une de ces trois conditions tombe, je prends un identifiant métier. »

« Quelle est la différence entre le state et les props ? »

La question la plus classique du monde — mais on juge la précision de ta réponse. « Les props sont les données qu'un composant reçoit de son parent : c'est son entrée, et vu de l'enfant elles sont en lecture seule. Le state, ce sont les données qu'un composant possède et qu'il peut faire évoluer dans le temps via useState. » Ajoute le critère de décision, c'est ce qu'on attend vraiment : « la vraie question n'est pas "state ou prop ?" mais qui possède cette donnée. Une donnée a un seul propriétaire ; les autres la reçoivent en prop. Si deux composants frères ont besoin de la même donnée, on la remonte chez leur parent commun — c'est le lifting state up. » Et une phrase pour clore : « les deux déclenchent un re-render quand elles changent, mais dans un sens unique : les données descendent en props, les événements remontent en callbacks. » Sur le web, tu peux glisser une troisième catégorie qui fait toujours bon effet : « et il y a un état qui n'appartient ni à l'un ni à l'autre — celui qui vit dans l'URL, comme un filtre ou une page. »

À retenir

Même React, renderer différent : le paquet react est identique, seul react-dom / react-native change. Hooks, props, état, contexte, JSX, réconciliation : transférés tels quels. Traduis les primitives — Viewdiv (ou une balise sémantique), Textp/span (ou rien : le texte nu est légal), Pressablebutton, TextInputinput, ScrollView→rien, FlashList.map(), SafeAreaView→rien. Les événements : onPressonClick, et surtout onChangeText(texte)onChange(event) avec e.target.value. Le style passe par des classes et className (jamais class) ; NativeWind devient Tailwind, enrichi de hover:, focus:, md:. Le point d'entrée tient en une ligne : createRoot(...).render(<App />). Les key portent une identité métier, jamais l'index d'une liste réordonnable. Et méfie-toi du 0 à gauche d'un &&.

Et ailleurs : la compétence réelle que tu viens d'acquérir n'est pas « la table des primitives » — ça se retrouve en trente secondes. C'est la capacité à séparer le modèle de la cible. Devant une technologie neuve, la bonne première question n'est jamais « quelle est la syntaxe ? » mais « qu'est-ce qui est le moteur conceptuel, et qu'est-ce qui n'est que l'organe de sortie ? ». Le moteur, tu ne l'apprends qu'une fois ; les organes, tu en changes toute ta carrière. C'est vrai de React (web, natif, terminal, PDF), de SwiftUI, de Jetpack Compose et de Flutter — tous déclaratifs — comme d'une base de données relationnelle quel que soit le dialecte SQL. En entrevue, « je connais React, et j'ai déjà changé de cible une fois » est bien plus solide qu'une liste de balises récitée.

🗂️ L'aide-mémoire
onChange
reçoit un événement : la valeur est e.target.value, la coche e.target.checked
Les deux mots réservés
classclassName, forhtmlFor. Tout le reste garde son nom HTML, en camelCase
Nombre nu en style={{}}
vaut des pixels ; toute autre unité s'écrit en chaîne ('50%', '1.25rem')
Ce que React n'affiche pas
null, undefined, true, false — et rien d'autre : un 0 s'affiche
Le point d'entrée
createRoot(document.getElementById('root')).render(<App />), depuis react-dom/client
key={index}
acceptable seulement si la liste ne se réordonne pas, n'a pas d'insertion au milieu, et ne porte aucun état interne
display: flex
empile en ligne sur le web ; la colonne s'écrit (flex-direction: column, flex-col)
Préfixes Tailwind gagnés
hover:, focus:, md:, dark: — sans équivalent NativeWind