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é.
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.
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.
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.
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.
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.
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 onPress → onClick
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.value — target, « 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.
-
Tu tapes « M »dans le
TextInput - onChangeText('M')la chaîne arrive telle quelle rien à extraire
- setFullName('M')l'état contient « M »
-
Tu tapes « M »dans l'
<input> -
onChange(e)
eest un événement, pas une chaîne - e.target.value → 'M'on va chercher la valeur dans l'événement l'étape en plus — celle qu'on oublie
- setFullName('M')l'état contient « M »
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.
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.
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 Native | React web | Ce que reçoit ton gestionnaire |
|---|---|---|
onPress | onClick | un événement (souvent ignoré) |
onLongPress | onContextMenu (approximatif) | un événement |
onChangeText | onChange | un événement → e.target.value |
onSubmitEditing | onSubmit (sur le form) | un événement → e.preventDefault() |
onFocus / onBlur | onFocus / onBlur | identiques (mais bien plus importants sur le web) |
| n'existe pas | onMouseEnter, onMouseLeave | le survol — un concept sans équivalent tactile |
| n'existe pas | onKeyDown, onKeyUp | le clavier physique |
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.
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.
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.
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 »).
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.
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.
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'
AppStated'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.
<!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.
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.
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.
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.
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.
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é.
0 qui s'affiche tout seulRegarde 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.
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.
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.
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.
« 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. »
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 —
View→div (ou une balise sémantique), Text→p/span
(ou rien : le texte nu est légal), Pressable→button,
TextInput→input, ScrollView→rien,
FlashList→.map(), SafeAreaView→rien. Les événements :
onPress→onClick, 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.
onChange- reçoit un événement : la valeur est
e.target.value, la cochee.target.checked - Les deux mots réservés
class→className,for→htmlFor. 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 : un0s'affiche- Le point d'entrée
createRoot(document.getElementById('root')).render(<App />), depuisreact-dom/clientkey={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