Skip to content

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.

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).

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',
},
},
};

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} />}
</>
);
}

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.

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' } } | null

Useful 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.

  • A deep link resolving to the wrong screen (or null) is almost always a mismatch between the configured path pattern and the actual registered screen name — 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.

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 it
async function persist(state: INavigatorState): Promise<void> {
await AsyncStorage.setItem('nav-state', JSON.stringify(serializeNavigatorState(state)));
}
// on app launch, restore it
async 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