# Getting started ## Create the app ```sh npx create-expo-app@latest my-app --template @ng-native/template cd my-app npx expo start ``` `create-expo-app` installs the template's framework packages and Angular, with the Metro preset configured in `metro.config.js`. No manual installation is needed. For an existing Expo app, see [Adding it to an existing app](/guide/manual-setup). In an Angular CLI or Nx workspace, add the same app as a project with `ng add @ng-native/schematics` (see [Angular CLI](/packages/schematics)), or `nx add @ng-native/nx` then `nx g @ng-native/nx:app apps/mobile` (see [Nx](/packages/nx)). Scan the QR code with [Expo Go](https://expo.dev/go), or press `i` or `a` for a simulator. The Angular component renders native views: `` becomes a `UIView` on iOS or an `android.view.View` on Android, with a press handler and no React in the render path. Before each release, checks publish every package to a registry, generate a template app with the command above, install dependencies by version, typecheck, test and bundle it. Any failure blocks the release. ## Edit `app.ts` and check the counter Import each element as an Angular component and use lowercase names. The template generates this `src/app/app.ts`: ```ts import { Component, signal } from '@angular/core'; import { Pressable, SafeAreaView, Text, View } from '@ng-native/components'; @Component({ imports: [Pressable, SafeAreaView, Text, View], selector: 'app-root', template: ` Angular, natively Real native views. React is never in the render path. Tapped {{ count() }} times `, styles: ` :host { flex: 1; } .screen { flex: 1; background-color: #101014; } .body { flex: 1; justify-content: center; gap: 12px; padding: 24px; } .title { color: #ffffff; font-size: 28px; font-weight: 700; } .hint { color: #8b8b96; font-size: 15px; } .button { align-items: center; margin-top: 8px; padding: 14px; border-radius: 10px; background-color: #3b6ef5; } .label { color: #ffffff; font-size: 16px; font-weight: 600; } `, }) export class App { protected readonly count = signal(0); } ``` Edit the `title` text and save to see it update on the device. Press the button to increment `count`: its label reads "Tapped 1 times", "Tapped 2 times", and so on. Use lowercase names such as `` and ``. Angular treats uppercase names as unknown components and silently emits an empty template; the Metro transform catches this as a build failure. An element used without its import renders a plain view and logs a development warning. ## Run `app.test.ts` ```sh npm test ``` `app.test.ts` checks that pressing the button increments the counter. It runs in Node in well under a second, without a simulator, rendering against a fake native host and querying what native would receive. ```ts import { render, screen, userEvent } from '@ng-native/testing'; import { expect, test } from 'vitest'; import { App } from './app.ts'; test('counts taps', async () => { await render(App); await userEvent.setup().press(screen.getByRole('button', { name: 'Tapped 0 times' })); expect(screen.getByText('Tapped 1 times')).toBeTruthy(); }); ``` [Testing](/packages/testing) explains what these tests prove and their limits. [Writing a test](/packages/testing/writing-a-test) covers forms, services and routing. ## Style a control `` handles presses without built-in styling; there is no styled component library to adopt. Bind Tailwind classes to state after [setting up Tailwind](/packages/tailwind): ```ts import { Component, signal } from '@angular/core'; import { Pressable, Text } from '@ng-native/components'; @Component({ selector: 'app-root', imports: [Pressable, Text], template: ` Wi-Fi `, }) export class App { protected readonly wifi = signal(true); } ``` [Theming and Tailwind](/guide/theming) covers what a class string means on a platform with no browser. ## Update templates and styles Template and stylesheet edits preserve component state through Angular's `ɵɵreplaceMetadata`, which the Metro transform embeds in the module. No Angular dev server is involved. Selector, input, method and import changes require a full reload; the console logs why. ## Expo Go or a development build `npx expo start` supports both. Expo Go covers most development. For a native module or dependency patch absent from Expo Go, create a development build with `npx expo run:ios`, `npx expo run:android` or EAS. For distribution, create a release build. Scanning the `expo start` QR code opens Expo Go on a physical device; `--dev-client` opens an installed development build. ## Building for release `npx expo run:ios --configuration Release` and `npx expo run:android --variant release` build standalone apps. [EAS Build](https://docs.expo.dev/build/introduction/) (`eas build`) builds on Expo's machines. Follow Expo's instructions for configuration and signing. The Metro preset sets Angular's production mode, replacing `ngDevMode` with `false` when `__DEV__` is false to remove dev-mode assertions and performance counters. See [Metro](/packages/metro). ## Where to go next [Theming and Tailwind](/guide/theming) covers styling, [Screens and navigation](/packages/router/screens) adds screens, and [Writing a test](/packages/testing/writing-a-test) extends the template's test. [Components](/packages/components) lists the available elements. For manual setup in an existing Expo project, see [Adding it to an existing app](/guide/manual-setup). Read [Architecture](/guide/architecture) to understand how Angular drives native views, [Known limitations](/guide/limitations) for alpha gaps, and [Angular Native compared](/guide/comparison) for comparisons with React Native, NativeScript, Ionic and Flutter. --- # Adding it to an existing app The [Getting started](/guide/getting-started) template is the shorter route, verified end to end before each release. For manual setup in a blank or existing Expo app, complete every step below. Each step includes troubleshooting notes. Angular CLI and Nx generators perform this setup and integrate workspace commands: use `ng add @ng-native/schematics` ([Angular CLI](/packages/schematics)) or `nx g @ng-native/nx:app` ([Nx](/packages/nx)). ## Start the project **Create a blank project:** ```sh npx create-expo-app my-app --template blank-typescript cd my-app rm App.tsx ``` **For an existing app:** remove its entry point, `App.tsx` or `index.js`. The `src/main.ts` below replaces it. Keep the dependencies, native configuration and assets. For either route, install the framework, configure Metro and write the two app files below. ## Install the framework packages ```sh npm install @angular/core @angular/common \ @ng-native/platform @ng-native/fabric \ @ng-native/components @ng-native/device @ng-native/metro ``` `` and `` use `react-native-safe-area-context` native views. Install it if you use them, as most apps do to clear the notch: ```sh npx expo install react-native-safe-area-context ``` Expo Go bundles it, hiding a missing installation until a development or release build renders `Unimplemented component: `. Add `@ng-native/router`, `@ng-native/expo` and `@ng-native/icons` as needed. Expo modules are optional peers, so `@ng-native/expo` installs no native code. Install individual modules such as `expo-haptics` for the capabilities you need. ## Configure Metro ```js // metro.config.js const { getDefaultConfig } = require('expo/metro-config'); const { withAngularNative } = require('@ng-native/metro/config.cjs'); module.exports = withAngularNative(getDefaultConfig(__dirname)); ``` `withAngularNative` registers Angular's ahead-of-time transformer, compiles component CSS into engine sheets, and adds source extensions to invalidate external templates. It installs `ngDevMode` and `animate.enter`/`animate.leave` polyfills before `@angular/core` loads. Pass `{ workspaceRoot }` in a monorepo where framework packages sit outside the app's `node_modules`. ## Configure TypeScript ```json // tsconfig.json { "extends": "expo/tsconfig.base", "compilerOptions": { "strict": true, "allowImportingTsExtensions": true } } ``` `allowImportingTsExtensions` is required. These packages ship TypeScript with `.ts` imports so Metro's Angular compiler processes library and app components together. Without this setting, `tsc` reports errors for files in `node_modules`. ### Checking templates `tsc` checks TypeScript only: a template that binds an input a component does not have, such as `` where the input is `checked`, compiles, bundles and does nothing. Angular's own compiler checks templates. With `@angular/compiler-cli` installed as a development dependency, add its options to the same `tsconfig.json` and type-check with `ngc` instead of `tsc`: ```json { "angularCompilerOptions": { "strictTemplates": true, "typeCheckHostBindings": false, "strictDomEventTypes": false } } ``` ```sh ngc -p tsconfig.json --noEmit ``` `typeCheckHostBindings` is off because the components' own host bindings name native props that Angular's DOM schema does not know. `strictDomEventTypes` is off because a native event such as `(scroll)` is an element event, not an output, so under the DOM schema its `$event` would be typed as a DOM `Event`. A view registered by name, such as `registerExpoView('expo-image', 'ExpoImage')`, is used through a component whose inputs are its props, so the template is checked against them: `@ng-native/expo` has `ExpoImage` and the `Ui*` SwiftUI components, and a view with none yet gets a small one of the same shape. Leave `CUSTOM_ELEMENTS_SCHEMA` and `NO_ERRORS_SCHEMA` out: either turns template checking off for the whole component. ## Write the entry point ```ts // src/main.ts import { AppRegistry, Image, Platform, processColor } from 'react-native'; import { mount } from '@ng-native/platform'; import { currentConditions, deviceTokens, watchConditions } from '@ng-native/device'; import { getFabricUIManager, registerPlatformComponents } from '@ng-native/fabric'; import { App } from './app/app.ts'; registerPlatformComponents(Platform.OS); AppRegistry.registerRunnable('main', ({ rootTag }) => { const app = mount(Number(rootTag), App, getFabricUIManager(), { processColor, conditions: currentConditions(), tokens: deviceTokens(), resolveAssetSource: (value) => Image.resolveAssetSource(value as never), }); watchConditions(app.engine); }); ``` The entry point configures colours, assets and device-dependent styles before rendering: - **`conditions`** supplies `@media` values. `watchConditions` updates them on rotation or theme changes, making `dark:` follow the system. - **`tokens`** supplies device values such as hairline width. Without it, `1px` produces a thick divider instead of a third of a point on a 3x screen. - **`resolveAssetSource`** converts `require('./x.png')` to a native-loadable asset; without it, images stay blank. - **`processColor`** converts colours to platform integers. ## Write the root component ```ts // src/app/app.ts import { Component, signal } from '@angular/core'; import { Pressable, Text, View } from '@ng-native/components'; @Component({ imports: [Pressable, Text, View], selector: 'app-root', template: ` tapped {{ count() }} times tap me `, styles: ` .box { flex: 1; justify-content: center; padding: 24px; } `, }) export class App { protected readonly count = signal(0); protected bump(): void { this.count.update((n) => n + 1); } } ``` Import each element's component, for example `imports: [View, Text]`. Missing imports render plain views and trigger development warnings. Use lowercase element names. Angular silently compiles uppercase names as unknown components with empty templates; the Metro transform catches this as a build failure. Run `npx expo start`, then scan the QR code with Expo Go or press `i` or `a` for a simulator. Check that each press increments the counter. ## Add Tailwind Skip Tailwind if you use plain `[style]` objects. ```sh npm install @ng-native/tailwind tailwindcss @tailwindcss/cli ``` ```css /* src/styles.css */ @import 'tailwindcss/theme.css'; @import 'tailwindcss/utilities.css'; @import '@ng-native/tailwind/native.css'; ``` ```js // metro.config.js const { withTailwind } = require('@ng-native/tailwind/config.cjs'); module.exports = withTailwind(withAngularNative(getDefaultConfig(__dirname)), { input: './src/styles.css', }); ``` ```ts // src/main.ts import tailwind from '../.angular-native/app.tailwind.js'; // ...and in the mount options, beside processColor: globalStyles: tailwind, ``` Expo's transform worker returns empty native modules for `.css` files, so the generated sheet uses `.js`. `withTailwind` runs and watches the Tailwind CLI, updating the sheet when template classes change without restarting. Metro generates the sheet and its `.d.ts` in `.angular-native/` on startup. Start Metro before the first typecheck, and add `.angular-native/` to `.gitignore` since it is rebuilt on every start. See [Theming and Tailwind](/guide/theming) for how classes work without a browser. ## The dev loop Template and style edits hot-swap on the device, preserving component state. Class bodies, selectors, imports and dependencies require a full reload; the console logs why. ## Where to go next Continue with [Build a form](/guide/forms), [Working offline](/guide/offline), then [Shipping to a device and the store](/guide/shipping). --- # Angular Native compared React Native, NativeScript, Ionic and Flutter offer different rendering models, languages, ecosystems and styling systems. The comparisons below include no benchmarks or speed claims. ## React Native Angular Native uses React Native's Fabric renderer and Expo, replacing React, JSX, hooks and the reconciler. Angular's `Renderer2` calls build `@ng-native/fabric`'s retained tree, which commits like React Native's `ReactFabric`; see [Architecture](/guide/architecture). A `` and React Native's `` become identical `UIView`s through the same C++ renderer. `@ng-native/expo` wraps Expo modules as injectable services. `registerExpoView(elementName, moduleName)` registers any Expo module's native view; see [Using a module](/packages/expo/using-a-module). Expo's native implementation works unchanged, regardless of the JavaScript renderer. On the JavaScript side, templates replace JSX, signals replace hooks and `useState`, and dependency injection replaces context and prop drilling. Zoneless change detection replaces re-renders on every state update. `@ng-native/platform`'s `mount()` always enables zoneless mode, with no option to add zone.js; see [Bootstrapping](/packages/platform/bootstrapping). ## NativeScript NativeScript generates synchronous JavaScript bindings for 100% of platform APIs at compile time, including marshalling of all data types ([NativeScript iOS Marshalling docs](https://docs.nativescript.org/guide/ios-marshalling)). It calls Objective-C and Java APIs directly, without React Native or per-API wrappers. Angular Native instead reuses the RN/Expo module ecosystem. An arbitrary native SDK still needs a facade, as each `@ng-native/expo` module does. `@nativescript/angular` is actively maintained: the [NativeScript/angular monorepo](https://github.com/NativeScript/angular) tracks current Angular releases, including 22.0.0 (August 2026) for Angular 22. Its CSS engine emulates text-property inheritance, solving the same problem as [CSS on native](/packages/fabric/css-engine). NativeScript targets iOS, Android and visionOS, with no browser counterpart to [`@ng-native/web`](/packages/web). ## Ionic and Capacitor A Capacitor app runs in the platform's WebView: `WKWebView` on iOS, `android.webkit.WebView` on Android. A JavaScript bridge exposes native plugin methods on `window.Capacitor` ([How Capacitor works](https://ionic.io/blog/how-capacitor-works-2)). Its `ion-button` and `ion-list` web components render as DOM, styled with CSS and shadow DOM. Angular is one of Ionic's three first-class framework targets, with an official, mature integration. Angular knowledge carries over, but `ion-*` components, browser CSS and DOM API calls do not. Angular Native's `` and `` render as `UIView`s and Android `View`s; see [Architecture](/guide/architecture). Ionic is closer to Angular Native's web target: `@ng-native/web` runs in a browser, and both are accessible by URL. Capacitor's UI always runs in a WebView. `@ng-native/web`'s `BrowserEngine` instead implements the same interface as Fabric; see [Native and web](/guide/native-and-web). A component rendered as DOM on this site renders as a `UIView` on a device. ## Flutter Flutter bypasses system UI widgets and paints its own onto a surface through Skia or Impeller. Impeller is the default on iOS and Android (API 29+) from release 3.27 ([Impeller docs](https://docs.flutter.dev/perf/impeller)). It creates no `UIView`s or Android `View`s. This allows pixel-identical rendering across platforms, with its own animation and layout systems. Flutter reimplements controls and their look-and-feel updates, and exposes accessibility through its semantics tree rather than native views. Angular Native's `` uses the platform's switch control and current OS styling. Flutter uses Dart, `pub.dev` and its own build system; it shares no Angular code or npm packages with Angular Native. ## Styling React Native has no CSS engine; NativeWind compiles Tailwind into style objects. Ionic uses DOM CSS, while Flutter styles widgets without a cascade. Angular Native compiles component `styles` with lightningcss and uses `@ng-native/fabric` for runtime matching, inheritance and resolution; see [CSS on native](/packages/fabric/css-engine). `@ng-native/tailwind` uses this cascade, so `class="flex-1 bg-blue-500 p-4"` on a `` matches directly, without an interop layer. ## Maturity Angular Native is an alpha. It implements a retained tree with incremental commits, routing, Signal Forms, CSS, Expo and React Native facades, and Reanimated worklet animations. Release builds (`expo run:ios --configuration Release` / `expo run:android --variant release`) run on the iOS simulator and Android emulator, producing Hermes bytecode with no Angular compiler and `ngDevMode` false. Many platform facades have unit tests and typechecks but no hardware verification. Simulator checks also cover CSS behaviour beyond unit tests; see the canary's verify screen and [CSS on native](/packages/fabric/css-engine). Treat this as a working prototype. See [Known limitations](/guide/limitations) for specific gaps. ## When to choose which - Choose Angular Native for an existing Angular app or a team wanting Angular templates, signals and DI with native views and Expo modules. - Choose React Native for a team invested in React, where Fabric's ecosystem is largest. - Choose NativeScript for direct access to an unwrapped native SDK, when that matters more than React Native plugin reuse. - Choose Ionic for a web app that needs installation, or a web team avoiding native build tooling. - Choose Flutter when pixel-identical rendering matters more than native controls, or a new team has no investment in web, Angular or React ecosystems. --- # Theming and Tailwind React Native has no CSS engine: NativeWind compiles Tailwind into style objects, attaches them through `cssInterop` and provides a variant runtime because native views do not understand `className`. Angular Native's cascade already supports selectors, specificity, inheritance, media queries and custom properties. `` matches through `class`, without an interop layer or `className` prop. A build step converts web CSS to the native subset; a preset adds native-specific vocabulary. ## The build step ```css @import 'tailwindcss/theme.css'; @import 'tailwindcss/utilities.css'; @import '@ng-native/tailwind/native.css'; ``` Import `theme.css` and `utilities.css` instead of `tailwindcss` to exclude preflight's browser reset (`html`, `::before`, `-webkit-*`). This documentation site imports preflight explicitly, since its own chrome is a real document even though the components in it are not. The compiler unwraps `@layer` and `@supports`, drops `@property`, substitutes static theme variables, and folds `calc()` through lightningcss. For example, `calc(var(--spacing) * 4)` is constant but cannot resolve on a device. The compiler converts `oklch()` to sRGB as in component stylesheets. Then the utilities compile: ``` p-4 -> { paddingTop: 16, paddingRight: 16, paddingBottom: 16, paddingLeft: 16 } gap-2 -> { gap: 8 } h-16 -> { height: 64 } rounded-lg -> { borderTopLeftRadius: 8, ... } bg-blue-500 -> { backgroundColor: 'rgb(43, 127, 255)' } ``` Unsupported styles produce diagnostics with line numbers: ``` [angular-native] app.tailwind.css:153: dropped 'appearance': 'appearance' is not mapped yet. ``` Diagnostics prevent unsupported styles from silently doing nothing. ## The preset `@ng-native/tailwind` contains three files: `shared.css` for platform-independent styles, plus `native.css` and `web.css`, which import it and define four platform-dependent variants. ### `hover:` is the pressed state `:hover` is permanently unsupported by the native matcher and dropped with a build warning. The `hover:` variant instead matches `:active`, which the engine sets on touched views and their ancestors, letting hover classes express touch engagement. `data-hover` supports iPad trackpads through React Native's W3C pointer events. Components must listen for these events and set the attribute themselves. Phones never send them, so the variant remains a press state. ```css /* native.css */ @custom-variant hover (&:active, &[data-hover]); /* web.css */ @custom-variant hover (&:hover, &:active, &[data-hover]); ``` The browser variant includes `:active` for touchscreen laptops and phone browsers, where `:hover` may never fire or may stick after a tap. Use `press:` and `hovered:` to distinguish the states. ### `focus-visible:` is `focus:` on native The web distinguishes keyboard focus from clicks to avoid unnecessary rings. On phones, focus comes from keyboards, remotes or assistive technology, where rings are useful. Since `:focus-visible` is also dropped with a build warning, the native variant aliases it to focus. Both platforms also match `data-focus` so a wrapper can show its control's focus ring. The native text field receives focus, while its wrapper owns the border, radius and padding; the control's composed behaviour informs the wrapper. ### `ios:`, `android:`, `web:`, `native:` `mount` adds `platform-ios`, `platform-android` or `platform-web` to the root, enabling Tailwind's platform variants. Variants for absent classes compile but never match, so shared classes such as `ios:pt-2` are harmless on the web. ### `dark:` follows a class Tailwind's default `dark:` uses `@media (prefers-color-scheme: dark)`, which the engine tracks. A class also supports palettes such as `.dark { --background: ... }` and in-app overrides of the OS theme. `watchConditions(app.engine)` keeps the root's `dark` class in sync with the system. For an app switcher, call `watchConditions(app.engine, { darkClass: false })` and control the class yourself; otherwise a system-dark root overrides the switcher's light choice. ```html … ``` ```ts import { computed, inject, signal, Service } from '@angular/core'; import { ColorScheme } from '@ng-native/device'; type Preference = 'light' | 'dark' | 'system'; @Service() export class Theme { private readonly system = inject(ColorScheme); private readonly preference = signal('system'); readonly className = computed(() => { const chosen = this.preference() === 'system' ? this.system.current() : this.preference(); return chosen === 'dark' ? 'dark' : ''; }); } ``` To preserve the preference across launches, save and restore it through the app's settings storage. The example does not persist it. A class retheme covers the app's own CSS only. To switch everything, the native chrome too (headers, switches, sheets, the keyboard), set the scheme on the window with `ColorScheme.set`: ```ts inject(ColorScheme).set('dark'); // the whole app is dark, whatever the system says inject(ColorScheme).set(null); // back to the system's ``` `prefers-color-scheme`, `light-dark()` and `ColorScheme.current` all follow it, so nothing else has to know. ## Tokens cross component boundaries CSS custom properties cascade down the node tree into child components. Ordinary rules match only their component's nodes because sheets attach to component classes, providing emulated encapsulation without per-element markers. A parent can retheme child internals through `--primary`, but cannot select them directly. ## Unsupported values and font fallbacks A unitless `line-height` value such as `calc(1.75 / 1.125)` inside a custom property leaves `text-lg` empty until build-time substitution resolves it. Unsupported values produce line-numbered diagnostics. Font stacks are a silent exception. `--font-sans` is a list, but native `fontFamily` accepts one name: `font-family: Inter, Helvetica, sans-serif` becomes `Inter`, discarding the rest without a warning. Native has no fallback stack. If the first font is unavailable or a CSS generic such as `ui-monospace`, text silently uses the system font. Name and verify a bundled font. --- # Build a form ## Install Signal Forms Install `@angular/forms` at your app's exact `@angular/core` version, shown by `npm ls @angular/core`. It is not a framework dependency. A mismatch causes `ERESOLVE` because `@angular/forms` requires matching `@angular/core` and `@angular/common` peers: ```sh npm install @angular/forms@ ``` Signal Forms binds directly to native control models: `form()` expects `value`, or `checked` for boolean controls. `` and `` expose these names. `@ng-native/components` uses this contract rather than `ControlValueAccessor`, which it does not support and never will. ## Define the form `form()` from `@angular/forms/signals` takes a data signal and a schema that adds validators to field paths: ```ts import { 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', imports: [FormField, TextInput, Switch, Text, View], template: ` @if (f.name().touched() && f.name().invalid()) { {{ f.name().errors().length }} error(s) } Subscribe to updates `, }) export class SignUp { protected readonly data = signal({ name: '', subscribed: false }); protected readonly f = form(this.data, (path) => { required(path.name); minLength(path.name, 3); }); } ``` `data` is the readable, writable state signal; `f` is its field tree, and `[formField]` binds a control to a path. `FormField`, `form`, `required` and `minLength` come from Angular's `@angular/forms/signals`. Include `FormField` in the component's `imports`. Without it, `[formField]` compiles but has no consumer: the control stays empty and the form invalid. This build does not run the browser compiler's usual "Can't bind to 'formField'" check. Instead, development builds log the element and missing import. The same applies to `ngModel` and Reactive Forms directives. ## Why no adapter class was needed `` exposes `value` as a `model()`; `` exposes `checked` as a `model()`. These names let `FormField` provide two-way binding, disabled/readonly state and touched-on-blur handling. See [Text input](/packages/components/input#the-signal-forms-contract) for the mapping of `disabled()`/`readonly()` to `editable` and `invalid`/`touched` to `data-invalid`/`data-touched` for CSS. ## Style invalid state Style the `data-*` attributes published by `FormField` and the control, as with press-state selectors: ```css text-input { border-width: 1px; border-color: #3a3a42; border-radius: 8px; padding: 12px; } text-input[data-invalid][data-touched] { border-color: #ff6b6b; } ``` ## Submit the form `submit()` runs your action only for a valid form and reports the outcome. Add a button, API call and status display: ```ts import { Component, signal } from '@angular/core'; import { FormField, form, minLength, required, submit } from '@angular/forms/signals'; import { Pressable, Switch, Text, TextInput, View } from '@ng-native/components'; /** Stands in for your own API call. Resolves false for a name that is already taken. */ async function createAccount(data: { name: string; subscribed: boolean }): Promise { return data.name !== 'ada'; } @Component({ selector: 'app-sign-up', imports: [FormField, Pressable, TextInput, Switch, Text, View], template: ` @if (f.name().touched() && f.name().invalid()) { {{ f.name().errors().length }} error(s) } Subscribe to updates {{ f().submitting() ? 'Signing up…' : 'Sign up' }} @switch (status()) { @case ('success') { You're signed up. } @case ('error') { Fix the errors above, then try again. } } `, }) export class SignUp { protected readonly data = signal({ name: '', subscribed: false }); protected readonly status = signal<'idle' | 'success' | 'error'>('idle'); protected readonly f = form(this.data, (path) => { required(path.name); minLength(path.name, 3); }); protected async save(): Promise { await submit(this.f, { action: async () => { const created = await createAccount(this.data()); if (!created) { return { kind: 'taken', message: 'That name is already taken.', fieldTree: this.f.name }; } this.status.set('success'); return; }, onInvalid: () => this.status.set('error'), }); } } ``` `submit()` first marks every field touched, showing errors without waiting for blur. For an invalid field tree, it calls `onInvalid` and resolves without running `action`. Only valid forms reach `createAccount`, the example API call. `f().submitting()` stays true during `action`, disabling the button and changing its label through signal bindings. `action` can return a validation error after validation passes. When `createAccount` returns `false`, the example names `f.name` as the error's `fieldTree`, so `f.name().errors()` exposes it like a client-side validation error. --- # Working offline Phone apps need to work through frequent connection losses. Use four steps: 1. Show cached data immediately, without waiting for a network request. 2. When connected, fetch fresh data and update the screen and cache. 3. When offline, keep displaying the cache. 4. Queue offline writes and send them on reconnection, preserving data without blocking the UI. Combine [`Network`](/packages/expo/network) for connectivity, [`Storage`](/packages/expo/storage) or [`Database`](/packages/expo/database) for caching, and `HttpClient` for requests. No new package is needed. ## Pick the cache: `Storage` or `Database` - **`Storage`** (or `SecureStorage`) holds individual preferences, flags or objects through a two-way signal, `store.signal('key', initial)`. Each read returns the whole value; it cannot query, filter or sort lists. - **`Database`** uses `expo-sqlite`, opened and migrated by `database()`, for feeds, search results and paginated rows. `SELECT ... WHERE ... ORDER BY` handles caches beyond a handful of items; `Storage` has no equivalent. The feed below uses `Database`. Use `Storage` for small values such as a last-synchronised timestamp; see the [Storage guide](/packages/expo/storage). ## The worked example: a note feed Declare the schema and migrations as a module-level value; [Database](/packages/expo/database) explains why this is a value rather than a service: ```ts // notes-db.ts import { database } from '@ng-native/expo/database'; export const notesDb = database('notes.db', [ { to: 1, up: (db) => db.execAsync(` CREATE TABLE note (id TEXT PRIMARY KEY, body TEXT NOT NULL, updated_at INTEGER NOT NULL); CREATE TABLE pending_write ( seq INTEGER PRIMARY KEY AUTOINCREMENT, id TEXT NOT NULL, body TEXT NOT NULL ); `), }, ]); ``` `pending_write` queues notes until the server receives them. Sort explicitly by `seq` to send oldest first; database row order is not guaranteed. The service reads the cache, refreshes online, and queues and flushes writes. It keeps the queue in memory and in `pending_write`. Unlike `Storage` and `Network`, `Database` throws without `expo-sqlite` (see [Without the module installed](/packages/expo/database#without-the-module-installed)). Each `notesDb` call therefore has its own `try`/`catch`. The running app reads and flushes the in-memory `queue`; SQLite persists it across restarts. Node tests and browser previews without `expo-sqlite` lose persistence, but the queue still works. ```ts // notes.ts import { Service, inject, signal, effect, type Signal } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { firstValueFrom } from 'rxjs'; import { Network } from '@ng-native/expo/network'; import { notesDb } from './notes-db.ts'; export interface Note { readonly id: string; readonly body: string; readonly updatedAt: number; } /** A queued write not yet sent, kept in memory so the queue works with no SQLite - see above. */ interface PendingWrite { readonly seq: number; readonly id: string; readonly body: string; } const nextId = () => Date.now().toString(36) + Math.random().toString(36).slice(2, 8); @Service() export class Notes { private readonly http = inject(HttpClient); private readonly network = inject(Network); private readonly list = signal([]); private readonly queue = signal([]); private readonly refreshing = signal(false); private flushing = false; private seq = 0; readonly notes: Signal = this.list.asReadonly(); readonly isRefreshing: Signal = this.refreshing.asReadonly(); constructor() { void this.loadFromCache(); // `connected` flips true the moment there is a network of any kind - see "reachable is not // connected" below for why this reads `connected`, not `reachable`. effect(() => { if (this.network.connected()) void this.refresh(); }); } /** * Cached rows, and the queue as it stood at the last launch, on screen immediately - this is * what a cold, offline start shows. The `catch` is what keeps a platform with no SQLite from * failing to start at all: no cache and an empty queue is exactly what a genuinely cold start * looks like anyway. */ private async loadFromCache(): Promise { try { const db = await notesDb.ready(); this.list.set( await db.getAllAsync( 'SELECT id, body, updated_at as updatedAt FROM note ORDER BY updated_at DESC', ), ); const pending = await db.getAllAsync( 'SELECT seq, id, body FROM pending_write ORDER BY seq', ); this.queue.set(pending); this.seq = pending.reduce((max, write) => Math.max(max, write.seq), 0); } catch { // No SQLite on this platform (Node under a test, a browser preview) - nothing was ever // written to read back, so an empty cache and an empty queue are the correct answer. } } /** * Fresh data when there is a connection; the cache already on screen is the offline fallback. * The in-memory `queue`, not a query against `pending_write`, decides which notes are still * local creations the server has not seen yet, so a refresh merges correctly whether or not * SQLite is there to ask. */ async refresh(): Promise { this.refreshing.set(true); try { await this.flushPendingWrites(); const fresh = await firstValueFrom(this.http.get('https://example.com/notes')); // Drop only notes the server no longer has and that are not still queued to be sent - a // pending note is never removed by a merge, only by a successful flush. const pendingIds = new Set(this.queue().map((write) => write.id)); const freshIds = new Set(fresh.map((note) => note.id)); const kept = this.list().filter((note) => pendingIds.has(note.id) && !freshIds.has(note.id)); this.list.set([...fresh, ...kept]); await this.persistAll(); } catch { // Offline, or the server is unreachable - the request fails and the cache stays exactly as // it was. There is no separate "offline branch" to write; not updating is the fallback. } finally { this.refreshing.set(false); } } /** Optimistic: the note is on screen and queued before the server has seen it. */ async add(body: string): Promise { const note: Note = { id: nextId(), body, updatedAt: Date.now() }; this.list.update((notes) => [note, ...notes]); const write: PendingWrite = { seq: ++this.seq, id: note.id, body: note.body }; this.queue.update((writes) => [...writes, write]); await this.persistNote(note); if (this.network.connected()) void this.flushPendingWrites(); } /** * Sends whatever is queued, oldest first by `seq`, and stops at the first failure to try again * later. `flushing` serialises this against itself, so a reconnect and a fresh `add()` calling * it at the same moment cannot send the same write twice. Reads and removes from the in-memory * `queue`; `pending_write` is a mirror of it kept for the next launch, not what this reads. */ private async flushPendingWrites(): Promise { if (this.flushing) return; this.flushing = true; try { const ordered = [...this.queue()].sort((a, b) => a.seq - b.seq); for (const write of ordered) { try { await firstValueFrom(this.http.post('https://example.com/notes', write)); this.queue.update((writes) => writes.filter((w) => w.seq !== write.seq)); await this.deletePendingRow(write.id); } catch { break; // still offline, or the server rejected it - leave the rest queued and retry later } } } finally { this.flushing = false; } } /** * The note and its queue entry, committed together in one transaction - a failure between the * two statements would otherwise leave a note with nothing that ever sends it. Best-effort, like * every SQLite call from here down: the `queue` signal above is what actually gets flushed, so * losing this write is losing durability across a restart, not losing the write. */ private async persistNote(note: Note): Promise { try { const db = await notesDb.ready(); await db.withTransactionAsync(async () => { await db.runAsync( 'INSERT INTO note (id, body, updated_at) VALUES (?, ?, ?)', note.id, note.body, note.updatedAt, ); await db.runAsync('INSERT INTO pending_write (id, body) VALUES (?, ?)', note.id, note.body); }); } catch { // No SQLite on this platform - the in-memory queue above is what actually gets flushed. } } /** Mirrors `refresh()`'s merged list into `note`, wholesale - simpler than reconciling row by * row, and cheap enough for a cache this size. Best-effort, as above. */ private async persistAll(): Promise { try { const db = await notesDb.ready(); await db.withTransactionAsync(async () => { await db.runAsync('DELETE FROM note'); for (const note of this.list()) { await db.runAsync( 'INSERT INTO note (id, body, updated_at) VALUES (?, ?, ?)', note.id, note.body, note.updatedAt, ); } }); } catch { // No SQLite on this platform. } } private async deletePendingRow(id: string): Promise { try { const db = await notesDb.ready(); await db.runAsync('DELETE FROM pending_write WHERE id = ?', id); } catch { // No SQLite on this platform. } } } ``` The [Notes example](/examples/notes) runs this with a sync-status pill and a settings toggle to disable sync. The component only reads signals and calls methods; it needs no offline-specific logic: ```ts // note-feed.ts import { Component, inject, signal } from '@angular/core'; import { Notes } from './notes.ts'; @Component({ selector: 'app-note-feed', template: ` @if (notes.isRefreshing()) { Refreshing… } @for (note of notes.notes(); track note.id) { {{ note.body }} } Add `, }) export class NoteFeed { protected readonly notes = inject(Notes); protected readonly draft = signal(''); protected addNote(): void { const body = this.draft().trim(); if (!body) return; void this.notes.add(body); this.draft.set(''); } } ``` Provide `HttpClient` once through `provideNativeHttpClient()` in `mount()`; see [HTTP requests](/packages/platform#http-requests): ```ts import { provideNativeHttpClient } from '@ng-native/platform/http'; mount(rootTag, App, getFabricUIManager(), { providers: [provideNativeHttpClient()], }); ``` ## An interceptor, for the same reconnect signal everywhere The `effect` handles one feed's reconnection. Use an `HttpInterceptorFn` for request-wide behaviour, such as retrying a failed `GET` once or tagging requests sent offline for the server: ```ts import type { HttpInterceptorFn } from '@angular/common/http'; import { catchError, throwError } from 'rxjs'; export const offlineAwareInterceptor: HttpInterceptorFn = (req, next) => next(req).pipe( catchError((error) => { // A network failure here is XHR's own error, not an HTTP status - there was no response to // have a status. Distinguish it from a real 4xx/5xx before deciding whether to retry. if (error.status === 0) { console.warn(`[offline] ${req.method} ${req.url} did not reach the server`); } return throwError(() => error); }), ); ``` ```ts import { withInterceptors } from '@angular/common/http'; import { provideNativeHttpClient } from '@ng-native/platform/http'; mount(rootTag, App, getFabricUIManager(), { providers: [provideNativeHttpClient(withInterceptors([offlineAwareInterceptor]))], }); ``` ## Pitfalls ### `reachable` is not proof a request will land [`Network`](/packages/expo/network) distinguishes a connection (`connected`) from the platform's internet-reachability estimate (`reachable`). The estimate is `null`, not `false`, until known; on iOS it equals `connected`. It informs the UI but does not probe your server. A banner checking `reachable === false` must treat `null` on `reachable` as unknown, not offline. The `refresh()` effect uses `connected` to decide whether to try a request. The `try`/`catch` handles unreachable servers even when `Network` reports internet access. Waiting for `reachable` would skip the first cold-start refresh while the estimate is unsettled. ### `HttpClient` needs `provideNativeHttpClient()` Plain `provideHttpClient()` silently returns null bodies because its default `fetch` backend cannot read React Native responses. The feed can appear offline, and its `catch` blocks do not resolve the ambiguity. Always use `provideNativeHttpClient()`; see [Known limitations](/guide/limitations#httpclient-needs-providenativehttpclient). ### The queue needs an order, a transaction, and a stopping point `flushPendingWrites()` sorts by `seq`, sends oldest first and stops at the first failure, leaving it queued. Reordering changes the user's write sequence; continuing after failure can leave a stuck write retrying indefinitely alongside later writes. The `flushing` flag prevents concurrent flushes when reconnection coincides with a new write. `add()` inserts the note and queue entry in one transaction, preventing crashes or write failures from leaving unsendable notes. `refresh()` merges server data into the cache and preserves queued notes absent from the response because the server has not confirmed them. ### `Database` is not inert without `expo-sqlite` - the queue has to be Without native modules, `Network.connected()` returns `false` and `Storage` signals retain their `initial` values. `Database` instead rejects `notesDb.ready()` on purpose: pretending to hold rows it does not have would be worse than saying so. See [Without the module installed](/packages/expo/database#without-the-module-installed). A SQLite-only cache and queue therefore fail entirely without the module. If `loadFromCache()` or `add()` awaits `notesDb.ready()` without `try`/`catch`, it rejects before queuing writes. These unawaited calls produce unhandled rejections in Node tests and browser previews without `expo-sqlite`. Keep `queue` as the running app's source of truth. Wrap each `notesDb` call in `try`/`catch` so persistence failures cannot stop dependent operations. SQLite adds durability underneath in-memory state that already works without it - the same graceful-fallback contract `Network` and `Storage` follow automatically, kept by hand here because `Database` will not keep it for you. The [Notes example](/examples/notes) uses this pattern in `sync/notes.ts`; its tests exercise the feed, queue and merge in Node without `expo-sqlite`. --- # Localisation Use Angular's `i18n` in templates, `$localize` in TypeScript, `localize-extract` to extract messages, and `loadTranslations()` from `@angular/localize` to load a language. Unlike the Angular CLI, Metro cannot bake a language into each build. Translations load at runtime before mount, so one build carries every language. Verification covers installation in a template app and iOS/Android bundles through `expo export`. `localisation.test.ts` covers the remaining examples through the Metro preset's compiler, except where explicitly noted as unverified. ## Install ```sh npm install @angular/localize@ npm install --save-dev @babel/core@^7.29.0 npx expo install expo-localization expo-secure-store ``` Replace `` with your app's exact `@angular/core` version, shown by `npm ls @angular/core`. Its peers include `@angular/compiler` and `@angular/compiler-cli`, which provide `localize-extract`. [`Locale`](/packages/expo/locale) uses `expo-localization` to read device languages. Omit `expo-secure-store` if the app follows the device rather than remembering an in-app language choice. ### The Babel pin, and why it is not optional `@angular/localize` 22 uses `@babel/core` 8 for build tools. React Native, Metro's Babel plugins and `react-native-worklets` use Babel 7, but React Native declares an unranged `@babel/core` peer. Without a direct Babel 7 dependency, a package manager is free to resolve that peer to Babel 8: - **pnpm** resolves React Native's peer to `@babel/core@8.0.1` in a fresh app or workspace, and the next bundle fails: `[Worklets] Babel plugin exception: Requires Babel "^7.0.0-0", but was loaded with "8.0.1"`. Bundles without worklets still succeed, hiding the problem until worklets are added. - **npm** usually nests Babel 8 under `@angular/localize`, but can hoist it to `node_modules` and nest Babel 7 under each Metro package. That layout bundles, but is unreliable. The `@babel/core@^7` dev dependency makes the peer resolve to Babel 7, keeping Babel 8 private to `@angular/localize`. Neither `@angular/localize/init` nor `loadTranslations()` uses Babel at runtime. Check the installed versions: ```sh pnpm why @babel/core # or: npm ls @babel/core ``` `react-native`, `expo` and `react-native-worklets` should show `7.x`; `8.x` should appear only under `@angular/localize` and `@angular/compiler-cli`. In pnpm workspaces, also pin Babel in the root `package.json`: an unpinned package can move every package's React Native onto Babel 8. ### Load the runtime first `@angular/localize/init` defines the `$localize` global. Import it first in the entry file, before any component module is evaluated: ```ts // src/main.ts import '@angular/localize/init'; import { AppRegistry, Image, Platform, processColor } from 'react-native'; // ...the rest of the entry file ``` ## Mark the text ### In a template `i18n` marks an element's text as a message. Give each one an ID with `@@`, and optionally a meaning and a description for the translator: ```html Your basket Pay now Hello, {{ name() }}! Tap the basket to check out ``` The format is `meaning|description@@id`; all parts are optional. Without an `@@id`, the runtime uses the same text-and-meaning hash as `localize-extract`. Changing the English changes that ID, orphaning its translations. Prefer custom IDs. Interpolations become placeholders (`{$INTERPOLATION}`). Nested elements produce paired placeholders (`{$STARTTAGTEXT}` and `{$CLOSETAGTEXT}`), letting translations move styled text to suit their grammar. Use names from the extracted file, which match this compiler's runtime, rather than Angular's documentation. ### In TypeScript `$localize` is a tagged template, with the same `:meaning|description@@id:` block at the front: ```ts protected readonly saveLabel = $localize`:@@editor.save:Save`; protected welcome(name: string): string { return $localize`:@@home.welcome:Welcome back, ${name}:name:!`; } ``` The `:name:` after an expression names its placeholder, `{$name}` in the extracted file; without one it is `{$PH}`. Keep `$localize` inside classes and functions. Class fields evaluate when components are created, after translations load. Module-level constants evaluate on import, before translations load, and remain in the source language. ### Attributes Bind a `$localize` string rather than marking the attribute with `i18n-`: ```html ``` ```ts protected readonly closeLabel = $localize`:@@dialog.close:Close the dialog`; ``` `i18n-accessibilityLabel` compiles but loses its source text. The extracted message is empty; without a translation, the label is also empty. A screen reader reading nothing is worse than one reading English. ### Plurals and selects ICU expressions (`{count, plural, =0 {...} other {...}}`) lose their placeholders during compilation and throw `Unable to parse ICU expression` on first render. Use these alternatives. For a plural, Angular's `i18nPlural` pipe, with each form as a message of its own: ```ts import { I18nPluralPipe } from '@angular/common'; @Component({ selector: 'app-basket-count', imports: [I18nPluralPipe, Text], template: `{{ count() | i18nPlural: items }}`, }) export class BasketCount { readonly count = input.required(); protected readonly items: Record = { '=0': $localize`:@@basket.empty:Your basket is empty`, one: $localize`:@@basket.one:One item`, other: $localize`:@@basket.other:# items`, }; } ``` An exact `=N` key takes precedence. Otherwise, `LOCALE_ID` selects a category using Angular's locale data, not `Intl`; `#` becomes the number. That is also the way to choose a category in code: Hermes has `Intl.NumberFormat` and `Intl.DateTimeFormat` but no `Intl.PluralRules`, so `new Intl.PluralRules(...)` throws on device. `getLocalePluralCase(locale)(count)` from `@angular/common` answers the same question from the locale data you registered. Include every category your languages need: English uses `one` and `other`; Polish also uses `few` and `many`. Missing categories fall back to `other`. Repeat the English text for categories without a distinct English form. For selects, use `@switch` with an `i18n` message per case: ```ts @Component({ selector: 'app-reply-line', imports: [Text], template: ` @switch (reply().author) { @case ('me') { You replied } @default { {{ reply().name }} replied } } `, }) export class ReplyLine { readonly reply = input.required<{ author: string; name: string }>(); } ``` ## Recipes - [Extracting messages](/guide/localisation-extraction) - pulling `messages.json` out of a Metro bundle, and translating a copy of it. - [Loading a language](/guide/localisation-loading) - deciding `LOCALE_ID` and loading its translations before the app's first frame. - [Switching language](/guide/localisation-switching) - in the app, and following the system. - [Formatting and right to left](/guide/localisation-formatting) - dates, numbers, currency, and mirroring a layout. ## What does not work yet - **ICU plurals and selects** throw on first render. Use `i18nPlural` and `@switch`. - **`i18n-` attributes** lose their source text. Bind a `$localize` string. - **Build-time translation** (`localize-translate`, one bundle per language) has no Metro integration or verification. Use runtime translation. - **`ng extract-i18n`** requires an unsupported browser build. Use `localize-extract` on the Metro bundle; see [Extracting messages](/guide/localisation-extraction). The first two gaps affect the Metro preset's template compiler; see [Known limitations](/guide/limitations#i18n-is-partial). --- # Extracting messages After marking templates with `i18n` and TypeScript with `$localize` as in [Localisation](/guide/localisation), run `localize-extract` on Metro's unminified JavaScript bundle, not Hermes bytecode. It extracts every message from compiled JavaScript: ```sh npx expo export --platform ios --no-bytecode --no-minify --output-dir i18n-build npx localize-extract -s 'i18n-build/**/*.js' -f json -o locale/messages.json rm -rf i18n-build ``` The output for the snippets in [Localisation](/guide/localisation), trimmed: ```json { "locale": "en", "translations": { "home.title": "Your basket", "home.greeting": "Hello, {$INTERPOLATION}!", "home.hint": "Tap {$STARTTAGTEXT}the basket{$CLOSETAGTEXT} to check out", "editor.save": "Save", "basket.other": "# items" } } ``` Extraction includes template messages and `$localize` strings from app code only. `-f` also accepts `xlf`, `xlf2`, `xmb` and `arb` for translation services. JSON works directly with `loadTranslations()`, without a parser. Copy `messages.json` to `messages.fr.json`, set `"locale": "fr"`, and translate the values, preserving every `{$...}` placeholder: ```json { "locale": "fr", "translations": { "home.title": "Votre panier", "home.greeting": "Bonjour, {$INTERPOLATION} !", "home.hint": "Touchez {$STARTTAGTEXT}le panier{$CLOSETAGTEXT} pour payer", "editor.save": "Enregistrer", "basket.other": "# articles" } } ``` `ng extract-i18n` requires a browser build, which fails for the template app with 164 errors across native properties (`Can't bind to 'zoomScale' since it isn't a known property of 'scroll-view'`), DOM/native event types and React Native's Flow syntax. Next: [Loading a language](/guide/localisation-loading) puts `messages.fr.json` into the app. --- # Loading a language With `messages.fr.json` from [Extracting messages](/guide/localisation-extraction), define a provider in a separate file to choose and load the language: ```ts // localisation.ts import { registerLocaleData } from '@angular/common'; import localeFr from '@angular/common/locales/fr'; import { LOCALE_ID, inject, makeEnvironmentProviders, provideAppInitializer, type EnvironmentProviders, } from '@angular/core'; import { loadTranslations } from '@angular/localize'; import { Locale } from '@ng-native/expo/locale'; import { getItem } from 'expo-secure-store'; import fr from './locale/messages.fr.json'; /** The language the source text is written in. There is nothing to load for it. */ const SOURCE = 'en'; const TRANSLATIONS: Record> = { fr: fr.translations, }; registerLocaleData(localeFr); /** The first language in the list that this app has, or the source language. */ export function chooseLanguage(preferred: readonly (string | null)[]): string { return ( preferred.find((code) => code === SOURCE || (code != null && code in TRANSLATIONS)) ?? SOURCE ); } export function provideLocalisation(): EnvironmentProviders { return makeEnvironmentProviders([ { provide: LOCALE_ID, useFactory: () => chooseLanguage([ getItem('language'), ...inject(Locale) .locales() .map((locale) => locale.languageCode), ]), }, provideAppInitializer(() => { const messages = TRANSLATIONS[inject(LOCALE_ID)]; if (messages) loadTranslations(messages); }), ]); } ``` Pass it to `mount`: ```ts // src/main.ts const app = mount(Number(rootTag), App, getFabricUIManager(), { // ...processColor, conditions, tokens, resolveAssetSource as before providers: [provideLocalisation()], }); ``` `LOCALE_ID` controls both translation loading and Angular pipe formatting. It selects the in-app choice first (see [Switching language](/guide/localisation-switching)), then device languages in preference order. Use `Locale.locales()`, not `Locale.locale()`: if the user prefers German then French, an app without German should choose French rather than English. `mount` runs initialisers before creating the root component, so translations must load synchronously. It starts promise-returning initialisers without awaiting them; network-fetched translations would arrive after the first frame and template evaluation. Import translations so Metro bundles them. Missing translations fall back to source text and log `No translation found`, so an incomplete file ships a partly English screen rather than a broken one. Unsupported device languages fall back to `SOURCE`. Next: [Switching language](/guide/localisation-switching) covers changing `LOCALE_ID` after startup. --- # Switching language After [Loading a language](/guide/localisation-loading), calling `loadTranslations()` again changes no existing templates, even after unmounting and remounting. Template messages are evaluated on the component's first render anywhere in the app and cached for the JavaScript runtime's lifetime. `$localize` class fields in newly created components use the new language, producing mixed-language screens. Tests verify both behaviours. Restart JavaScript to switch languages. Expo can reload in place: ```ts import { Component, LOCALE_ID, inject } from '@angular/core'; import { Pressable, Text, View } from '@ng-native/components'; import { reloadAppAsync } from 'expo'; import { setItemAsync } from 'expo-secure-store'; @Component({ selector: 'app-language-picker', imports: [Pressable, Text, View], template: ` @for (language of languages; track language.code) { {{ language.name }} } `, }) export class LanguagePicker { protected readonly current = inject(LOCALE_ID); protected readonly languages = [ { code: 'en', name: 'English' }, { code: 'fr', name: 'Français' }, ]; protected async switchTo(code: string): Promise { if (code === this.current) return; await setItemAsync('language', code); await reloadAppAsync('The language changed'); } } ``` Store the choice where startup can read it _synchronously_, before `LOCALE_ID` is set for the first frame. `expo-secure-store`'s `getItem` can read synchronously; AsyncStorage cannot. Await the write before reloading to avoid a race. Display language names in their own languages so users can recover from an unreadable choice. Alternatively, let the system manage language without app code. With `supportedLocales`, iOS and Android 13+ expose per-app language settings, which `Locale` reports: ```json { "expo": { "plugins": [["expo-localization", { "supportedLocales": ["en", "fr"] }]] } } ``` iOS terminates the app after a Settings language change; the next launch uses the new language. Android's behaviour for a running React Native app remains unverified. If necessary, on foreground, compare `chooseLanguage` over `Locale.locales()` with `LOCALE_ID` and reload if they differ. Expo documents `reloadAppAsync` for release and development builds; device behaviour remains unverified here. Next: [Formatting and right to left](/guide/localisation-formatting) covers dates, numbers and layout mirroring. --- # Formatting and right to left Alongside translations in [Localisation](/guide/localisation), language affects date and number formatting and layout direction. ## Dates, numbers and currency Angular's `date`, `number`, `percent` and `currency` pipes use `LOCALE_ID` and Angular's locale data. `@angular/common` does not call `Intl`, so Hermes and browsers behave alike without polyfills. English is built in; register other languages once, as [`localisation.ts`](/guide/localisation-loading) does with `registerLocaleData(localeFr)`. With `LOCALE_ID` set to `fr`: ```html {{ when | date: 'longDate' }} {{ 1234567.891 | number: '1.0-2' }} {{ 1234.5 | currency: 'EUR' }} ``` French uses narrow and non-breaking spaces, as in a browser. For regional variants (`fr-CA`, `en-GB`), register the matching `@angular/common/locales/` data file and use the full `LOCALE_ID` tag. ### Timezones `date` accepts the same timezones as in a browser through its third argument: offsets such as `'+0530'` or `'-05:00'`, `'UTC'`, `'GMT'`, and North American abbreviations such as `'EST'` or `'PDT'`. `mount()` adds support to Hermes' `Date.parse` for the legacy date form Angular uses to determine the offset. ```html {{ when | date: 'HH:mm' : 'UTC' }} {{ when | date: 'HH:mm' : '+0500' }} ``` Angular does not support named zones such as `'Europe/London'`, in browsers or here. The pipe uses the device's zone and logs a development warning once, naming the unsupported zone. For named zones, use `Intl.DateTimeFormat` with `timeZone`, supported by Hermes: ```ts new Intl.DateTimeFormat('en-GB', { timeZone: 'Asia/Tokyo', timeStyle: 'short' }).format(when); // '23:30' ``` Hermes supports only part of `Intl`, affecting direct callers. The iOS Hermes framework used here provides `Intl.Collator`, `Intl.DateTimeFormat` and `Intl.NumberFormat`, but no `Intl.PluralRules`, `Intl.RelativeTimeFormat` or `Intl.ListFormat`. Hence [Localisation](/guide/localisation#plurals-and-selects) uses `i18nPlural`. Check missing APIs before using them, for example `'PluralRules' in Intl`. Angular's locale data excludes user clock and calendar preferences; an English speaker may use a 24-hour clock. `Locale.calendars()` exposes `uses24hourClock`, `firstWeekday` and `timeZone` to help choose formats such as `'HH:mm'` instead of `'shortTime'`. ## Right to left The platform sets direction at startup, independently of translations. Enable `supportsRTL` in the same plugin entry to lay out a right-to-left language correctly: ```json ["expo-localization", { "supportedLocales": ["en", "ar"], "supportsRTL": true }] ``` React Native chooses direction from the device language at startup; it cannot change while the app runs. The cascade mirrors padding, text alignment and row-child order without stylesheet changes. For TypeScript-calculated positions, such as a drawer's opening edge or a slider's drag direction, use [`Direction`](/packages/device/direction) from `@ng-native/device`. `Locale.rtl()` reports language direction; branch on `Direction` for the actual layout. --- # Shipping to a device and the store [Getting started](/guide/getting-started) uses Expo Go, which covers most development. To include your own native code, build an app for a physical device and eventual App Store or Google Play distribution. - A **development build** includes Expo's launcher, fast refresh and dev menu. Use it for native modules or dependency patches absent from Expo Go. - A **preview build** compiles in release mode for device testing without store submission. - A **release build** is ready for submission: minified, without dev tools or Angular dev-mode assertions. See "What a release build strips" below. ## Configure the app All builds read `app.json`. The template supports Expo Go; native builds also need reverse-DNS iOS bundle and Android package identifiers. Both become permanent after shipping: ```json { "expo": { "name": "My App", "slug": "my-app", "version": "1.0.0", "ios": { "bundleIdentifier": "com.example.myapp" }, "android": { "package": "com.example.myapp" } } } ``` `version` is the store's human-readable version. iOS build numbers and Android version codes are separate build-time values. EAS Build increments them when a profile sets `autoIncrement`. The template has no `android/` or `ios/` folder. `npx expo run:ios`, `npx expo run:android` and EAS Build generate native projects from `app.json` using `expo prebuild` before compiling. Config changes take effect on the next build without manual syncing. ## Install `expo-dev-client` for a development build Install the package required for development builds, beyond Expo Go: ```sh npx expo install expo-dev-client ``` ## Build ### Locally ```sh npx expo run:ios npx expo run:android ``` These commands require Xcode or Android Studio, compile development builds locally and install on a connected device or simulator. Add `--configuration Release` (iOS) or `--variant release` (Android) for a local release build with no dev tools or Metro connection, the closest thing locally to what a store submission runs. ### With EAS Build ```sh npm install -g eas-cli eas login eas build:configure ``` `eas build:configure` writes `eas.json`, with three profiles: ```json { "build": { "development": { "developmentClient": true, "distribution": "internal" }, "preview": { "distribution": "internal" }, "production": { "autoIncrement": true } } } ``` `distribution: "internal"` permits direct-link installation without a store or a paid Apple developer account for testing. `developmentClient: true` installs `expo-dev-client` and enables Metro. Omit it for `preview` and `production` release builds. `autoIncrement` increases the build number and version code per build to prevent duplicate submissions. ```sh eas build --platform ios --profile development eas build --platform android --profile preview ``` EAS Build uses Expo's machines by default. `--local` produces the same artefact locally and requires the native toolchain used by `expo run`. Use it when Expo's build servers are unreachable or policy prohibits sending source off the machine. ### Signing On the first platform build, cloud builds create and store an Apple distribution certificate and provisioning profile or an Android upload keystore, then reuse them. Manage them with `eas credentials`. Local builds use the machine's Xcode or Android Studio signing configuration. ## Installing on a device Install **internal** development or preview builds through their device link or [Expo Orbit](https://expo.dev/orbit), which also installs from the build page. iOS devices must be registered to the signing Apple developer account; add them with `eas device:create`. Android APKs install on any device that permits them. Submit **production** builds, then use TestFlight or the Play Console's internal testing track to install them before public release. ## Submit to a store ```sh eas submit --platform ios eas submit --platform android ``` These commands submit the profile's latest build by default; use `--path` for another binary. iOS requires an App Store Connect API key or Apple ID and uploads to App Store Connect, appearing in TestFlight in ten to fifteen minutes. Android requires a Google Play service account key and uploads to the track selected in `eas.json` or on the command line. For screenshots, review information and first submissions, see Expo's guides: [Submit to the Apple App Store](https://docs.expo.dev/submit/ios/) and [Submit to the Google Play Store](https://docs.expo.dev/submit/android/). ## What a release build strips The Metro preset replaces `ngDevMode` with `false` in minified bundles, including release and preview builds. The minifier removes Angular's dev-mode assertions, error strings and `setClassMetadata` calls as dead code. See [Metro](/packages/metro) for other build-time processing. Development builds remain unminified, larger and slower. --- # Architecture Angular components call `Renderer2` rather than manipulating nodes. `@angular/platform-browser` implements that interface with calls such as `document.createElement`; `@angular/core` requires no DOM. `@ng-native/platform` provides a `Renderer2` and a `RendererFactory2` that build a retained tree for React Native's Fabric renderer. Fabric turns it into `UIView`s and Android `View`s. Angular replaces React's JavaScript and drives the same C++ renderer, with no React element tree, reconciler or `react-dom` equivalent. ## Rendering packages and responsibilities ``` @ng-native/components one component per native view, plus the behaviour composed into them @ng-native/platform Renderer2 over the engine, and bootstrap @ng-native/fabric the engine: retained tree, commit, events, CSS ``` The engine has no Angular dependency: `@nx/enforce-module-boundaries` bans `@angular/*` imports in `@ng-native/fabric`, catching dependencies the tests would miss. It also has no dependency on React Native's JavaScript; the host supplies what it needs. `@ng-native/platform` contains the only Angular-aware renderer code, fulfilling the role of `@angular/platform-browser` for the DOM. ## What a commit is The engine holds a **retained tree** of elements, text and anchors. Anchors participate in sibling ordering but never reach Fabric. Angular's structural directives use them as placeholders for absent views. The engine records Angular's individual renderer calls to create elements, set props and move children, then commits at most once per change-detection pass. Commits are incremental: unchanged subtrees return to Fabric by reference, avoiding rebuilds that would impair scrolling. Each commit ends with one call to Fabric's `completeRoot`, which diffs the new root against the screen in C++, off the JavaScript thread. ## The host seam Two hosts share an explicit contract. `HostEngine` and `HostNode`, in `@ng-native/fabric`, define only the calls the shared packages make. Fabric's `Engine` implements the former and `EngineNode` extends the latter, preserving the native path. `@ng-native/web`'s `BrowserEngine` implements both over the DOM without casts. `worklet-style`, `worklet-scroll` and `native-gesture` sit outside this interface. They inject the concrete `Engine` to access Fabric handles for Reanimated and react-native-gesture-handler, rather than answer wrongly through a browser stub. On the web, these native-only dependencies raise Angular's `NullInjectorError` naming `Engine`. ## CSS Angular components declare CSS in `styles` and `styleUrl`. Native has no CSS engine, and only `platform-browser` provides the `SHARED_STYLES_HOST` that receives Angular's `styles`. Without its own engine, Angular Native would silently drop component stylesheets. Angular Native handles CSS at build time and runtime. **At build time**, lightningcss parses component stylesheets inside the Metro transform. It converts values for React Native, compiles selectors into compounds and combinators, and sorts rules by specificity then source order. The device does no sorting. The rule set attaches to the component class as a static, which also scopes it: `RendererFactory2.createRenderer(host, type)` receives the component definition, whose class reference lets the renderer find its stylesheet without a registry or generated ID. There is no runtime CSS parser: lightningcss is a native Node addon. **At runtime**, `packages/fabric/src/css.ts` matches and merges rules. It matches right-to-left so the rightmost compound rejects most candidates immediately. Precedence, weakest first: native defaults, matched CSS, explicit props, inline `[style]`, `!important`. The cascade emulates inheritance for `color`, `font*`, `lineHeight`, `letterSpacing`, `textAlign`, `textTransform` and `textDecorationLine`. React Native does not inherit text properties: a view's `color` does not affect its text children. Without this propagation, browser-style sheets would silently lose inherited colours. ## The scope rule **If React Native can express it, CSS gets a spelling for it. If it cannot, the build drops it and warns.** `elevation` compiles because React Native accepts it, even though it has no effect on iOS; no-oping on one platform is React Native's own semantics. `float` has no native equivalent, so it is dropped with a build warning naming the file, the line and the reason; dropping it without a word would hide the problem. `::before`, `::after`, grid, table layout, `position: fixed` and `position: sticky` are permanently unsupported - their warnings say so rather than "not yet". React Native's style API cannot express them. A pseudo-element, for example, requires a node absent from the template, making the rendered tree diverge from the code. Declare the element in the template instead. ## Events Fabric dispatches events by node tag. The engine keeps the handler table, runs React Native's responder negotiation (capture down, bubble up, one node holding the gesture), and calls Angular. It uses React Native's event system without adding a synthetic layer. ## Routing `@angular/router` owns URLs, guards, resolvers and lazy loading. `` renders each activated route into an `RNSScreen` from react-native-screens. Pushes use native transitions and back gestures. Screens below the top stay mounted, preserving scroll positions and text-field state. Push, sheet and full-screen modal presentation travel as navigation state, keeping URLs typable, shareable and deep-linkable. --- # Native and web The same components use a shared renderer interface on native platforms and in browsers. Every example on this site runs a real `@ng-native/components` component through the browser renderer, not a screenshot or a React port. Controls have one implementation: `packages/components/src/switch.ts`, for example, serves both hosts. ## The seam `HostEngine` and `HostNode` in `@ng-native/fabric` define only the renderer calls `@ng-native/components` uses. Direct dependencies on `inject(Engine)`, whose constructor requires a `FabricUIManager`, and Fabric's `EngineNode` would couple components to native rendering. Casting a browser object through `as unknown as Engine` would bypass type checking. Fabric's `Engine` implements `HostEngine`; `EngineNode` extends `HostNode`. `BrowserEngine` implements both over the DOM without casts, while the native path stays unchanged. ## Mounting on the web ```ts import { provideRouter } from '@angular/router'; import { mount } from '@ng-native/web'; import { App } from './app/app.ts'; const root = document.getElementById('app-root')!; mount(root, App, { providers: [provideRouter(routes)] }); ``` Browser `mount` differs from `@ng-native/platform`'s `mount` only in host requirements. The first argument is the target `Element`. Fabric instead uses the numeric root tag supplied by React Native's host. The browser needs no `FabricUIManager`, `processColor` or device tokens. It provides `HostEngine`, not `Engine`, so the bases of `worklet-style`, `worklet-scroll` and `native-gesture` throw `NullInjectorError` naming `Engine`. Their public directives fail earlier: their Reanimated and gesture-handler imports cannot load in a browser. `DOCUMENT` is the real document, rather than native `mount`'s `TransferState` stub. ## The layout reset Browser and Yoga layout defaults differ. `flex-row` on a `` sets `flex-direction` explicitly and behaves identically on both platforms. Without flex properties, Yoga defaults to `display: flex; flex-direction: column; align-items: stretch; flex-shrink: 0`, while browsers give the unknown `` element `display: inline; flex-shrink: 1`. `@ng-native/web`'s `reset.css` aligns those defaults using `[data-rn]`, which marks every created node. Tag selectors would miss `text-input` and `switch`, rendered as `