Skip to main content

Building a screen

One screen from start to end: its component and props, its title and a Save button, fresh data each time it shows, and leaving it without losing changes.

Here you build one real screen: a note the user opens from a list, edits and saves. It touches every part a screen has, in the order you need them. You end with a screen that:

  • opens with the id of a note, checked by TypeScript;
  • shows the title of the note in the top bar, and a Save button that turns on with the first change;
  • loads the note each time it shows, so it is fresh when the user comes back;
  • goes back after a save, and asks before the user leaves with unsaved changes.
iOS

What you need​

  • An app with the navigation installed, and the typed commands that setup writes: see Installation.
  • What a screen is and how its props reach it: Screens and props. The rest of the parts are linked where they come in.
  • Two functions of your own, loadNote(id) and saveNote(id, text): a request to your server, or a local database. The guide doesn't care which.

How it will work​

The screen is a React component, but what is around it is native: the top bar, its button, the back button and the swipe back are the platform's. So each part has its place:

WhatWhere it goes
Which note to showThe screen's props, given by push
The first titleoptions of defineScreen, there before the first frame
The noteLoaded by useFocusEffect, each time the screen gets the focus
The title of the note, the Save buttonuseScreenOptions, from the state of the component
Back after a savegoBack() of useNavigation
Asking before leavingusePreventLeave, which holds the back button and the gestures

1. The component and its props​

A screen is a React component, and its props are the data of one showing: here, which note. defineScreen keeps the component and its options together, so the screen has its title wherever it opens:

src/screens/Note.tsx
import { Text } from 'react-native';
import { defineScreen } from '@apexnative/navigation';

type Props = { noteId: string };

export function Note({ noteId }: Props) {
return <Text>Note {noteId}</Text>;
}

export const NoteScreen = defineScreen({
component: Note,
options: (s) => s.title('Note'),
});

The list opens it with the id of the note. Here the list is one button, to keep the guide on the Note screen:

src/screens/Notes.tsx
import { Button, View } from 'react-native';
import { defineScreen, useNavigation } from '@apexnative/navigation';

export function Notes() {
const navigation = useNavigation();
const open = (noteId: string) =>
navigation.push('Note', { props: { noteId } });

return (
<View>
<Button title="Shopping" onPress={() => open('7')} />
</View>
);
}

export const NotesScreen = defineScreen({
component: Notes,
options: (s) => s.title('Notes'),
});

The app lists both in screens, under the names the commands use:

Navigation.ts
import { createNavigation } from '@apexnative/navigation';
import { NoteScreen } from './src/screens/Note';
import { NotesScreen } from './src/screens/Notes';

export const Navigation = createNavigation({
screens: { Notes: NotesScreen, Note: NoteScreen },
root: 'Notes',
});

The user taps Shopping: the Note screen slides in with "Note" in the top bar, and back goes to the list. TypeScript checks the name and the props: push('Note') without noteId doesn't compile. More: Props.

2. The data, each time the screen shows​

A screen under another one stays mounted: when the user comes back to it, React's useEffect doesn't run again. useFocusEffect runs each time the screen gets the focus, the first time too, so the note is loaded when the screen opens and again when the user comes back to it, from a screen pushed over it or a sheet:

src/screens/Note.tsx
import { useState } from 'react';
import { TextInput } from 'react-native';
import { useFocusEffect } from '@apexnative/navigation';
import { loadNote, type NoteData } from '../notes'; // your own data

export function Note({ noteId }: Props) {
const [note, setNote] = useState<NoteData | null>(null);
const [text, setText] = useState('');

useFocusEffect(() => {
loadNote(noteId).then((loaded) => {
setNote(loaded);
setText(loaded.text);
});
});

return (
<TextInput value={text} onChangeText={setText} multiline />
);
}

The note shows once it is loaded. When the user opens another screen from here and comes back, the note is loaded again. Unlike React Navigation's hook, this one takes a plain function, with no useCallback: it runs the latest one each time. Every hook of the life of a screen: The hooks of a screen.

3. The title from the data​

The title of defineScreen is there from the first frame, before the note is loaded. Once it is, the bar shows the note's own title. useScreenOptions builds options from the state of the component, and applies them when they change:

useScreenOptions((s) => s.title(note?.title ?? 'Note'));

The user sees "Note" while the screen comes in, then the title of the note. More: From the state: useScreenOptions.

4. A Save button​

The button goes into the same options. It is on only while the text differs from the saved note, and its onPress sees the state of the component:

const changed = note !== null && text !== note.text;

const save = async () => {
await saveNote(noteId, text); // your own data
};

useScreenOptions((s) =>
s.title(note?.title ?? 'Note').rightButtons({
id: 'save',
text: 'Save',
enabled: changed,
onPress: save,
}),
);

Save shows at the right of the top bar, dimmed. The user types a letter, and it turns on. A button that needs no state, such as one that opens a screen, goes into the options of defineScreen instead: see Where the press is handled.

5. Keep the edits when the user comes back​

Step 2 loads the note each time the screen gets the focus, and that would also replace what the user typed. Load it only while there is nothing to lose:

useFocusEffect(() => {
if (changed) return; // the user is editing
loadNote(noteId).then((loaded) => {
setNote(loaded);
setText(loaded.text);
});
});

The effect is the latest one each time it runs, so it reads the current changed. The user types, opens another screen, comes back: the text is still theirs.

6. Leave after a save​

