createNavigation()
The app's navigation in one object: its screens, its first root, how the screens look and show, the data every screen reads, and what runs at launch. AppRegistry.registerComponent in index.js takes it, and the app starts from it when the native side launches.
import { createNavigation } from '@apexnative/navigation';
You call it once, in its own file, and hand the result to AppRegistry.registerComponent in index.js. There is no navigation container component to render: the
native side starts the app from this object when it launches.
Usage
import { createNavigation } from '@apexnative/navigation';
import { HomeScreen } from './screens/Home';
import { DetailsScreen } from './screens/Details';
export const Navigation = createNavigation({
screens: { Home: HomeScreen, Details: DetailsScreen },
root: 'Home',
});
import { AppRegistry } from '@apexnative/navigation';
import { Navigation } from './src/Navigation';
AppRegistry.registerComponent('MyApp', () => Navigation);
The app opens on Home inside a stack, with the platform's top bar. A navigation.push('Details') from Home slides
Details in.
A real app usually adds the app's data and a start that loads it, so the first screen depends on whether the user is signed in:
export const Navigation = createNavigation({
screens: { Home: HomeScreen, Login: LoginScreen },
context: { signedIn: false },
willMount: async () => {
const token = await readToken(); // your own storage
navigation.setContext({ signedIn: !!token });
},
root: (context) => (context.signedIn ? 'Home' : 'Login'),
});
The splash screen stays while willMount reads the token, then Home or Login shows. No screen flashes in between.
For typed screen names and props in every navigation call, declare the app once:
declare module '@apexnative/navigation' { interface Register { app: typeof Navigation } }. See
Screens and props.
Signature
function createNavigation<
Context = void,
// As written: a screen named alone (`roots: { login: 'Login' }`) stays its name, not any string.
const Roots extends RootsMap = Record<never, never>,
const Target extends RootTarget<Roots> = RootTarget<Roots>,
AppScreens extends Screens = Record<never, never>,
Groups extends ScreenGroups = Record<never, never>,
// The layout of the first root, not the whole value of `root`: TypeScript then types the parameter of
// `root: (context) => …` from the context and infers the layout from what the function returns.
const FirstLayout extends RootLayout = never,
Patterns extends string = never,
>(
config: NavigationConfig<
Context,
Roots,
Target,
AppScreens,
Groups,
FirstRoot<Context, FirstLayout>,
Patterns
> &
NoInfer<CheckedRoots<FirstLayout, Roots, AllScreens<AppScreens, Groups>>>
): NavigationApp<Roots, AllScreens<AppScreens, Groups>>
It returns a NavigationApp: an opaque value for AppRegistry.registerComponent. It has no methods; commands go
through navigation, which works once the app has started.
Every key below is optional except the first root: root, or roots with initialRoot.
The screens
screens
screens?: AppScreens
AppScreensPlatformsiOSiPadOSAndroidThe screens of the app by name. Every command names a screen by this name (navigation.push('Details')), and the
props it passes are typed from the component of defineScreen.
screens: {
Home: HomeScreen,
Details: DetailsScreen,
Profile: ProfileScreen,
},
Only these screens can be shown. A name that isn't here is a type error once the app's type is registered, and at run time the command rejects: "There is no screen 'Detail' in screens".
groups
groups?: { [Group in keyof Groups]: ScreenGroup & { screens: Groups[Group] } }
{ [Group in keyof Groups]: ScreenGroup & { screens: Groups[Group] } }PlatformsiOSiPadOSAndroidScreens that share options, by group name. The group's options go under each screen's own, so a sign-in flow
without a top bar is one line instead of one per screen:
groups: {
auth: {
options: (s) => s.hideTopBar(),
screens: { Login: LoginScreen, SignUp: SignUpScreen },
},
},
The names of all groups and of screens are one namespace: a command names 'Login', not 'auth/Login'. A name
in two places fails the start, with an error naming both.
The first root
A root is what fills the window: one screen, a stack, tabs, a side menu, a split view. The app has exactly one at a
time. root gives the one the app starts with; roots names several to switch between.
root
root: Root
Root Required.PlatformsiOSiPadOSAndroidThe first root. Three forms:
- A screen name,
'Home': the screen inside a stack, the usual start. - A layout,
Tabs(Tab('Home'), Tab('Settings')): anything the layout builders make. - A function of the context,
(context) => …: called oncewillMountis done, so it sees what the start loaded.
root: (context) =>
context.signedIn
? Tabs(Tab('Home'), Tab('Settings'))
: Stack('Login'),
The screens a layout uses are checked against screens and groups by the types.
roots
roots?: never
neverPlatformsiOSiPadOSAndroidNamed roots, for an app that switches between whole layouts: signed out and signed in, onboarding and the main app. A root is a layout, or a function of a param that makes one.
roots: {
signedOut: Stack('Login'),
main: (tab: 'home' | 'inbox') => mainTabs(tab),
},
initialRoot: (context) =>
context.signedIn ? ['main', 'home'] : 'signedOut',
navigation.switchTo('main', 'inbox') then replaces the whole window, typed by the names and params here. With
roots, root isn't allowed. See Switching roots.
initialRoot
initialRoot?: never
neverPlatformsiOSiPadOSAndroidWhich of roots the app starts with: its name, or [name, param]. It is a function of the context, called after
willMount, so the choice can depend on what the start loaded. Required with roots.
The app's data
context
context?: Context
ContextPlatformsiOSiPadOSAndroidThe first value of the app's context: data every screen reads, such as whether the user is signed in, or services (an API client) a screen needs. Its type becomes the type of the context everywhere.
context: { signedIn: false, api: createApi() },
// in any screen, which renders again when the value changes
const signedIn = useAppContext((context) => context.signedIn);
It is there from the first moment, while willMount runs too. navigation.setContext is the one way to change it,
from a screen or from outside React.
The context exists because each screen is a React root of its own by default: a React context created at the top of the app doesn't reach them. You don't have to use it. A store of Redux, Zustand or another library works too; see State management for each way and when to pick which.
Launch
What runs before and after the first root, and what the user sees meanwhile.
willMount
willMount?: () => void | PromiseLike<void>
() => void | PromiseLike<void>PlatformsiOSiPadOSAndroidRuns before the first root, while the splash screen shows. The place to load what the first screen needs: a stored token, the user, remote config. The first root waits for the promise.
willMount: async () => {
const user = await loadUser(); // your own
navigation.setContext({ signedIn: !!user, user });
},
- It runs once per JavaScript runtime, not once per launch. On Android the activity can be recreated while the
JavaScript keeps running; the app then shows its root again without
willMount. - If it throws or rejects, the start fails:
errorFallbackshows as the first screen, and itsretryrunswillMountagain. WithouterrorFallback, the splash screen stays and the next launch tries again. - Keep it short. The user looks at the splash screen until it resolves; what can wait goes to
didMount.
didMount
didMount?: (context: Context) => void
(context: Context) => voidPlatformsiOSiPadOSAndroidRuns once the first root is shown, with the context. The place for what shouldn't delay the first screen: prefetching, asking for notification permission, starting analytics.
didMount: (context) => {
if (context.signedIn) prefetchInbox(); // your own
},
Like willMount, once per JavaScript runtime.
splash
splash?: SplashConfig
SplashConfigPlatformsiOSiPadOSAndroidA splash of your own in React Native, after the native one: an animation of the logo, or a progress while willMount
runs. The native splash is a picture in the native files and can't move; this one is a component.
splash: {
component: Splash,
minDuration: 800,
},
On a cold start the native splash stays until Splash is drawn, and Splash takes its place at once. It stays while
willMount runs; once willMount is done and 800 ms have passed, the first root comes in over it with a fade. Its
keys:
component: a component without props, over the whole window, under the status bar too. It isn't one ofscreens. Its logo lands on the native one at the size ofuseSplash().logo.minDuration: the least time it stays, in ms, from when it shows: the length of its animation.0by default.hide:'auto', the default, leaves oncewillMountis done andminDurationpassed;'manual'waits foruseSplash().hide()too, such as at the end of an animation.transition: how the first root replaces it, asRoot(…).transitiontakes it;'fade'by default. The root that a launch link'sstartmakes comes in the same way; a first root built with a transition of its own keeps it.
When it shows:
- Once per cold start of the app's process. Not after a reload of the JavaScript, which goes to the first root at once, and not when Android gives the app a new activity while the JavaScript runs.
- Outside the providers. It renders before
willMountends, so it is a React root of its own, outsideappProviderandscreenProvider, which may need whatwillMountprepares. The context is there:useAppContextreads it aswillMountchanges it. - When the start fails,
errorFallbackshows in its place, and itsretryruns the start again without the splash. WithouterrorFallbackthe splash stays.
In the navigation's state the splash is a root with the screen ApexNavigation.Splash. The whole way, with the native
splash the CLI makes: Splash screen.
Providers
By default every screen is a React root of its own. A provider such as Redux's <Provider> or React Query's
<QueryClientProvider> therefore doesn't reach the screens from index.js: it has to wrap them here. Two places:
appProvider | screenProvider | |
|---|---|---|
| Instances | One, for the whole app | One per shown screen |
| Screens render | In one React tree, inside it | Each in its own root |
| State in the provider | Shared, lives as long as the app | Each screen has its own |
| For | Providers with shared state: a store, a query client | Providers without shared state (a UI kit theme, a portal host), or a store made once at module level |
appProvider
appProvider?: AppProvider
AppProviderPlatformsiOSiPadOSAndroidWraps the whole app once. With it every screen renders in one React tree inside the provider, so a React context it holds reaches every screen, and its state lives as long as the app.
import type { ReactNode } from 'react';
import { Provider } from 'react-redux';
import { store } from './store';
export function AppProvider({ children }: { children: ReactNode }) {
return <Provider store={store}>{children}</Provider>;
}
appProvider: AppProvider,
The screens stay the platform's screens; only their React content moves into one tree. See One tree for how it works and what changes.
screenProvider
screenProvider?: ScreenProvider
ScreenProviderPlatformsiOSiPadOSAndroidWraps each screen on its own, inside appProvider when there is one. It gets the screen as { id, name }:
screenProvider: ({ children, screen }) => (
<PortalHost name={screen.id}>{children}</PortalHost>
),
screenProvider renders once for every shown screen. A store or a client created inside it (useState(() => new QueryClient())) is a new one for each screen, and the screens don't share data. Create it once, at module level, and
pass it in, or use appProvider.
Errors and loading
errorFallback
errorFallback?: ComponentType<ErrorFallbackProps>
ComponentType<ErrorFallbackProps>PlatformsiOSiPadOSAndroidWhat a screen that failed to render shows instead. It gets { error, screen, retry }; retry renders the screen
again. The screen's top bar stays, so the user can go back.
errorFallback: ({ error, retry }) => (
<ErrorView message={String(error)} onRetry={retry} />
),
Without it, a failed screen is empty and the other screens go on. When the start fails (willMount or the first root),
it is the first screen, in place of the splash screen, and its retry runs the start again. It renders inside
screenProvider, unless the provider itself failed. See Errors and loading.
loadingFallback
loadingFallback?: LoadingFallback
LoadingFallbackPlatformsiOSiPadOSAndroidWhat a screen shows while it suspends: a React.lazy component, or data of a Suspense library. Nothing without it.
loadingFallback: () => <ActivityIndicator style={{ flex: 1 }} />,
onError
onError?: (error: unknown, source: NavigationErrorSource) => void
(error: unknown, source: NavigationErrorSource) => voidPlatformsiOSiPadOSAndroidEvery error the navigation catches, with where it came from: source.type is 'screen' (with the screen and the
component stack), 'start', or 'link' (with the URL). The place to report them:
onError: (error, source) => {
Sentry.captureException(error, { tags: { source: source.type } });
},
Without it the errors go to the console, as React Native reports them. Errors of commands aren't here: a command rejects its own promise.
Look
options
options?: AppOptionsInput
AppOptionsInputPlatformsiOSiPadOSAndroidThe default options of every screen. A screen's own options, and those of a command, go over them. Options as an object, or a chain built from the theme and the context:
options: (s, { theme }) =>
s.largeTitle(false).backButton({ title: '' }),
The chain is built again when the theme changes, so options that depend on the theme follow it. See Screen options.
theme
theme?: NavigationTheme
NavigationThemePlatformsiOSiPadOSAndroidThe colors of the navigation: primary (bar buttons, the selected tab), background, bar, text, border,
badge. A color can have a light and a dark value; the platform switches them with the system appearance.
theme: {
colors: {
primary: '#0A84FF',
background: { light: '#FFFFFF', dark: '#000000' },
},
},
navigation.setTheme changes it while the app runs; React content reads it with useNavigationTheme. See
Theme and context.
presentation
presentation?: Presentation
PresentationPlatformsiOSiPadOSAndroidWhat every push, showModal and showOverlay starts from; the settings of a command go over it.
presentation: {
modal: { style: 'pageSheet', closeButton: true },
},
Every modal of the app is then a page sheet with a close button, unless a showModal says otherwise.
Across the app
listeners
listeners?: NavigationListeners
NavigationListenersPlatformsiOSiPadOSAndroidHandlers of the navigation's events for as long as the app runs, outside any screen: every screen that appears, every tab press, every change of the state.
listeners: {
screenDidAppear: ({ screen }) => analytics.screen(screen.name),
},
A screen listens to its own events with hooks instead (useScreenAppear, useTabPress). See
Lifecycle and events and Analytics.
linking
linking?: LinkingConfig<Context, Roots, Patterns, AllScreens<AppScreens, Groups>>
LinkingConfig<Context, Roots, Patterns, AllScreens<AppScreens, Groups>>PlatformsiOSiPadOSAndroidThe links that open the app's screens, by path: custom schemes, universal links iOS and App Links Android.
linking: {
prefixes: ['myapp://', 'https://example.com'],
routes: { 'user/:userId': 'Profile' },
},
myapp://user/42 pushes Profile with { userId: '42' }. A link runs after willMount, once the first root is shown;
the link that launches the app can make that root itself. The schemes and domains are also declared natively. See
Deep links.
when keeps links waiting, such as until the sign-in, from the app's context. It is asked when a link comes and,
for a waiting link, on each setContext; a waiting link then runs by itself (pending: 'auto'). With the sign-in in a
store instead, set pending: 'manual' and call navigation.openPendingLink() after the sign-in: the navigation
doesn't see the store change. See Links that wait for the sign-in.
direction
direction?: NavigationDirection
NavigationDirectionPlatformsiOSiPadOSAndroid'ltr', 'rtl', or 'locale' for the language of the app. The bars, the transitions and the swipe back follow it.
navigation.setDirection changes it while the app runs. See Right to left.
safeArea
safeArea?: { edges: SafeAreaEdge[] }
{ edges: SafeAreaEdge[] }PlatformsiOSiPadOSAndroidThe edges on which every screen keeps its content out of the safe area. By default the top and the sides, but
those where the content goes under a bar; the bottom is free, and a list keeps clear of it with useSafeAreaInsets(). A screen sets its own with safeArea(…) in its
options; set it here only to change it for every screen.
window
window?: WindowOptions
WindowOptionsPlatformsiOSiPadOSAndroid (not supported)iOS The app's window, behind every screen: its color shows around a modal as it comes in. On Android the window is the activity's, set in the app's theme.
window: { backgroundColor: '#000000' },
Where to call it
- In a module of its own, such as
src/Navigation.ts, imported byindex.js. Not inside a component: it describes the app once. - Screens import
navigation, not this module. A screen that importsNavigationwhileNavigationimports the screen makes an import cycle. The app's type goes throughRegister(above), which is a type import only. - Commands wait for the start. Code in
willMountsets the context; apushbelongs after the first root, indidMount, a screen or a listener.
See also
- Starting the app: the launch step by step, relaunches, failures.
- Switching roots, Theme and context, One tree.
- State management: Redux, Zustand and others with this navigation.