In React Navigation 7, how do you stop a user leaving a product-review screen with unsaved text, and what can still bypass that guard?
answer
- beforeRemove, not blur
- usePreventRemove(condition, callback)
- dispatch data.action to leave
- native stack blocks iOS swipe
- tab switch and backgrounding bypass it
basics
~10 sCall usePreventRemove(hasUnsavedText, ({ data }) => confirm, then navigation.dispatch(data.action)). It intercepts any navigation action that would remove the screen, but not switching tabs, pushing another screen, backgrounding or killing the app.
solid answer
~50 sReact Navigation 7's `usePreventRemove(preventRemove, callback)` listens for the screen's `beforeRemove` event and, while `preventRemove` is true, cancels the removal and calls back with the blocked action in `data.action`. The callback shows a confirmation, and on Discard calls `navigation.dispatch(data.action)` to perform exactly the navigation the user attempted, whether it was the header back button, a swipe, a hardware back press or a `goBack` in code. On the native stack the hook also sets the iOS screen's native dismiss prevention so the swipe cannot complete, and disables the header back button's long-press menu. It does not fire when the screen is not being removed: switching tabs, pushing another screen, the app going to the background or being killed. Those need a saved draft. A common bug is clearing the dirty flag and calling `goBack()` in the same handler, which the guard still blocks.
code
tsx · 38 linesimport { useEffect, useState } from 'react';
import { usePreventRemove, useNavigation } from '@react-navigation/native';
import { Alert, Button, TextInput, View } from 'react-native';
declare function postReview(text: string): Promise<void>;
export function WriteReviewScreen() {
const navigation = useNavigation();
const [text, setText] = useState('');
const [submitted, setSubmitted] = useState(false);
usePreventRemove(text.length > 0 && !submitted, ({ data }) => {
Alert.alert('Discard your review?', 'Your text will be lost.', [
{ text: 'Keep editing', style: 'cancel' },
{
text: 'Discard',
style: 'destructive',
onPress: () => navigation.dispatch(data.action),
},
]);
});
useEffect(() => {
if (submitted) navigation.goBack();
}, [submitted, navigation]);
const submit = async () => {
await postReview(text);
setSubmitted(true);
};
return (
<View>
<TextInput value={text} onChangeText={setText} multiline />
<Button title="Submit" onPress={submit} />
</View>
);
}go deeper
Recall that usePreventRemove takes a condition and a callback, and that leaving after confirmation means dispatching the action the callback receives.
Explain why the guard hangs off beforeRemove rather than blur, which actions trigger it and which situations never reach it.
Show the production details: native-stack gesture blocking, the same-handler goBack bug, replaying data.action, and pairing the guard with a persisted draft.
Balance data-loss protection against user friction, deciding which flows warrant a blocking dialog and which should rely on silent draft persistence instead.
## Which event means "leaving" In **React Navigation 7**, a screen can lose the user's attention in two different ways: - it can **blur**, because another screen was pushed on top or another tab was selected, while staying mounted; - it can be **removed**, because a navigation action is about to take its route out of the navigator's state, after which it unmounts. Unsaved input is lost only in the second case, so the guard hangs off the **`beforeRemove`** event. React Navigation emits it, with the pending action in `e.data.action`, before any action that would remove the route: `goBack`, `pop`, `popTo`, `replace`, `reset` and their gesture and button equivalents. Calling `e.preventDefault()` cancels the action. ## usePreventRemove The hook wraps that event in a declarative API: ```tsx usePreventRemove(hasUnsavedText, ({ data }) => { Alert.alert('Discard your review?', 'Your text will be lost.', [ { text: 'Keep editing', style: 'cancel' }, { text: 'Discard', style: 'destructive', onPress: () => navigation.dispatch(data.action) }, ]); }); ``` What it does, step by step: 1. It registers the route as **prevented** with the navigator while the first argument is `true`. 2. It adds a `beforeRemove` listener that, when the flag is `true`, calls `preventDefault()` and passes `data.action` to the callback. 3. The callback decides; dispatching `data.action` replays the user's original intent, whether that was going back one screen or a reset to the home screen. The registration step matters on the **native stack**. The native stack reads the prevented routes and sets the iOS screen's native dismiss prevention, so the swipe-back gesture cannot finish natively, and it forces the header back button's long-press history menu off for that screen; setting `headerBackButtonMenuEnabled: true` on a prevented screen logs an error. A bare `beforeRemove` listener does not register the route, so on the native stack prefer the hook. ## Using the raw event The hook is built on a listener anyone can add directly: ```tsx useEffect(() => navigation.addListener('beforeRemove', (e) => { if (!hasUnsavedText) return; e.preventDefault(); confirmDiscard(() => navigation.dispatch(e.data.action)); }), [navigation, hasUnsavedText]); ``` That works on the JavaScript stack, but it does not register the route as prevented, so the native stack cannot block its iOS swipe or disable the back-button history menu ahead of time. The hook also keeps its listener pointing at the latest callback without re-subscribing on every render. Prefer the hook, and reach for the raw event only when you need the event object itself, for example to inspect `e.data.action.type` and allow some actions through. ## What the guard does not cover | Situation | Removal action? | Guard fires? | |---|---|---| | Header back button, swipe back, `goBack()` | yes | yes | | `reset` to the home screen after sign-out | yes | yes | | Pushing a photo picker screen on top | no | no | | Switching to another tab | no | no | | App sent to background or killed by the system | no | no | The screen stays mounted in the first two "no" rows, so the text survives. The last row is where users actually lose work: a review typed on a phone that is then locked for an hour may be gone when the process is killed. A robust screen therefore combines the guard with a **saved draft**, written as the user types or when the app goes to the background. ## The same-handler bug A frequent defect: after a successful submit, the handler clears the flag and navigates in one go. ```tsx const submit = async () => { await postReview(text); setText(''); navigation.goBack(); }; ``` The guard still blocks the `goBack()`, because the hook reads `preventRemove` from the **last render**, and the state update has not rendered yet. Fixes that work: - keep a `submitted` state and navigate in an effect after it turns `true`, when the guard is already off; - or derive the flag from data that is updated before navigation, so the render that removes the guard happens first. ## Good practice - Keep the condition precise: prevent removal only while there is text that differs from what was saved, otherwise users meet a pointless dialog. - Always offer both a way to stay and a way to leave; the leave path must dispatch `data.action`, not a hard-coded `goBack()`, or a blocked reset turns into a wrong navigation. - Do not use the guard to trap users; platform expectations are that back works.
- Why dispatch data.action on Discard instead of calling navigation.goBack()?The blocked action is not always a single back step: it may be a `popTo`, a `reset` after sign-out or a pop of several screens. Dispatching `data.action` replays exactly what the user or the code attempted; a hard-coded `goBack()` would take the user somewhere else.
- Why does setText('') followed immediately by goBack() still show the discard dialog?`usePreventRemove` reads the flag from the latest render. The state update is queued but not rendered when `goBack()` dispatches, so the listener still sees unsaved text and blocks. Navigate after the render that turns the guard off, for example from an effect on a submitted flag.
- Why is a saved draft still needed when the guard is in place?The guard only sees navigation actions that remove the screen. Switching tabs keeps the screen mounted, but the app being backgrounded and then killed by the system removes everything without any navigation action, so the text is lost unless it was persisted.
usePreventRemove is a door attendant who stops you at the exit only when you are carrying unpaid items and asks whether to put them back; walking to another aisle or stepping outside through a fire door is not something the attendant ever sees.
saying these in an interview costs you the question
- Listening for blur is enough to catch the user leaving with unsaved text.
- usePreventRemove also stops the user switching to another tab.
- On Discard, calling goBack() is equivalent to dispatching data.action.
- The guard protects the text when the app is killed in the background.
- A plain beforeRemove listener blocks the native stack's iOS swipe the same way.