Sign in and sign out
An app with a signed-out part and a signed-in part: the saved session read at launch, the switch after a sign-in and a sign-out, a session that expires, and links that wait for the user to sign in.
Most apps have two parts: the sign-in screens, and the app behind them. In this guide you build both, open the right one at launch, and switch between them when the user signs in or out. You end with:
- an app that opens on its tabs when a session is saved, without Login flashing first;
- a sign-in and a sign-out that replace the whole app, with no way back to the other part;
- a session that expires while the app runs, handled in one place;
- links that wait until the user signs in, then open.
What you need
- An app set up with Installation:
Navigation.ts,index.js, and thenavigation-env.d.tsthatsetupwrites. - The screens Login, SignUp, Home, Settings and Details, each made with
defineScreen(see Screens and props). - Your own server call that signs the user in, and a secure storage on the device for the session (the keychain on iOS, the keystore on Android). This guide calls them
api.signIn,loadSession,saveSessionandclearSession. - Where the app keeps the signed-in user: the app's context, or a store you already use. The steps show both; the store is Zustand 5. State management and The app's context compare them.
How it will work
launch → willMount reads the session
├─ a user → signedIn: Tabs(Home, Settings)
└─ none → signedOut: Stack(Login → SignUp)
signIn() → save the session → the user in → switchTo('signedIn')
signOut() → clear the session → switchTo('signedOut') → the user out
- Two roots. A root is what takes the whole window. The signed-out part and the signed-in part are two named roots, and
navigation.switchToreplaces one with the other. - The user in one place that the start of the app, every screen and the links read.
signInandsignOutin one module, outside the screens: Login, Settings and your API client call the same functions.
In React Navigation you render {user ? <AppStack /> : <AuthStack />} and the navigator follows the state. Here the native side owns the window, so the switch is a command you make: switchTo. The rest is the same idea: the signed-in part doesn't exist while the user is signed out.
1. Keep the user where every screen reads it
Each screen is a React root of its own, so a React provider around Login doesn't reach Home, and useState in one screen isn't seen in another. The user needs a place outside React that every screen, and the start of the app, can read.
- The app's context
- A store (Zustand)
The app's context is the navigation's own place for it: initialRoot, the options of a screen and the when of links read it, and a component reads it with useAppContext.
export type User = { id: string; name: string };
export type AppContext = { user: User | null };
const context: AppContext = { user: null };
Export the type: the navigation-env.d.ts that setup wrote registers it, so useAppContext, navigation.setContext and navigation.getContext are typed with it (see Typing it).
A Zustand store lives in a module, not in a provider, so every screen reads the same one, whatever React root it is in:
import { create } from 'zustand';
export type User = { id: string; name: string };
type SessionState = { user: User | null };
export const useSession = create<SessionState>()(() => ({
user: null,
}));
A component reads it with useSession((s) => s.user); code outside React with useSession.getState() and useSession.setState().
Nothing shows yet. Pick the context when the user is all you need to keep: the navigation reads it by itself. Pick the store when the app keeps its state in one already, and the user belongs with it.
2. Name the two roots
The signed-out part is a stack with Login, so Login can push SignUp. The signed-in part is the app's tabs. roots names them, and initialRoot picks one at launch:
- The app's context
- A store (Zustand)
import {
createNavigation,
Root,
Stack,
Tab,
Tabs,
} from '@apexnative/navigation';
import { DetailsScreen } from './src/screens/Details';
import { HomeScreen } from './src/screens/Home';
import { LoginScreen } from './src/screens/Login';
import { SettingsScreen } from './src/screens/Settings';
import { SignUpScreen } from './src/screens/SignUp';
export type User = { id: string; name: string };
export type AppContext = { user: User | null };
const context: AppContext = { user: null };
const main = Tabs(
Tab('Home').label('Home'),
Tab('Settings').label('Settings'),
);
export const Navigation = createNavigation({
screens: {
Login: LoginScreen,
SignUp: SignUpScreen,
Home: HomeScreen,
Details: DetailsScreen,
Settings: SettingsScreen,
},
context,
roots: {
signedOut: Stack('Login'),
signedIn: Root(main).transition('fade'),
},
initialRoot: (context) =>
context.user ? 'signedIn' : 'signedOut',
});
The same file without context; initialRoot reads the store:
import { useSession } from './src/sessionStore';
// … the other imports and `main` as with the context
export const Navigation = createNavigation({
screens: {
Login: LoginScreen,
SignUp: SignUpScreen,
Home: HomeScreen,
Details: DetailsScreen,
Settings: SettingsScreen,
},
roots: {
signedOut: Stack('Login'),
signedIn: Root(main).transition('fade'),
},
initialRoot: () =>
useSession.getState().user ? 'signedIn' : 'signedOut',
});
Run the app: Login shows, as nobody is signed in yet. Root(main).transition('fade') makes every switch to the tabs a fade, so the sign-in doesn't cut. More: How a new root comes in.
3. Read the saved session at launch
willMount runs once before the first root, while the splash screen shows. It reads the saved session and puts the user in place, so initialRoot sees it:
- The app's context
- A store (Zustand)
import { navigation } from '@apexnative/navigation';
import { loadSession } from './src/storage';
export const Navigation = createNavigation({
// …
willMount: async () => {
// your own storage; a session that can't be read is none
const user = await loadSession().catch(() => null);
navigation.setContext((c) => ({ ...c, user }));
},
initialRoot: (context) =>
context.user ? 'signedIn' : 'signedOut',
});
import { loadSession } from './src/storage';
export const Navigation = createNavigation({
// …
willMount: async () => {
// your own storage; a session that can't be read is none
const user = await loadSession().catch(() => null);
useSession.setState({ user });
},
initialRoot: () =>
useSession.getState().user ? 'signedIn' : 'signedOut',
});
Sign in once (step 4), then close the app and open it again: the splash screen shows, then the tabs. Login never flashes before them, because the splash screen stays until willMount is done.
The .catch matters: a willMount that throws fails the start, and the app shows errorFallback instead of a screen. A storage that can't be read is better treated as signed out. See When the start fails.
willMount runs once per JavaScript runtime. When Android creates a new activity while the JavaScript keeps running, the navigation sets the last root again without running it, so a signed-in user stays signed in. See When the app launches again.
4. Sign in
Put the sign-in in a module of its own: navigation of the package works anywhere, and SignUp calls the same function.
- The app's context
- A store (Zustand)
import { navigation } from '@apexnative/navigation';
import { api } from './api';
import { saveSession } from './storage';
export async function signIn(email: string, password: string) {
const user = await api.signIn(email, password); // your own
await saveSession(user); // your own storage
navigation.setContext((c) => ({ ...c, user }));
await navigation.switchTo('signedIn');
}
import { navigation } from '@apexnative/navigation';
import { api } from './api';
import { useSession } from './sessionStore';
import { saveSession } from './storage';
export async function signIn(email: string, password: string) {
const user = await api.signIn(email, password); // your own
await saveSession(user); // your own storage
useSession.setState({ user });
await navigation.switchTo('signedIn');
}
The user goes in first, then the root: the screens of the new root, and their options, read the user as they are built.
Login calls it, shows the error when the request fails, and turns the button off while it waits, so a second tap doesn't sign in twice:
import { useState } from 'react';
import { Button, Text, TextInput, View } from 'react-native';
import { defineScreen, useNavigation } from '@apexnative/navigation';
import { signIn } from '../session';
function Login() {
const navigation = useNavigation();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [busy, setBusy] = useState(false);
const submit = async () => {
setBusy(true);
setError('');
try {
await signIn(email, password);
} catch {
setError('Wrong email or password');
setBusy(false);
}
};
return (
<View>
<TextInput value={email} onChangeText={setEmail} />
<TextInput
value={password}
onChangeText={setPassword}
secureTextEntry
/>
{error ? <Text>{error}</Text> : null}
<Button title="Sign in" onPress={submit} disabled={busy} />
<Button
title="Create an account"
onPress={() => navigation.push('SignUp')}
/>
</View>
);
}
export const LoginScreen = defineScreen({
component: Login,
options: (s) => s.title('Sign in'),
});
Tap Sign in: the Login stack fades out, and the tabs come in at Home. There is no way back to Login: the old root is gone, with its screens. When the request fails, nothing switches, and the error shows under the fields.
Home reads the user like any other value:
- The app's context
- A store (Zustand)
const user = useAppContext((c) => c.user);
return <Text>Hello, {user?.name}</Text>;
const user = useSession((s) => s.user);
return <Text>Hello, {user?.name}</Text>;
5. Sign out
The sign-out is the same move the other way, in a different order: the root goes first, then the user.
- The app's context
- A store (Zustand)
export async function signOut() {
await clearSession(); // your own storage
await navigation.switchTo('signedOut');
navigation.setContext((c) => ({ ...c, user: null }));
}
export async function signOut() {
await clearSession(); // your own storage
await navigation.switchTo('signedOut');
useSession.setState({ user: null });
}
Why this order: a change of the user renders every component that reads it, on every screen that is mounted, at once. Cleared before the switch, Home, Settings and the screens in the other tab would render once more with no user, and a user.name there would throw. Switched first, they are gone when the user is cleared.
Settings offers it:
import { Button } from 'react-native';
import { defineScreen } from '@apexnative/navigation';
import { signOut } from '../session';
function Settings() {
return <Button title="Sign out" onPress={() => signOut()} />;
}
export const SettingsScreen = defineScreen({
component: Settings,
options: (s) => s.title('Settings'),
});
Tap Sign out: the tabs go, and Login shows. What the switch does to the rest of the app:
- The screens of the tabs unmount, with their state: nothing of the signed-in user is left on screen. The next sign-in builds the tabs again, with fresh history and the badges of the builder.
- The modals close: they were tasks of the signed-in app.
- The overlays stay. One that belongs to the user, such as a call, goes with
navigation.dismissAllOverlays()before the switch.
What the navigation doesn't own, you reset in signOut too: the other stores of the app, and the cache of a query library (queryClient.clear() of React Query). See State management and Data fetching.
6. Make links wait for the sign-in
A link to an order means nothing to a signed-out user. when of linking lets links run only while it is true; until then the latest link waits.
- The app's context
- A store (Zustand)
export const Navigation = createNavigation({
// …
linking: {
prefixes: ['myapp://'],
when: (context) => context.user !== null,
routes: {
'order/:id': ({ id }) =>
navigation.push('Details', { props: { id } }),
// A password reset runs signed out too.
'reset-password/:token': {
when: () => true,
open: ({ token }) =>
navigation.showModal('ResetPassword', {
props: { token },
}),
},
},
},
});
The waiting link runs by itself once the context lets it: pending is 'auto' by default, and it is checked again each time the context changes.
The navigation checks a waiting link again when the app's context changes, not when your store does. So pending: 'manual' keeps the link, and signIn opens it:
export const Navigation = createNavigation({
// …
linking: {
prefixes: ['myapp://'],
when: () => useSession.getState().user !== null,
pending: 'manual',
routes: {
'order/:id': ({ id }) =>
navigation.push('Details', { props: { id } }),
'reset-password/:token': {
when: () => true,
open: ({ token }) =>
navigation.showModal('ResetPassword', {
props: { token },
}),
},
},
},
});
export async function signIn(email: string, password: string) {
// … as in step 4
await navigation.switchTo('signedIn');
await navigation.openPendingLink(); // false when none waits
}
Here is what happens to myapp://order/42 when the user is signed out:
- 1The link comes, and
whenis false: it waits. Login shows as usual.when → false - 2The user signs in:
signInputs the user in, then switches to the tabs.switchTo('signedIn') - 3The link runs now, after the switch, in the new root.
push('Details', { id: '42' }) - 4The user sees Home, then Details pushed over it; back goes to Home.
The same holds for a link that launches the app signed out: the first root is Login, and the link waits there. With the context, the link runs in the new root because signIn switches right after setContext, with no await between them: a waiting link lets the commands then on their way finish first. More: Opened after the sign-in.
Tell the user a link waits
usePendingLink gives the waiting link to a component, so Login can say why the user is asked to sign in:
import { usePendingLink } from '@apexnative/navigation';
function PendingLinkNote() {
const link = usePendingLink();
if (!link) return null;
return <Text>Sign in to open {link.path}</Text>;
}
navigation.clearPendingLink() forgets it, for a Not now button. More: Taken by the app.
The session expires while the app runs
A session that expires is a sign-out the user didn't ask for. Your API client sees it first, when the server answers 401, and it isn't a screen. It calls the same signOut(), then tells the user why:
import { Alert } from 'react-native';
import { signOut } from './session';
export async function request(path: string) {
const response = await fetch(`https://example.com${path}`);
if (response.status === 401) {
await signOut();
Alert.alert('You were signed out', 'Sign in again.');
throw new Error('Signed out');
}
return response.json();
}
Several requests fail at once when a session ends, and each would switch to Login again. signOut makes one sign-out of them: the calls that come while it runs get the same promise, and those after it find no user.
let signingOut: Promise<void> | undefined;
export function signOut(): Promise<void> {
signingOut ??= leave().finally(() => {
signingOut = undefined;
});
return signingOut;
}
async function leave() {
if (!navigation.getContext().user) return; // signed out already
await clearSession(); // your own storage
await navigation.switchTo('signedOut');
navigation.setContext((c) => ({ ...c, user: null }));
}
With a store, useSession.getState().user is the check, and useSession.setState({ user: null }) the last line.
Signing out from a modal
Settings is often a modal: the account in a sheet over the app. signOut() from there needs no dismiss first: switchTo closes every modal with the root. The onDismiss of the modal runs, as for any other way it closes.
A form with unsaved changes doesn't hold it either: usePreventLeave holds the ways the user leaves, not the commands of the app. A sign-out the user asked for from such a form should ask first, as a form's Cancel does.
The back button on Login
Android Login is the first screen of its root, so the system back leaves the app there, as on the first screen of any app. On SignUp it goes back to Login. After the sign-in, back on Home leaves the app too: Login isn't under the tabs, the old root is gone.
iOS Login has no back button; SignUp has the back button and the swipe back to Login.
A Login that shouldn't be left this way (a sign-up in progress, say) uses usePreventLeave on that screen: see Keeping the user on a screen.
An app that guests can browse
Some apps show their content to everyone and ask for a sign-in only to buy or to write. Then there is one root, and Login is a modal over it, shown when needed:
export async function askToSignIn() {
await navigation.showModal('Login');
}
Login then closes its modal after the sign-in instead of switching roots:
await signInOnly(email, password); // your own: save, set the user
await navigation.dismiss();
The user stays where they were, now signed in; every component that reads the user renders again. The dismiss here is the one of useNavigation() in Login.
The whole code
With the app's context; the store version differs in the lines the tabs above show.
import {
createNavigation,
navigation,
Root,
Stack,
Tab,
Tabs,
} from '@apexnative/navigation';
import { DetailsScreen } from './src/screens/Details';
import { HomeScreen } from './src/screens/Home';
import { LoginScreen } from './src/screens/Login';
import { ResetPasswordScreen } from './src/screens/ResetPassword';
import { SettingsScreen } from './src/screens/Settings';
import { SignUpScreen } from './src/screens/SignUp';
import { loadSession } from './src/storage';
export type User = { id: string; name: string };
export type AppContext = { user: User | null };
const context: AppContext = { user: null };
const main = Tabs(
Tab('Home').label('Home'),
Tab('Settings').label('Settings'),
);
export const Navigation = createNavigation({
screens: {
Login: LoginScreen,
SignUp: SignUpScreen,
ResetPassword: ResetPasswordScreen,
Home: HomeScreen,
Details: DetailsScreen,
Settings: SettingsScreen,
},
context,
roots: {
signedOut: Stack('Login'),
signedIn: Root(main).transition('fade'),
},
willMount: async () => {
const user = await loadSession().catch(() => null);
navigation.setContext((c) => ({ ...c, user }));
},
initialRoot: (context) =>
context.user ? 'signedIn' : 'signedOut',
linking: {
prefixes: ['myapp://'],
when: (context) => context.user !== null,
routes: {
'order/:id': ({ id }) =>
navigation.push('Details', { props: { id } }),
'reset-password/:token': {
when: () => true,
open: ({ token }) =>
navigation.showModal('ResetPassword', {
props: { token },
}),
},
},
},
});
import { navigation } from '@apexnative/navigation';
import { api } from './api';
import { clearSession, saveSession } from './storage';
export async function signIn(email: string, password: string) {
const user = await api.signIn(email, password);
await saveSession(user);
navigation.setContext((c) => ({ ...c, user }));
await navigation.switchTo('signedIn');
}
let signingOut: Promise<void> | undefined;
export function signOut(): Promise<void> {
signingOut ??= leave().finally(() => {
signingOut = undefined;
});
return signingOut;
}
async function leave() {
if (!navigation.getContext().user) return;
await clearSession();
await navigation.switchTo('signedOut');
navigation.setContext((c) => ({ ...c, user: null }));
}
import { useState } from 'react';
import { Button, Text, TextInput, View } from 'react-native';
import {
defineScreen,
useNavigation,
usePendingLink,
} from '@apexnative/navigation';
import { signIn } from '../session';
function PendingLinkNote() {
const link = usePendingLink();
if (!link) return null;
return <Text>Sign in to open {link.path}</Text>;
}
function Login() {
const navigation = useNavigation();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [busy, setBusy] = useState(false);
const submit = async () => {
setBusy(true);
setError('');
try {
await signIn(email, password);
} catch {
setError('Wrong email or password');
setBusy(false);
}
};
return (
<View>
<PendingLinkNote />
<TextInput value={email} onChangeText={setEmail} />
<TextInput
value={password}
onChangeText={setPassword}
secureTextEntry
/>
{error ? <Text>{error}</Text> : null}
<Button title="Sign in" onPress={submit} disabled={busy} />
<Button
title="Create an account"
onPress={() => navigation.push('SignUp')}
/>
</View>
);
}
export const LoginScreen = defineScreen({
component: Login,
options: (s) => s.title('Sign in'),
});
import { Button } from 'react-native';
import { defineScreen } from '@apexnative/navigation';
import { signOut } from '../session';
function Settings() {
return <Button title="Sign out" onPress={() => signOut()} />;
}
export const SettingsScreen = defineScreen({
component: Settings,
options: (s) => s.title('Settings'),
});
api, loadSession, saveSession and clearSession are your own: a request to your server, and a secure storage of the device.
What's next
- Onboarding: a first-launch flow before the sign-in.
- Deep links: links that wait, in full.
- Notifications: a tap on a notification while signed out.
- Switching roots: what a switch does, roots with a param.
- The app's context and State management: where the rest of the app's state goes.