Skip to main content

One tree

How screens render: a React root per screen by default, a provider around every screen, or one tree with every screen inside the app's provider. How it differs from navigation in the React tree, and when to pick which.

SectionOne treeDraftDocumented, not reviewed yet: the API can change.

A React app usually has its providers at the top of one tree: a store, a query client, the theme of a UI kit. Screens of this navigation are the platform's own screens, each with React content of its own, so the app says where its providers go. A provider that every screen shares goes in appProvider:

src/AppProvider.tsx
import type { ReactNode } from 'react';
import {
QueryClient,
QueryClientProvider,
} from '@tanstack/react-query';

const queryClient = new QueryClient();

export function AppProvider({ children }: { children: ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
}
Navigation.ts
import { createNavigation } from '@apexnative/navigation';
import { AppProvider } from './src/AppProvider';

export const Navigation = createNavigation({
screens: { Inbox: InboxScreen, Message: MessageScreen },
root: 'Inbox',
appProvider: AppProvider,
});

Every screen now renders inside AppProvider, and reads the one query client. This page answers:

  1. How it differs from navigation that lives in the React tree.
  2. How screens render: a root per screen, or one tree.
  3. A root per screen, the default, and a provider around every screen.
  4. One tree, with every screen inside the app's provider.
  5. Which to pick.

How it differs​

When the navigation lives in the React tree, the screens are React components inside it, under the app's providers. A provider at the top reaches every screen by itself, because every screen is its child.

Here the screens are the platform's. The platform's screen is created when a command shows it, and goes when the user leaves it. Each one needs React content of its own, and by default each gets a React root of its own. A provider at the top of one root doesn't reach the others, so the app says where its providers go:

Navigation in the React tree
<QueryClientProvider client={client}>
<NavigationRoot>
<Screen name="Inbox" />
<Screen name="Message" />
</NavigationRoot>
</QueryClientProvider>
Native screens
createNavigation({
screens: { Inbox, Message },
root: 'Inbox',
// every screen renders inside it
appProvider: AppProvider,
});
Navigation in the React treeHere: a root per screenHere: one tree
Where a screen rendersIn the app's treeIn a React root of its own, inside its native screenIn the app's tree, shown in its native screen
A provider at the topReaches every screenReaches nothing: screenProvider wraps each screenReaches every screen: appProvider
Who moves the screensReact, with its stateThe platformThe platform
A screen that goesUnmounts from the treeIts root unmounts with the native screenIts part of the tree unmounts with the native screen

So one tree gives back what a router in the React tree has, one set of providers for every screen, while the screens stay native.

How screens render​

A root per screen · the default
screenProviderHomescreenProviderChatscreenProviderProfile
Each screen is a React tree of its own, with its own copy of a provider.
One tree · with appProvider
appProviderHomeChatProfile
Every screen renders inside one provider: what it holds is shared, and lives as long as the app.

The mode is set once, in createNavigation. With appProvider the app runs in one tree; without it each screen is a root of its own.

A root per screen​

Without providers in createNavigation, each screen is a React root of its own. A store kept in a module is shared anyway, because every screen imports the same module:

src/cart.ts
import { create } from 'zustand';

type Cart = { count: number; add(): void };

export const useCart = create<Cart>((set) => ({
count: 0,
add: () => set((cart) => ({ count: cart.count + 1 })),
}));
Navigation.ts
export const Navigation = createNavigation({
screens: { Products: ProductsScreen, Cart: CartScreen },
root: 'Products',
});

Products adds to the cart, and the Cart screen pushed later shows the same count: both read useCart, and nothing wraps them. The same goes for Redux with its store imported, or MobX.

A provider around every screen​

screenProvider wraps every screen on its own. It is the place for providers that hold no state the screens share, such as the theme of a UI kit or the language of a translation library:

src/ScreenProvider.tsx
export function ScreenProvider({ children }: { children: ReactNode }) {
// the provider of your UI kit
return <UiKitProvider theme={uiTheme}>{children}</UiKitProvider>;
}
Navigation.ts
export const Navigation = createNavigation({
screens: { Products: ProductsScreen, Cart: CartScreen },
root: 'Products',
screenProvider: ScreenProvider,
});

Each screen has its own copy of it, so state kept inside it is the screen's alone: a counter in it starts from zero on every screen, and is gone when the screen goes.

screenProvider also gets the screen as screen: { id, name }, so it can differ for one screen. It renders inside the screen, so the screen's hooks, such as useScreen and useNavigation, work in it; appProvider is above every screen, and has none.

One tree​

appProvider wraps the whole app once, and every screen renders inside it. It is the place for providers that hold state the screens share in React context, such as the query client of the example at the top of this page.

The Inbox loads the messages; the Message screen pushed later reads the same query, and shows the message at once from the cache the Inbox filled:

src/screens/Message.tsx
function Message({ id }: { id: string }) {
const { data } = useQuery({
queryKey: ['messages'],
queryFn: loadMessages, // your own request, the same as in Inbox
});
const message = data?.find((item) => item.id === id);
return <Text>{message?.body}</Text>;
}

With a root per screen, each screen would need a client of its own: Message would start empty, and load the messages again.

  • The screens stay native. Each one is pushed, presented and put in tabs as any other, with its bars and gestures. Only its React content renders inside the app's tree, and its views show in the native screen.
  • What appProvider holds lives as long as the app. Screens come and go under it; a reload of the JavaScript starts it again.
  • An error stays in its screen. Each screen has an error boundary of its own, so one screen that fails doesn't take the tree down (see A screen that fails).
  • Native screens share it too. The useProps hook of a native screen runs inside appProvider, so it reads the same query client.

appProvider and screenProvider go together: the app's providers once, and inside them what each screen needs on its own.

Which to pick​

The app hasPick
No providers, or a store kept in a moduleA root per screen: nothing to set.
Providers without shared state, such as the theme of a UI kitA root per screen, with screenProvider.
State in React context that screens share, such as a query client or a provider-based storeOne tree, with appProvider.

Every feature of the navigation works the same way in both: the Showcase runs each of its sections in either mode. One difference is in timing. Android A push in one tree renders the screen before it starts, so the screen comes in drawn, and the push starts later by the time React takes to render it (see A push on Android, in one tree).

API​