~10 min de lecture

Angular Native : ton app Angular en vraies vues iOS et Android, et les 6 pièges de l'alpha

TL;DR

Angular Native est un projet open source, indépendant de Google, sorti fin septembre 2026. Il branche le rendu d'Angular sur Fabric, le moteur de rendu natif de React Native, et laisse Expo gérer build et publication. Un <view> devient un UIView sur iOS, un android.view.View sur Android. Pas de WebView (le navigateur embarqué dans l'app, où tourne Ionic) pour le rendu, et React n'est jamais dans le chemin de rendu. Signals, inject(), router et Signal Forms fonctionnent. Ce qui coince : le DOM, tes inputs de formulaire maison, une partie du CSS, HttpClient si ton main.ts n'importe pas expo, le SSR. Le projet est en alpha (v0.8.0 au 8 octobre 2026) et ne marche qu'avec Angular 22.

Le ticket tombe un lundi : "le client veut une app mobile d'ici la fin du trimestre". Jusqu'ici, trois réponses, aucune agréable. Ionic avec Capacitor : ton Angular tourne sans changement, mais dans une WebView qui imite le natif sans l'être. NativeScript : du vrai natif, mais un écosystème de plugins à part. React Native : le plus gros écosystème mobile JavaScript, au prix d'une réécriture de ta couche UI en React.

Angular Native propose une quatrième voie : garder Angular, et emprunter à React Native son moteur de rendu et ses modules natifs, sans React pour dessiner l'écran.


Ce que c'est, en une couche

Le rendu de tes templates passe par Renderer2, une classe abstraite. Sur le web, @angular/platform-browser l'implémente avec document.createElement. @angular/core, lui, ne demande pas de vrai DOM : Angular Native lui pose quelques variables globales factices (un document réduit, Node, AnimationEvent, getComputedStyle) et un jeton DOCUMENT minimal.

Angular Native fournit une autre implémentation de Renderer2. Au lieu d'un arbre DOM, elle construit en mémoire un arbre de nœuds qu'elle conserve d'une passe à l'autre, et le remet à Fabric, le renderer C++ de la "nouvelle architecture" de React Native (la refonte de son moteur interne, seule architecture depuis React Native 0.82). Fabric compare avec l'écran, crée les vues natives et applique les changements au plus une fois par passe de détection de changements.

Autour, Expo gère la douleur du mobile : test sur ton téléphone via l'app Expo Go, builds de release, signature, envoi aux stores. Et Metro, le bundler de React Native, compile tes composants en AOT (templates compilés au build plutôt qu'à l'exécution) grâce à une configuration fournie par le projet. @ng-native/web rend aussi le même code dans un navigateur.

Démarrer tient en deux commandes :

npx create-expo-app@latest my-app --template @ng-native/template
cd my-app && npx expo start

Les prérequis sont serrés : Angular 22, Expo SDK 57, React Native 0.86, Node 22.18 ou plus. Dans un workspace Angular CLI ou Nx existant, ng add @ng-native/schematics ou nx add @ng-native/nx ajoutent l'app mobile comme un projet de plus.


Ce qui passe sans adaptation

Un compteur, lu avec tes yeux de dev web :

// counter.ts
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { Pressable, SafeAreaProvider, SafeAreaView, Text } from '@ng-native/components';

@Component({
  selector: 'app-counter',
  changeDetection: ChangeDetectionStrategy.OnPush,
  imports: [Pressable, SafeAreaProvider, SafeAreaView, Text],
  template: `
    <safe-area-provider>
      <safe-area-view class="screen">
        <pressable accessibilityRole="button" class="button" (press)="increment()">
          <text class="label">Count: {{ count() }}</text>
        </pressable>
      </safe-area-view>
    </safe-area-provider>
  `,
  styles: `
    :host { flex: 1; }
    .screen { flex: 1; justify-content: center; padding: 24px; }
    .button { padding: 14px; border-radius: 10px; background-color: #3b6ef5; }
    .label { color: white; font-weight: 600; }
  `,
})
export class Counter {
  protected readonly count = signal(0);

  protected increment(): void {
    this.count.update((value) => value + 1);
  }
}

