The Back Button and Gestures

How Android's system back and iOS's swipe-back reach your app, how to intercept a back press, and how not to trap the user.

SyntaxusePreventRemove(preventRemove: boolean, callback: (event: { data: { action: NavigationAction } }) => void): void

Going back is not one mechanism. On Android the system supplies it: a hardware or gesture back that the operating system delivers to your app. On iOS there is no system back button. The user taps the header back control, or swipes in from the left edge of the screen. A stack navigator wires both to the same thing, popping the top screen, so most of the time you write nothing.

Intercepting a back

When a screen holds unsaved work, you want to ask first. usePreventRemove(preventRemove, callback) from @react-navigation/native does that. The first argument is a boolean: while it is true, the screen refuses to be removed. The second is called instead, receiving an object whose data.action is the navigation action that was stopped. Dispatch that action to let the user through. This covers the header button, the iOS gesture and the Android back key at once, which is why it is preferred over handling them separately.

The callback is usually written ({ data }) => …. That is parameter destructuring, exactly the same as (event) => { const data = event.data; … }.

BackHandler

BackHandler is the low-level Android API and is Android only. BackHandler.addEventListener('hardwareBackPress', handler) returns a subscription with a remove() method. Returning true from the handler swallows the press; returning false lets it fall through to the navigator, and on the first screen that exits the app.

What goes wrong

A handler that always returns true gives Android users a dead back button with no way out. Always leave a path: a confirm dialog that actually proceeds, or a visible close control.

Example

Example · tsx
// app/note.tsx
import { useState } from 'react';
import { Alert, StyleSheet, Text, TextInput, View } from 'react-native';
import { useNavigation, usePreventRemove } from '@react-navigation/native';

export default function Note() {
  const [text, setText] = useState('');
  const navigation = useNavigation();

  // The screen is "dirty" while there is typed text that has not been saved.
  const hasUnsavedChanges = text.length > 0;

  // First argument blocks or allows removal.
  // Second argument runs only when a removal was blocked.
  usePreventRemove(hasUnsavedChanges, ({ data }) => {
    Alert.alert('Discard changes?', 'This note has not been saved.', [
      { text: 'Keep editing', style: 'cancel' },
      {
        text: 'Discard',
        style: 'destructive',
        // data.action is the back action that was stopped.
        // Dispatching it performs the navigation the user asked for.
        onPress: () => navigation.dispatch(data.action),
      },
    ]);
  });

  return (
    <View style={styles.screen}>
      <Text style={styles.label}>Note</Text>
      <TextInput
        style={styles.input}
        value={text}
        onChangeText={setText}
        placeholder="Type something"
        multiline
      />
    </View>
  );
}

const styles = StyleSheet.create({
  screen: { flex: 1, padding: 16, gap: 8 },
  label: { fontSize: 13, fontWeight: '600' },
  input: {
    borderWidth: 1,
    borderColor: '#999',
    borderRadius: 8,
    padding: 12,
    minHeight: 120,
  },
});

When to use it

  • Warn before leaving a half-finished form, on the header button, the iOS swipe and the Android back key at once.
  • Ask for confirmation instead of silently closing the app when Android back is pressed on the home screen.
  • Prevent an accidental swipe out of a payment or signature step on iOS.
  • Close your own full-screen overlay on Android back instead of popping the screen behind it.

More examples

Confirming app exit on the first screen (Android)

On the first screen of a stack there is nothing to pop, so an unhandled Android back closes the app. This asks first. Two details matter: the subscription is removed in the cleanup function, and the handler returns true to stop the default exit. Reach for this only on the root screen, and only on Android, where the press exists.

Example · tsx
// app/index.tsx
import { useCallback } from 'react';
import {
  Alert,
  BackHandler,
  Platform,
  StyleSheet,
  Text,
  View,
} from 'react-native';
import { useFocusEffect } from 'expo-router';

export default function Index() {
  // useFocusEffect runs the effect while this screen is focused,
  // so a background screen never steals the back press.
  useFocusEffect(
    useCallback(() => {
      if (Platform.OS !== 'android') {
        return;
      }

      const onBackPress = () => {
        Alert.alert('Exit app?', 'You are on the first screen.', [
          { text: 'Cancel', style: 'cancel' },
          { text: 'Exit', onPress: () => BackHandler.exitApp() },
        ]);
        return true; // swallow this press; the dialog decides
      };

      const subscription = BackHandler.addEventListener(
        'hardwareBackPress',
        onBackPress,
      );
      return () => subscription.remove();
    }, []),
  );

  return (
    <View style={styles.screen}>
      <Text>Inbox</Text>
    </View>
  );
}

const styles = StyleSheet.create({
  screen: { flex: 1, alignItems: 'center', justifyContent: 'center' },
});

Turning off the iOS swipe-back for one screen

Useful during a payment step, where a half-swipe mid-transaction is worse than a confirm dialog. It is a presentation option, not a guard: it removes one way out on one platform. The honest version of this screen still offers a Cancel button.

Example · tsx
// app/_layout.tsx
import { Stack } from 'expo-router';

export default function RootLayout() {
  return (
    <Stack>
      <Stack.Screen name="index" options={{ title: 'Cart' }} />
      {/* gestureEnabled is an iOS-only option: it disables the
          swipe-in-from-the-edge dismissal for this screen.
          Android has no such gesture here, and its system back is
          unaffected, so pair this with usePreventRemove if the screen
          must not be left by accident on both platforms. */}
      <Stack.Screen
        name="checkout"
        options={{ title: 'Checkout', gestureEnabled: false }}
      />
    </Stack>
  );
}

Letting the user leave on purpose after a block

The block is driven by state, not by the dialog. Saving flips the boolean, and from then on back works normally with no prompt. This is the pattern that avoids the common bug where the confirm dialog keeps appearing after the user has already saved.

Example · tsx
// app/draft.tsx
import { useState } from 'react';
import { Alert, Button, StyleSheet, TextInput, View } from 'react-native';
import { useNavigation, usePreventRemove } from '@react-navigation/native';

type Draft = { body: string; saved: boolean };

export default function DraftScreen() {
  const [draft, setDraft] = useState<Draft>({ body: '', saved: true });
  const navigation = useNavigation();

  usePreventRemove(!draft.saved, ({ data }) => {
    Alert.alert('Leave without saving?', '', [
      { text: 'Stay', style: 'cancel' },
      {
        text: 'Leave',
        onPress: () => navigation.dispatch(data.action),
      },
    ]);
  });

  const save = () => {
    // Marking the draft saved sets the boolean back to false,
    // so the next back press is not intercepted at all.
    setDraft((current) => ({ ...current, saved: true }));
  };

  return (
    <View style={styles.screen}>
      <TextInput
        style={styles.input}
        value={draft.body}
        onChangeText={(body) => setDraft({ body, saved: false })}
        placeholder="Draft"
        multiline
      />
      <Button title="Save" onPress={save} />
    </View>
  );
}

const styles = StyleSheet.create({
  screen: { flex: 1, padding: 16, gap: 12 },
  input: { borderWidth: 1, borderColor: '#999', borderRadius: 8, padding: 12, minHeight: 100 },
});

Discussion

  • Be the first to comment on this lesson.