Linking & state
Deep linking gets your app to a specific screen from an external URL — a push notification tap, a universal link, a QR code. State persistence restores where the user left off across app restarts — the stack they had open, not just the initial route. Both are opt-in: neither runs unless you wire it up yourself, and both work identically across all four adapters at the config/data level — only the lifecycle glue that wires them into a component differs.
Deep linking
Section titled “Deep linking”The config shape (ILinkingConfig) is this library’s own hand-rolled type — not
react-navigation’s, and not built on react-router’s matcher. That was a deliberate choice: see
packages/navigation/src/core/linking-config.ts for why (react-router 8’s matcher has no
DOM-free subpath export and its param-name extraction only works for compile-time literal path
strings, neither of which fits a runtime config object on Hermes).
ILinkingConfig
Section titled “ILinkingConfig”type IScreenLinkingConfig = string | { path?: string; screens?: Record<string, IScreenLinkingConfig> };
type ILinkingConfig = { prefixes: string[]; // URL prefixes to match, e.g. 'myapp://', 'https://example.com' config: { screens: Record<string, IScreenLinkingConfig> }; // screen name -> path pattern ('user/:id') or a nested group};export const APP_LINKING_CONFIG: ILinkingConfig = { prefixes: ['myapp://', 'https://example.com'], config: { screens: { Details: 'details/:id', HeaderOptions: 'header-options', TabsDemo: 'tabs', }, },};Wiring it up
Section titled “Wiring it up”useLinkingIntegration(config, navigatorHandle) takes the resolved handle. A ref is
null on first render, so the real app defers mounting this hook in a small child component
until the Stack’s ref callback has fired:
import { useState } from 'react';import { Stack, useLinkingIntegration } from '@symbiote-native/navigation/react';import type { ILinkingConfig, INavigatorHandle } from '@symbiote-native/navigation/react';import { APP_LINKING_CONFIG } from './navigation-linking';
function LinkingRunner({ handle, config }: { handle: INavigatorHandle; config: ILinkingConfig }): null { useLinkingIntegration(config, handle); return null;}
function App() { const [stackHandle, setStackHandle] = useState<INavigatorHandle | null>(null); return ( <> <Stack ref={setStackHandle} initialRouteName="Home">{/* screens */}</Stack> {stackHandle !== null && <LinkingRunner handle={stackHandle} config={APP_LINKING_CONFIG} />} </> );}useLinkingIntegration(config, navigatorHandle) deliberately takes the ref itself, not a
resolved handle. Vue’s onMounted hooks fire bottom-up (children before parents), so by the
time this composable’s own onMounted runs, the Stack’s ref has already resolved — no child
component workaround needed, unlike React:
<script setup lang="ts">import { ref } from 'vue';import { Stack, useLinkingIntegration } from '@symbiote-native/navigation/vue';import type { INavigatorHandle } from '@symbiote-native/navigation/vue';import { APP_LINKING_CONFIG } from './navigation-linking';
const stackHandle = ref<INavigatorHandle | null>(null);useLinkingIntegration(APP_LINKING_CONFIG, stackHandle);</script><template> <Stack ref="stackHandle" initial-route-name="Home"><!-- screens --></Stack></template>injectLinkingIntegration(config, navigatorHandle) is an injection function — it calls
inject(DestroyRef) internally, so it needs an injection context, which a plain lifecycle-hook
body isn’t (only field initializers/constructors are). Stack implements INavigatorHandle
directly, so @ViewChild resolves the handle itself once view children are populated. The real
wiring runs it from ngAfterViewInit through runInInjectionContext:
import { AfterViewInit, Component, Injector, ViewChild, inject, runInInjectionContext,} from '@angular/core';import { Stack, ScreenDirective, injectLinkingIntegration } from '@symbiote-native/navigation/angular';import { APP_LINKING_CONFIG } from './navigation-linking';
@Component({ selector: 'App', standalone: true, imports: [Stack, ScreenDirective], template: ` <Stack #nav initialRouteName="Home"> <!-- screens --> </Stack> `,})export class App implements AfterViewInit { @ViewChild('nav') private readonly nav!: Stack; private readonly injector = inject(Injector);
ngAfterViewInit(): void { runInInjectionContext(this.injector, () => { injectLinkingIntegration(APP_LINKING_CONFIG, this.nav); }); }}ngAfterViewInit is the first lifecycle hook Angular guarantees @ViewChild queries are
populated by, making it the earliest safe call site — the same gating React’s LinkingRunner
achieves with a null-checked child component, done here with one hook instead of a second one.
useLinkingIntegration(config, getNavigatorHandle) takes a getter function, not a
resolved handle or a ref object: a Svelte component’s script runs exactly once, and
bind:this only assigns its target during mount, so the rune reads the getter inside its
own $effect — after mount, no ref-callback workaround needed like React’s. Since
stackInstance below is declared with $state.raw, that read is tracked, so the wiring
re-runs the moment the binding actually resolves:
<script lang="ts"> import { Stack, useLinkingIntegration } from '@symbiote-native/navigation/svelte'; import type { INavigatorHandle } from '@symbiote-native/navigation/svelte'; import { APP_LINKING_CONFIG } from './navigation-linking';
// `Stack` ships as a raw `.svelte` file, so TypeScript resolves it through svelte's // ambient `declare module '*.svelte'` fallback — a bare component type with the // `export function` surface `bind:this` actually hands back erased. So the binding // target is typed `unknown` and narrowed on read instead of annotated directly. let stackInstance = $state.raw<unknown>(null);
function isNavigatorHandle(value: unknown): value is INavigatorHandle { return typeof value === 'object' && value !== null && 'push' in value && 'replace' in value; }
useLinkingIntegration(APP_LINKING_CONFIG, () => isNavigatorHandle(stackInstance) ? stackInstance : null, );</script>
<Stack bind:this={stackInstance} initialRouteName="Home"><!-- screens --></Stack>Behavior
Section titled “Behavior”All four adapters share the same semantics: on mount, the OS’s initial URL is resolved once and
navigatorHandle.replace(name, params) is called for the resolved route. From then on, incoming
deep-link URL events are subscribed to, and each one calls navigatorHandle.push(name, params).
Both paths resolve the URL through the same function as the standalone one below.
Manual resolution
Section titled “Manual resolution”resolveRouteFromUrl and its inverse resolveUrlFromRoute are plain functions exported from the
framework-agnostic @symbiote-native/navigation package root — not the /react, /vue, or
/angular subpath, since they need no framework at all:
import { resolveRouteFromUrl } from '@symbiote-native/navigation';
const route = resolveRouteFromUrl(APP_LINKING_CONFIG, 'myapp://details/42');// -> { key: 'Details', name: 'Details', params: { id: '42' } } | nullUseful for a debug screen, or for testing your linking config without triggering a real OS-level deep link — no OS event, no navigator, no adapter needed at all.
Common gotchas
Section titled “Common gotchas”- A deep link resolving to the wrong screen (or
null) is almost always a mismatch between the configured path pattern and the actual registered screenname— double-check they match exactly. - Screen/navigator names must be unique across the whole linking config, even across nested groups — a duplicate name makes resolution ambiguous.
- Changing
prefixes(a custom URL scheme or universal-link domain) requires a native rebuild — a JS-only reload won’t pick up new native URL-scheme/associated-domain entitlements.
State persistence
Section titled “State persistence”serializeNavigatorState / deserializeNavigatorState are plain framework-agnostic functions
from the @symbiote-native/navigation package root — persistence storage (e.g. AsyncStorage) is
left to the app, this package only handles the serialize/validate step. To restore, pass the
result into a Stack handle’s reset(state) method:
import { serializeNavigatorState, deserializeNavigatorState } from '@symbiote-native/navigation';import type { INavigatorState } from '@symbiote-native/navigation';import type { INavigatorHandle } from '@symbiote-native/navigation/react';
// on navigator state change, persist itasync function persist(state: INavigatorState): Promise<void> { await AsyncStorage.setItem('nav-state', JSON.stringify(serializeNavigatorState(state)));}
// on app launch, restore itasync function restore(stackHandle: INavigatorHandle): Promise<void> { const raw = await AsyncStorage.getItem('nav-state'); if (raw === null) return; stackHandle.reset(deserializeNavigatorState(JSON.parse(raw)));}| Signature | Description |
|---|---|
useLinkingIntegration(config: ILinkingConfig, navigatorHandle: INavigatorHandle): void (React) |
Subscribes to deep links and drives the given handle. Takes the resolved handle — mount it only once the Stack’s ref is non-null |
useLinkingIntegration(config: ILinkingConfig, navigatorHandle: Ref<INavigatorHandle | null>): void (Vue) |
Same behavior, but takes the ref itself — reads .value lazily inside its own onMounted, which fires after the Stack’s ref has resolved |
injectLinkingIntegration(config: ILinkingConfig, navigatorHandle: INavigatorHandle): void (Angular) |
Same behavior as an injection function — needs an injection context (inject(DestroyRef) internally), call it via runInInjectionContext from ngAfterViewInit |
useLinkingIntegration(config: ILinkingConfig, getNavigatorHandle: () => INavigatorHandle | null): void (Svelte) |
Same behavior, but takes a getter function over the Stack’s bind:this target — reads it inside its own $effect, tracked so it re-runs once the binding resolves |
resolveRouteFromUrl(config: ILinkingConfig, url: string): IRoute<unknown> | null |
Resolves a URL to a route synchronously, with no navigator or OS event involved — the same resolution the integration hooks use internally |
resolveUrlFromRoute(config: ILinkingConfig, route: IRoute<unknown>): string | null |
The inverse of resolveRouteFromUrl — builds a URL for a route, filling :param segments from route.params; null when a required param is missing or the route name isn’t configured |
serializeNavigatorState(state: INavigatorState): unknown |
Currently an identity passthrough — INavigatorState is already plain JSON-safe data |
deserializeNavigatorState(raw: unknown): INavigatorState |
Validates the shape at runtime and throws "deserializeNavigatorState: persisted value is not a valid navigator state" if invalid — no blind as cast |