Lifecycle and events
The events of a screen and of the whole app, their order in one move, where to listen to them, what the user sees now, and every event the navigation sends.
An inbox loads new messages each time the user comes back to it; a video stops when another screen covers it; analytics logs every screen the user opens. The platform runs the navigation, so JavaScript learns what happened from events: a screen appeared, the user pressed a button of the bar, picked a tab, swiped a sheet down. A screen hears its own with hooks:
import { useScreenAppear } from '@apexnative/navigation';
function Inbox() {
useScreenAppear(() => {
refetch(); // your own request
});
// …
}
Inbox loads its messages when it first shows, and again each time the user comes back to it. This page answers:
- What a screen goes through, and the order of the events in one move.
- The hooks of a screen: to refresh on appear, to stop work while covered.
- Where to listen: in a screen, in any component, outside React, for the whole run.
- What the user sees now.
- Every event the navigation sends.
What a screen goes through
A screen mounts once and appears many times: each time the user comes back to it from a screen over it, from another tab, from a modal. Mounted and on screen are two different lines, as The life of a screen shows. The events follow the second line:
| Event | When |
|---|---|
screenWillAppear | The screen starts to come in: the move begins. |
screenDidAppear | The screen is in place: the move has ended. |
screenDidDisappear | The screen is covered, or has left. |
The order in one move
Home pushes Details. Each screen and the app get their events in this order:
screenDidDisappearscreenWillAppearscreenDidAppearpush('Details')stateChangedpush() resolves- Details gets
screenWillAppearwhen the move starts, andscreenDidAppearwhen it ends. - Home gets
screenDidDisappearonce Details covers it. It stays mounted. - The app: once the move has ended,
stateChangedtells that Details is now the screen the user sees, and thenpushresolves. Code afterawait navigation.push('Details')reads Details ingetState().
Back from Details plays it in reverse: Home appears again, Details disappears and unmounts.
The hooks of a screen
Inside a screen, hooks listen to the events of that screen only, and stop when it unmounts:
| Hook | Runs |
|---|---|
useFocusEffect(effect) | The effect each time the screen gets the focus; its cleanup each time it loses it. |
useScreenAppear(handler) | Each time the screen appears. |
useScreenDisappear(handler) | Each time it disappears. |
useIsFocused() | Not an event: true while the user is on this screen. |
useScreenEvent(name, handler) | Any other event of this screen, by its name in Every event. |
Work that goes on only while the user is on the screen, such as a timer, a subscription, a camera, belongs in useFocusEffect. It starts when the screen gets the focus: the screen has appeared, and nothing covers it. It stops when the screen loses it: a screen pushed over it, another tab, a modal or a sheet over it. A covered screen costs nothing.
import {
useFocusEffect,
useIsFocused,
useScreenEvent,
} from '@apexnative/navigation';
// in a screen
useFocusEffect(() => {
const timer = setInterval(reload, 30_000); // your own reload
return () => clearInterval(timer);
});
The timer stops while a pushed screen or a sheet covers this one, and starts again when the user comes back. Screens side by side in a split view keep the focus, each of them: see When the effect runs.
Fresh data each time the user comes back is useScreenAppear, as in Inbox at the top of this page.
What renders differently while the screen isn't in front reads useIsFocused(): true while this is the screen the user sees, the one of the navigation state. Under a sheet the screen still shows and doesn't disappear, but the user is in the sheet: useIsFocused() is false, and useFocusEffect has cleaned up.
const focused = useIsFocused();
return <Player paused={!focused} />; // your own player
Any other event of the screen has its name. Here a screen in a sheet shows more once the user drags the sheet to full height:
useScreenEvent('sheetDetentChanged', ({ detent }) => {
setExpanded(detent === 'large');
});
The events of the parts of a screen have hooks of their own on their pages: useNavigationButton on Bar buttons, useSearchBar on Search, useTabPress on Tabs.
Where to listen
The same events can be heard in four places. Each one is a narrower scope than the one around it:
listenersFor the whole runcreateNavigation: from the start, for as long as the app runs.navigation.onFrom anywhereA store, a service: from the call until the function it returns is called.useNavigationEventIn a componentAny component: while it is mounted.useScreenEventIn a screenThe events of this screen only, while it is mounted.For the whole run, such as the screens for analytics, listeners of createNavigation. They work from the first screen, before any component mounts:
export const Navigation = createNavigation({
// …
listeners: {
// your own analytics
screenDidAppear: ({ screen }) => trackScreen(screen.name),
},
});
From code outside React, such as a store, navigation.on. It returns the function that stops listening:
import { navigation } from '@apexnative/navigation';
const stop = navigation.on('modalDismissed', () => {
refreshBadge(); // your own step
});
// later
stop();
In a component that isn't a screen, or for the events of every screen, useNavigationEvent. It listens while the component is mounted:
import { useNavigationEvent } from '@apexnative/navigation';
// in any component
useNavigationEvent('tabSelected', ({ selectedTabIndex }) => {
setLastTab(selectedTabIndex);
});
In a screen, for its own events, the hooks above.
Why the events are outside React
When the navigation lives in the React tree, its state is React state. Every move updates it at the top of the tree, the navigators render again, and code that wants to know the current screen reaches it through a ref passed around the app.
Here the platform moves the screens, and the events come from it. JavaScript only hears them, so listening costs no render and no provider:
const ref =
createNavigationContainerRef();
<NavigationContainer
ref={ref}
onStateChange={() => {
const route =
ref.current?.getCurrentRoute();
trackScreen(route?.name);
}}
>
createNavigation({
// …
listeners: {
screenDidAppear: ({ screen }) =>
trackScreen(screen.name),
},
});
- A move renders nothing by itself. No React state changes when the user goes from screen to screen. A component renders on a move only when it reads
useNavigationState, and only when the part it picked changes. - No ref and no provider. Listeners are plain functions, in
createNavigation, a store or a service. Code outside React needs nothing passed down to it. - From the first screen, across the whole app.
listenersare attached before the first root shows, so the first screen is heard too. They don't belong to a root, a tab or a modal, so they don't unmount with one: one listener hears every screen of the app, for as long as it runs.
What the user sees now
The navigation keeps one small state: the screen the user sees, and the selected tab. It is the screen of the top modal, or of the root down to the visible one; overlays don't count.
navigation.getState();
// { screen: { id: 'Component12', name: 'Details' }, tab: 1 }
The state changes at the end of a move, and a command resolves after it: after await navigation.showModal('Login'), getState() names Login, and the next command acts from there.
useNavigationState gives it to a component and renders again when it changes. A function picks the part the component needs, so it renders only when that part changes:
import { useNavigationState } from '@apexnative/navigation';
function MiniPlayer() {
const name = useNavigationState((state) => state.screen?.name);
if (name === 'Player') return null; // the full player shows
return <PlayerBar />;
}
stateChanged is the same as an event, for listeners and navigation.on.
Every event
Every event has a name, the same in listeners, navigation.on, useNavigationEvent and useScreenEvent. The events of a screen carry it as screen: { id, name }.
| Event | When | Page |
|---|---|---|
screenWillAppear | A screen starts to come in. | This page |
screenDidAppear | A screen is in place. | This page |
screenDidDisappear | A screen is covered, or has left. | This page |
screenPopped | A screen left its stack: back, a swipe, pop. | Stack |
buttonPressed | A button of the top bar or the toolbar. | Bar buttons |
tabSelected | A tab became current. | Tabs |
tabPressed, tabLongPressed | A press on a tab. | Tabs |
modalDismissed | A modal is gone, whatever closed it. | Modals |
modalDismissAttempted | A swipe tried to close a modal that doesn't let it. | Modals |
sheetDetentChanged | The user dragged a sheet to a detent. | Modals |
searchChanged, searchSubmitted, searchCancelled | The search field. | Search |
searchSuggestionPicked | The user picked a suggestion of the search field (iOS 16+ and Android). | Search |
splitViewDisplayModeChanged | iOS The columns of a split view showed or hid. | Split view |
accessoryPlacementChanged | iOS 26+: the accessory of the tab bar moved. | Tabs |
titleRenamed | iOS The user renamed the title. | Top bar |
previewCompleted | iOS A preview opened into its screen. | Transitions |
keyCommandPressed | iOS A keyboard shortcut. | System UI |
stateChanged | What the user sees changed. | This page |
Next: the containers
That is the core of the navigation: screens, moves, options, roots and events. The containers that hold the screens come next, from the main one: Stack, then Tabs and Modals.