Rien d'exotique. Les éléments natifs (Pressable, Text) sont des directives Angular importées comme les autres, et écrites en minuscules dans le template. Les styles sont compilés au build, et la cascade est émulée au runtime, y compris l'héritage de color d'une vue vers le texte qu'elle contient, ce que React Native ne fait pas.

Le reste de la stack suit :

  • Zoneless, sans option. mount(), l'équivalent de bootstrapApplication, active toujours la détection de changements zoneless, et la v0.8.0 n'offre aucun moyen d'ajouter zone.js. Code encore dépendant de zone.js ? Lis d'abord notre guide de migration zoneless.
  • @angular/router pour la navigation, rendu sur des piles de navigation natives : vraies transitions, vrai geste de retour. Guards, resolvers, lazy loading et deep links fonctionnent, car NativeNavigation.push() (empiler un écran) et present() (l'ouvrir en modale) ne sont que des Router.navigate().
  • Les modules Expo comme services. Caméra, géolocalisation, notifications, biométrie, retour haptique : inject(Haptics) comme tu injecterais HttpClient.
  • Tailwind, avec des variantes ios: et android: en plus.
  • Les tests, avec une API façon Testing Library qui tourne dans Node contre un faux Fabric. Pas de simulateur.
// counter.spec.ts
import { render, screen, userEvent } from '@ng-native/testing';
import { expect, test } from 'vitest';
import { Counter } from './counter';

test('incrémente le compteur à chaque appui', async () => {
  await render(Counter);

  await userEvent.setup().press(screen.getByRole('button', { name: 'Count: 0' }));

  expect(screen.getByText('Count: 1')).toBeTruthy();
});

Si tu cibles déjà tes éléments par rôle et par nom, façon Testing Library, rien ne change. Un Page Model bâti sur By.css et data-testid, comme dans notre article sur le Page Model, est à adapter : ici, getByTestId lit la propriété testID, pas l'attribut data-testid.


Les 6 pièges de l'alpha

Maintenant, ce qui casse, d'après la documentation et le code source de la v0.8.0. Les pièges 1 et 4 ont été reproduits sur simulateur iOS, en build Debug et Release, sur un projet généré ; le piège 5 l'a été au build Metro (expo export). La variante EXPO_PUBLIC_USE_RN_FETCH n'a pas été testée.

Piège 1 : le DOM est absent, mais window existe

Les API DOM ne fonctionnent pas ; ElementRef.nativeElement n'est pas un HTMLElement mais un nœud du moteur ; et DomSanitizer et NgOptimizedImage sont indisponibles.

La surprise : window existe, React Native en fait un alias de l'objet global, et un document minimal est posé par la configuration Metro. Une garde typeof window !== 'undefined' passe donc sur mobile, et window.innerWidth vaut undefined, sans erreur. Un utilitaire partagé qui ne fait que lire window.innerWidth ne plante pas, il calcule faux en silence.

Les réflexes appris pour le SSR (window, document et PLATFORM_ID) ne suffisent pas tous. afterNextRender s'exécute sur l'appareil, et !isPlatformServer(id) est vrai puisque PLATFORM_ID vaut 'native'. Protège ces accès avec isPlatformBrowser(), ou avec isPlatformNative() de @ng-native/platform. Taille d'écran, thème et clavier passent par @ng-native/device. Et pour une lib purement web (un <canvas>), les "DOM components" de @ng-native/expo embarquent une WebView dans un écran natif.

Piège 2 : ton CSS n'est pas tout ton CSS

La règle du projet : une déclaration CSS passe si React Native sait l'exprimer. Sinon, le build la supprime et affiche un warning avec le fichier, la ligne et la raison.

Sont définitivement hors périmètre : display: grid, ::before et ::after, la mise en page en tableau, position: fixed et position: sticky. Le layout, c'est flexbox.

Trois nuances. Une pile de polices (font-family: Inter, Helvetica, sans-serif) est réduite à sa première police sans warning. Et les warnings ne sortent qu'à la transformation du fichier : avec le cache de Metro chaud, tu ne les revois pas. Relance avec npx expo start --clear pour l'inventaire. Enfin, une valeur passée par une variable CSS (display: var(--d) qui vaut grid) n'est signalée qu'au runtime, en développement.

Piège 3 : tes inputs de formulaire maison sont à réécrire

Les éléments de @ng-native/components n'implémentent pas ControlValueAccessor, et la doc précise qu'ils ne le feront jamais. Ils se lient directement à Signal Forms : <text-input> expose un model() nommé value, <switch> un model() nommé checked. @angular/forms n'est pas inclus dans le projet généré : installe-le à la version exacte de ton @angular/core.

// sign-up.ts
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { FormField, form, minLength, required } from '@angular/forms/signals';
import { Switch, Text, TextInput, View } from '@ng-native/components';

@Component({
  selector: 'app-sign-up',
  changeDetection: ChangeDetectionStrategy.OnPush,
  imports: [FormField, Switch, Text, TextInput, View],
  template: `
    <view>
      <text-input placeholder="Name" [formField]="signUp.name" />
      @if (signUp.name().touched() && signUp.name().invalid()) {
        <text>Name is too short</text>
      }
      <switch [formField]="signUp.subscribed" />
    </view>
  `,
})
export class SignUp {
  protected readonly model = signal({ name: '', subscribed: false });
  protected readonly signUp = form(this.model, (path) => {
    required(path.name);
    minLength(path.name, 3);
  });
}

Tes inputs maison façon ControlValueAccessor ne migrent pas : leur template est du HTML, et <input> n'existe pas sur mobile. Réécris-les sur les éléments natifs, avec un model() value ou checked, le contrat documenté. Si tu es encore en Reactive Forms, commence par la migration vers Signal Forms.

Piège 4 : HttpClient peut marcher en debug et renvoyer du vide en release

Depuis Angular 22, HttpClient passe par fetch par défaut, et ce transport lit le corps de la réponse via le flux response.body. Sur React Native, ça dépend du fetch global :

  • celui d'Expo expose ce flux, et il est installé quand le bundle importe expo ;
  • celui de React Native (whatwg-fetch au-dessus de XHR) n'a pas de body.

Le projet généré, les générateurs et la doc d'installation écrivent import 'expo'; en tête de main.ts. Mais si cette ligne disparaît, parce que tu as réécrit main.ts à la main par exemple, le build de debug récupère encore le fetch d'Expo par un autre chemin, et le build de release non. Chaque requête se résout alors avec un corps null, sans erreur. Et si la variable d'environnement EXPO_PUBLIC_USE_RN_FETCH est posée, aucun build n'a de body, debug compris.

La parade ne dépend d'aucune de ces conditions : ajoute un provider à l'appel mount() existant, sans toucher à ses autres options.

// main.ts
import { provideNativeHttpClient } from '@ng-native/platform/http';

const app = mount(Number(rootTag), App, getFabricUIManager(), {
  // ...options generated by the template (processColor, conditions, tokens...)
  providers: [provideNativeHttpClient()],
});

Elle configure HttpClient avec withXhr(), sur le XMLHttpRequest natif de React Native, progression d'upload comprise. C'est le même withXhr() que dans notre article sur l'upload de fichiers, pour une raison différente.

Piège 5 : le compilateur n'est pas ngc

Metro ne compile pas tes templates avec le compilateur officiel d'Angular (ngc), mais avec @oxc-angular/vite (version 0.0.40 dans la v0.8.0), un compilateur alternatif. Il s'écarte de ngc sur plusieurs points, dont trois qu'Angular Native transforme en erreur de build :

  • Une fonction fléchée de template qui lit son propre paramètre, comme (press)="open.update((was) => !was)", est compilée comme si was était une propriété du composant. Elle lit undefined.
  • Un host écrit avec un spread (host: { ...SHARED, '(press)': 'go()' }) ou déclaré dans une constante n'est pas lu : les listeners ne se déclenchent jamais.
  • Un bloc de control flow mal fermé, comme @if (on() {, fait disparaître la suite du template en silence, là où ngc lève une erreur.

Le fichier fautif est nommé. Pour les deux premiers, c'est une erreur de build sur du code que ngc accepte. Le contournement : une méthode toggle() plutôt qu'une lambda, des bindings en clair dans host.

Deux autres écarts, que ngc signale et pas @oxc-angular/vite. Un élément écrit avec une majuscule, <View> au lieu de <view>, donne un template vide là où ngc lève NG8001 (élément inconnu) ; ce cas-là aussi, la configuration Metro le transforme en erreur de build. Et un [formField] sans import de FormField ne lève pas le NG8002 habituel (propriété inconnue) : rien au build, un simple log en développement.

Piège 6 : ce qui manque à l'appel

À connaître avant de chiffrer un projet :

  • Pas de SSR ni d'hydratation, ni sur mobile ni avec @ng-native/web.
  • Pas de @angular/animations. animate.enter et animate.leave fonctionnent, ainsi que les transitions CSS et @keyframes. Pour ce qui suit un geste, il y a AnimatedStyle et Reanimated, la bibliothèque d'animation de React Native, dont les "worklets" sont des fonctions exécutées sur le thread d'interface.
  • Pas d'Angular DevTools. Le projet renvoie vers Pangular Inspector, un inspecteur tiers, et le debugger de React Native.
  • i18n au runtime seulement. Pas de build traduit par locale comme avec @angular/localize sur le web : les traductions se chargent avant le montage, et changer de langue impose de recharger l'app. L'extraction passe par localize-extract sur le bundle Metro, pas par ng extract-i18n.

Ionic ou Angular Native : ce que ça change pour ton équipe

Ionic est le choix par défaut d'une équipe Angular sur mobile : c'est lui la vraie comparaison.

Question Ionic + Capacitor Angular Native
Où tourne l'UI ? Dans une WebView En vues natives, via Fabric
Ton CSS web existant Passe sans changement Flexbox seulement, pas de grid
Tes inputs de formulaire Passent sans changement À réécrire sur les éléments natifs
Plugins natifs Écosystème Capacitor Modules Expo, injectés en services
Maturité Intégration Angular d'Ionic, mûre Alpha, API instables en 0.x

Angular Native gagne quand le rendu natif compte et que ton code est déjà moderne. Il perd face à une base pleine de Reactive Forms, de CSS grid et d'accès DOM.

Sur la maturité, prends l'avertissement au sérieux : le projet a publié onze versions entre le 29 septembre et le 8 octobre 2026. Sa propre doc dit que beaucoup de ses services qui enveloppent les API natives n'ont pas été vérifiés sur du matériel réel, et qu'il faut le traiter comme un prototype fonctionnel. Pour une app à livrer ce trimestre, c'est un pari ; pour un prototype interne, c'est jouable.


Récap actionnable

Avant de dire oui à Angular Native sur un vrai projet :

  1. Vérifie la version. Angular 22 obligatoire. Pas de v21.
  2. Passe ton code partagé en zoneless. La v0.8.0 n'accepte pas zone.js.
  3. Cherche document, window et navigator dans ce que tu comptes partager, et protège chaque accès par isPlatformBrowser() ou isPlatformNative(), pas par afterNextRender.
  4. Inventorie tes inputs de formulaire maison. Chacun est un composant à réécrire sur les éléments natifs.
  5. Repère tes display: grid et pseudo-éléments. Un build avec --clear te les listera. Vérifie à la main les piles de polices et les valeurs passées par variable.
  6. Mets provideNativeHttpClient() dès le premier jour. Et teste un build de release avant la démo, pas après.
  7. Sors les lambdas de tes templates vers des méthodes.
  8. Prévois des API qui bougent. Épingle les versions @ng-native/* et lis le changelog à chaque montée.

Si ta base est moderne, l'essentiel de ton Angular reste ton Angular. Sinon, Angular Native te donne une bonne raison de la moderniser.

📧 Reste informé(e) !

Reçois les derniers articles et conseils EasyAngularKit directement dans ta boîte mail.

S'inscrire gratuitement

AngularKit

Suite d'outils pour développeurs Angular francophones. Apprends, modernise tes réflexes, audite ta codebase.

Produits

Contact

Légal

© 2026 AngularKit. Tous droits réservés.