
SwiftUI n’est pas React
Je cherchais useState. Il n’y a pas de useState.
Quand j’ai commencé Renala, j’écrivais du React depuis des années. Interface déclarative, état local au composant, un DOM virtuel qui calcule les différences et met à jour le vrai DOM. Je comprenais ce système. Je m’attendais à ce que SwiftUI en soit une variante adaptée à la syntaxe Swift. Les composants seraient des Views, les hooks seraient des property wrappers, le réconcilieur serait quelque chose qu’Apple aurait écrit à la place de l’équipe React.
Sur la forme, j’avais raison. Sur tout le reste, j’avais tort.
Le modèle mental de React
Le modèle mental de React fonctionne ainsi : un composant est une fonction. Il reçoit des props, lit un état local, renvoie une description d’interface. Quand l’état change, la fonction s’exécute à nouveau. La nouvelle sortie est comparée à l’ancienne. Le DOM est patché.
On gère tout cela explicitement. On appelle useState pour créer un fragment d’état local et un setter. On appelle useEffect pour gérer les effets de bord, les abonnements, le chargement de données. On appelle useMemo pour éviter de recalculer des valeurs coûteuses. Le framework fournit des primitives, et on les assemble soi-même.
SwiftUI ne fonctionne pas ainsi.
En SwiftUI, une View est une struct. Pas une classe, pas une fonction : une struct. Pour qui vient de JavaScript : les structs sont des types valeur. Quand on passe une struct, on passe une copie, pas une référence. Pas d’identité partagée, pas d’objet persistant. SwiftUI crée et jette les structs View sans ménagement, comme des stack frames. Chaque View possède une propriété body qui renvoie une autre View. SwiftUI appelle body quand il a besoin de savoir à quoi ressemble la vue. C’est tout ce qu’est une View. On n’est pas censé stocker de l’état dans quelque chose d’aussi éphémère.
L’état vit ailleurs.
@Observable
La réponse moderne est @Observable. Pensez à un décorateur Python ou TypeScript : c’est une annotation à la compilation qui réécrit une classe pour que SwiftUI puisse suivre quelles propriétés une vue lit réellement. Quand une propriété suivie change, SwiftUI réévalue exactement les vues qui l’ont lue. Pas le parent. Pas les voisines. Uniquement les vues qui ont touché cette propriété précise.
Pas d’appel d’abonnement. Pas de tableau de dépendances. Pas de nettoyage. Pas de fermeture périmée à 2 h du matin. On annote une classe avec @Observable, et le suivi est automatique. La session WWDC 2023 Discover Observation in SwiftUI couvre le mécanisme en détail.
@Observable @MainActor
final class ScanViewModel {
var isScanning: Bool = false
var scannedBytes: Int64 = 0
var currentRoot: FileNode? = nil
}
Une vue qui lit scanViewModel.isScanning sera réévaluée quand isScanning change. Une vue qui ne lit que scannedBytes sera réévaluée quand scannedBytes change, mais pas quand currentRoot change. La granularité est par propriété, automatiquement.
C’est plus propre que useSelector dans Redux ou le memo de React avec ses tableaux de dépendances. Le framework s’occupe de l’intendance.
MVVM
L’architecture sur laquelle je me suis arrêté est MVVM : Model, ViewModel, View. Si vous avez utilisé Redux ou MobX, la forme est familière. Dans les termes de Renala :
- Le Model, c’est
FileNode,ScanResult,VolumeInfo. Des données pures. Aucune dépendance à l’interface. - Le ViewModel, c’est
ScanViewModeletTreemapViewModel:@Observable,@MainActor. Ils détiennent l’état de l’application et l’exposent aux vues. Ils lancent les scans, calculent les dispositions, traitent les actions utilisateur. - La View lit le ViewModel et lui renvoie des actions.
graph LR
M["Model<br/>FileNode / ScanResult"] -->|"lu par"| VM["ViewModel<br/>@Observable @MainActor"]
VM -->|"suivi @Observable"| V["View<br/>SwiftUI"]
V -->|"action utilisateur"| VM
VM -->|"met à jour"| M
V -->|"dessin Canvas"| C["TreemapCanvasView<br/>mode immédiat"]
La liaison est automatique. La vue lit les propriétés du ViewModel dans body, SwiftUI enregistre l’accès, et quand ces propriétés changent, la vue est réévaluée. Pas de câblage. Pas de useEffect à nettoyer. Pas de bogues de fermeture périmée.
Contrairement à JavaScript, le code Swift peut s’exécuter sur plusieurs threads simultanément. @MainActor est le contrat imposé par le compilateur qui dit : tout le code de cette classe s’exécute sur le thread de l’interface, là où SwiftUI s’attend à lire l’état. Ce n’est pas optionnel. Si vous tentez de muter l’état du ViewModel depuis un thread secondaire, le compilateur refuse.
Il y a un coût. Tout travail d’arrière-plan (scan, hachage, calcul de dispositions) doit explicitement transiter par @MainActor pour mettre à jour l’état. Le surcoût est faible ; la garantie de correction est totale.
Le piège du re-rendu
Le modèle mental de React vous induit activement en erreur sur la performance.
En React, on raisonne en termes de frontières de composants et de mémoïsation. Un composant se re-rend si ses props changent. On utilise React.memo ou useMemo pour empêcher les re-rendus inutiles. Le diff du DOM virtuel est peu coûteux, mais il n’est pas gratuit, et avec des milliers d’éléments on commence à le sentir.
En SwiftUI, le diff se fait au niveau de la struct. Les Views sont des structs. SwiftUI compare l’ancienne struct à la nouvelle. Equatable en Swift est un protocole qui signifie « ce type supporte la comparaison d’égalité ». Pensez à un type déclarant qu’il peut être comparé avec ===. Si deux structs View sont égales, le sous-arbre est ignoré. C’est généralement rapide.
Mais il y a un piège. En Swift comme en JavaScript, une fermeture est une valeur de fonction qu’on peut passer et stocker, comme une arrow function qu’on passerait en prop onClick. Si on passe une fermeture à une vue enfant en paramètre, la fermeture n’est pas Equatable. Les fonctions n’ont aucun concept d’égalité : SwiftUI ne peut pas savoir si {'{'{'}'} vm.doThing() {'}'} de ce rendu est identique à {'{'{'}'} vm.doThing() {'}'} du précédent. Il suppose qu’elles diffèrent. L’enfant se re-rend à chaque fois.
J’ai heurté ce problème assez tôt avec les menus contextuels et les lignes de la barre latérale. La correction est simple : passer le ViewModel directement, pas des fermetures dérivées. Le ViewModel est un type référence (les classes en Swift, comme les objets en JavaScript, sont passées par référence). Deux références à la même instance de ViewModel sont toujours égales : même adresse mémoire, même objet. SwiftUI sait qu’il n’a pas changé.
Aucun tutoriel ne documente cela, du moins aucun que j’aie trouvé. Je l’ai découvert de la manière dont on découvre la plupart des problèmes de performance : en me demandant pourquoi tout était si lent. La session WWDC 2021 Demystify SwiftUI explique le mécanisme de diff en détail et reste la meilleure référence pour comprendre ce qui déclenche réellement un re-rendu.
La barre latérale : liste plate, pas arbre récursif
La barre latérale affiche l’arborescence de fichiers. L’implémentation évidente est un DisclosureGroup récursif : chaque répertoire se déplie pour montrer ses enfants, qui peuvent eux-mêmes être des répertoires avec leur propre DisclosureGroup.
C’est aussi la mauvaise implémentation. Un DisclosureGroup récursif pour un arbre de 5,2 millions de nœuds crée une hiérarchie de vues profondément imbriquée. SwiftUI ne peut pas la rendre de manière paresseuse. L’arbre entier est instancié d’un coup, et l’application cesse d’en être une.
La solution : aplatir l’arbre. Une List plate avec ForEach sur un tableau précalculé de nœuds visibles. Le tableau ne contient que les nœuds qui doivent être affichés compte tenu de l’état d’expansion courant. Déplier un répertoire insère ses enfants au bon index. Replier les supprime. La List fait du rendu paresseux : seules les lignes du viewport visible deviennent des vues SwiftUI. Les autres n’existent pas.

List plate, pas un arbre récursif. Pendant la recherche, le tableau visible est filtré aux nœuds correspondants et à tous leurs ancêtres. Vider la recherche restaure l’état d’expansion précédent.Canvas : mode immédiat dans un cadre déclaratif
Le treemap posait un problème de nature entièrement différente.
Un scan complet du disque produit des dizaines de milliers de rectangles visibles. Modéliser chacun comme une vue SwiftUI, c’est injecter des dizaines de milliers de nœuds dans la hiérarchie de vues. Chaque passe de disposition les compare tous. Chaque changement de propriété les compare tous. L’application devient inutilisable.
La réponse de SwiftUI est Canvas. Pensez à l’élément <canvas> du navigateur, pas au DOM. On émet des commandes de dessin qui peignent directement des pixels. Pas d’arbre d’éléments, pas de diff, pas de structure retenue : juste des appels de peinture qui s’exécutent et sont oubliés. À l’intérieur de la fermeture Canvas, on utilise un GraphicsContext pour dessiner des chemins, des images, du texte. Ce ne sont pas des vues SwiftUI. Ce sont des commandes de dessin. Elles n’existent pas dans la hiérarchie de vues. Ce ne sont que des pixels.
Canvas { context, size in
for rect in treemapViewModel.rects {
context.draw(rect.cachedImage, in: rect.frame)
}
}
L’implémentation réelle compose quatre couches en une seule passe :
Un seul nœud Canvas dans la hiérarchie. Tout le dessin en une seule passe. SwiftUI gère la composition.

L’API Canvas a une contrainte à connaître : les fermetures passées à Canvas ne peuvent pas lire @Environment. (@Environment est l’équivalent SwiftUI de React Context : des valeurs injectées en haut de l’arbre de vues et consommées n’importe où en dessous.) SwiftUI trace les lectures d’environnement via l’arbre de vues, et les fermetures Canvas n’en font pas partie. Si on a besoin d’une valeur d’environnement à l’intérieur de la fermeture du canevas, il faut la capturer dans une variable locale avant d’entrer dans la fermeture.
Il y a un coût d’accessibilité. Les vues SwiftUI standard participent automatiquement à l’arbre d’accessibilité : un Button obtient un label VoiceOver, une ligne de List peut être naviguée au clavier. Un Canvas est un bitmap opaque. Le système d’accessibilité ne voit rien à l’intérieur. Ajouter le support VoiceOver pour le treemap a nécessité de construire une représentation parallèle des rectangles visibles que le lecteur d’écran peut parcourir, plus des modifiers accessibilityElement explicites pour chacun. L’accessibilité sur les surfaces de dessin en mode immédiat est toujours du travail manuel. Il faut le prévoir dès le départ. Dire « on ajoutera l’accessibilité plus tard », c’est comme dire « on écrira les tests plus tard ».
Navigation : c’est la plateforme qui décide
Vers la fin du projet, un ami a testé l’application. Graphiste de métier, attentif au comportement des choses, il a laissé des retours écrits détaillés. Deux commentaires portaient directement sur le modèle de navigation.
Le premier : « Quand tu rentres dans l’arbre et que tu cliques sur un fichier, tu perds tes repères et l’arbre disparaît. Il faut cliquer sur la ligne pour revenir. Pas terrible. » Le second : « Pourquoi mettre une flèche dans le fil d’Ariane si on ne peut pas cliquer dessus ? Un slash ou rien serait moins déroutant. »
Les deux étaient justes. L’arbre se repliait d’une manière désorientante. Le fil d’Ariane avait une affordance visuelle qui suggérait une interaction sans la fournir. Ce ne sont pas des problèmes de framework. Ce sont des problèmes de conception qui ne se révèlent que lorsque quelqu’un d’autre que l’auteur utilise l’application.
Ces retours ont produit deux changements : la navigation par zoom a été révisée pour préserver le contexte quand on navigue vers un fichier, et la flèche non interactive du fil d’Ariane a été supprimée. La navigation clavier a suivi la même logique : flèches pour se déplacer dans l’arbre, retour arrière pour remonter, espace pour Quick Look. Le modèle de navigation correspond désormais à ce que la plateforme offre réellement, pas à ce qu’on attendrait sur un téléphone.
Le principe de Steve Krug tiré de Don’t Make Me Think s’applique ici : chaque point d’interrogation dans la tête d’un utilisateur est de la friction. Une flèche de fil d’Ariane qui ne répond pas au clic est un point d’interrogation. Un arbre qui disparaît quand on sélectionne un fichier est un point d’interrogation. La correction, dans les deux cas, n’était pas de répondre à la question mais de la supprimer.
SwiftUI sur macOS n’est pas SwiftUI sur iOS. Le framework est le même ; les attentes de la plateforme sont différentes.
Pour aller plus loin
Sessions WWDC (gratuites, officielles) :
- Discover Observation in SwiftUI : WWDC 2023. L’introduction définitive à
@Observableet à la manière dont il remplaceObservableObject. - Demystify SwiftUI : WWDC 2021. Comment SwiftUI décide quoi re-rendre et quoi ignorer. Visionnage indispensable avant de profiler la performance.
- Add rich graphics to your SwiftUI app : WWDC 2021. Présente l’API
Canvasavec des exemples concrets. - SwiftUI on iPad: Organize your interface : WWDC 2022. Motifs de navigation pour les plateformes non mobiles. Pertinent si vous développez pour macOS ou iPad.
Références écrites :
- SwiftUI documentation : la référence officielle d’Apple. Les pages
Canvas,@ObservableetNavigationStacksont particulièrement utiles. - Hacking with Swift: SwiftUI : le guide pratique de Paul Hudson. Couvre la plupart des API SwiftUI avec des exemples courts et ciblés. Utile pour retrouver un motif rapidement.
Le pipeline de rendu du treemap, y compris l’ombrage de Lambert qui donne aux rectangles leur aspect tridimensionnel, est couvert dans le prochain article.