Once the note is saved, the screen goes back. goBack() does what the back button would do: it pops the screen when it was pushed, and closes the modal when the screen is the first one of a modal. So the same screen works both ways:

const navigation = useNavigation();

const save = async () => {
await saveNote(noteId, text); // your own data
await navigation.goBack();
};

The user taps Save: the note is saved, and the list shows again. More: Going back.

7. Ask before the changes are lost​

A swipe back would drop the unsaved text. usePreventLeave holds the ways the user goes back from this screen to another screen of the app while its condition is true: the back button, the swipe back, the system back of Android, the swipe down of a modal. It calls its handler instead, so the screen can ask:

usePreventLeave(changed, () => {
Alert.alert('Discard the changes?', undefined, [
{ text: 'Keep editing', style: 'cancel' },
{
text: 'Discard',
style: 'destructive',
onPress: () => navigation.goBack(),
},
]);
});

The user edits and swipes back: the screen stays, and the alert asks. Discard goes back. The commands of the app are not held, which is why goBack() in Discard and in Save leaves at once. Leaving the app isn't held either; Note is pushed, so back always has a screen to go to. On the first screen of the app, see Asking before the user leaves the app. More: Keeping the user on a screen.

The complete file​

src/screens/Note.tsx
import { useState } from 'react';
import { Alert, TextInput } from 'react-native';
import {
defineScreen,
useFocusEffect,
useNavigation,
usePreventLeave,
useScreenOptions,
} from '@apexnative/navigation';
import { loadNote, saveNote, type NoteData } from '../notes';

type Props = { noteId: string };

export function Note({ noteId }: Props) {
const navigation = useNavigation();
const [note, setNote] = useState<NoteData | null>(null);
const [text, setText] = useState('');
const changed = note !== null && text !== note.text;

// Each time the screen shows, unless the user is editing.
useFocusEffect(() => {
if (changed) return;
loadNote(noteId).then((loaded) => {
setNote(loaded);
setText(loaded.text);
});
});

const save = async () => {
await saveNote(noteId, text);
await navigation.goBack();
};

useScreenOptions((s) =>
s.title(note?.title ?? 'Note').rightButtons({
id: 'save',
text: 'Save',
enabled: changed,
onPress: save,
}),
);

usePreventLeave(changed, () => {
Alert.alert('Discard the changes?', undefined, [
{ text: 'Keep editing', style: 'cancel' },
{
text: 'Discard',
style: 'destructive',
onPress: () => navigation.goBack(),
},
]);
});

return (
<TextInput value={text} onChangeText={setText} multiline />
);
}

export const NoteScreen = defineScreen({
component: Note,
options: (s) => s.title('Note'),
});

The sections below change this file for the cases a real app meets.

Coming from React Navigation​

The parts have the same jobs; where they differ, it is because the bar and the back gestures are native:

React NavigationHere
navigation.setOptions({ title }) in an effectuseScreenOptions((s) => s.title(…)), applied before the screen paints
headerRight: () => <Button … />rightButtons({ id, text, onPress }): a native button
useFocusEffect(useCallback(() => …, [deps]))useFocusEffect(() => …): the latest function runs
usePreventRemove(changed, callback)usePreventLeave(changed, callback)
route.params.noteIdnoteId as a prop of the component

A save that fails​

When saveNote throws, goBack() doesn't run, and the user is left with a button that did nothing. Catch the error, say so, and stay: the text is still there to try again. A second tap while the first save runs would save twice, so the button turns off meanwhile:

const [saving, setSaving] = useState(false);

const save = async () => {
setSaving(true);
try {
await saveNote(noteId, text);
} catch {
setSaving(false);
Alert.alert('The note was not saved. Try again.');
return;
}
await navigation.goBack();
};

// in useScreenOptions
enabled: changed && !saving,

A command that fails, such as goBack() when there is nowhere to go, rejects its promise; it doesn't crash the app. More: Where the errors go.

The same note opened twice​

The list opens Note, Note opens a linked note, and that one links back to the first. Each push adds a screen, so the stack can hold the same note twice, edited in two places. A key makes Note one screen per note in a stack: a push of a note already open goes back to its screen, closing the screens over it:

src/screens/Note.tsx
export const NoteScreen = defineScreen({
component: Note,
options: (s) => s.title('Note'),
key: (props) => props.noteId,
});

More: One screen per item.

The same screen opens as a modal, for a new note over the list:

navigation.showModal('Note', { props: { noteId: 'new' } });

Nothing in the screen changes: goBack() closes the modal, and usePreventLeave holds the swipe down too, so the alert asks there as well. A Cancel button for the modal and the form patterns: Forms in a modal.

The note from a store or a query client​

Without appProvider, each screen is a React root of its own. A provider you put around the list, or around the app's first screen, doesn't reach Note. So a store, a React Query client or a UI kit's theme goes around every screen through the navigation, not around one of them:

When Android brings the app back​

Android can destroy the app's activity while the app is in the background, and create a new one when the user returns. The JavaScript keeps running, but the navigation shows the last root again without the screens pushed over it: the user comes back to the list, and the unsaved text of Note is gone. When drafts matter, keep the text in your own storage as the user types, and read it in loadNote. More: When the app launches again.

What's next​

  • Testing: a Jest test for the Notes list, and an end-to-end test that opens a note and goes back.
  • Bar buttons: icons, menus, buttons from a component.
  • Screen options: where options come from, and how they merge.
  • Lifecycle and events: every event of a screen.
  • List and details: the list side, with one screen per item and a split view on iPad.