Alerts and Toasts

Alert.alert draws the platform's own dialog from a button array, and there is no built-in toast, so you branch or build one.

SyntaxAlert.alert(title: string, message?: string, buttons?: AlertButton[], options?: AlertOptions): void

Alert.alert opens the platform's native dialog. It is a function call, not a component, so it never appears in your render tree and you cannot style it. That is the trade: it looks exactly right on both platforms and you get no say in how it looks.

The signature

Alert.alert(title, message?, buttons?, options?), returning nothing. Each button is { text, onPress?, style?, isPreferred? }, where style is 'default', 'cancel' or 'destructive' and both style and isPreferred are iOS only. iOS accepts any number of buttons. Android accepts three at most and lays them out as neutral, negative, positive in array order. In options, cancelable and onDismiss are Android only and userInterfaceStyle is iOS only. Alert.prompt, which collects text, is iOS only.

Toasts

There is no cross-platform toast. ToastAndroid.show(message, duration) exists on Android, with ToastAndroid.SHORT and ToastAndroid.LONG; showWithGravity and showWithGravityAndOffset take TOP, BOTTOM or CENTER, but since Android 11 the system ignores gravity for text toasts. iOS has no equivalent. So either branch on Platform.OS or render your own banner, which also gets you one appearance on both platforms.

What trips people up

Alert.alert returns immediately. The lines after it run before the user has read a word.

Example

Example · tsx
import { Alert, Pressable, StyleSheet, Text, View } from 'react-native';

type DeleteRowProps = {
  name: string;
  onDelete: () => void;
};

export default function DeleteRow({ name, onDelete }: DeleteRowProps) {
  const ask = () => {
    Alert.alert(
      'Delete note',
      `"${name}" will be removed from this device.`,
      [
        // on Android this is the negative slot; on iOS it is greyed as Cancel
        { text: 'Cancel', style: 'cancel' },
        // style and isPreferred are iOS only and ignored on Android
        { text: 'Delete', style: 'destructive', onPress: onDelete },
      ],
      // Android only: a tap outside the dialog dismisses it with no choice
      { cancelable: true, onDismiss: () => {} },
    );
    // anything written here runs NOW, long before the user decides
  };

  return (
    <View style={styles.row}>
      <Text style={styles.name}>{name}</Text>
      <Pressable onPress={ask} hitSlop={8}>
        <Text style={styles.delete}>Delete</Text>
      </Pressable>
    </View>
  );
}

const styles = StyleSheet.create({
  row: {
    flexDirection: 'row', // the default is column, so a row must say so
    alignItems: 'center',
    justifyContent: 'space-between',
    padding: 16,
  },
  name: { fontSize: 16 },
  delete: { color: '#c0392b', fontSize: 16 },
});

When to use it

  • Confirm deleting something before it goes.
  • Tell the user a copy or save succeeded without interrupting them.
  • Report a failed action in words, with a Retry button on the dialog.
  • Ask for one short piece of text on iOS with Alert.prompt.

More examples

Alert wrapped in a promise

Alert's callback style forces the rest of your logic inside onPress, which gets awkward once two decisions follow each other. Wrapping it once gives you await confirm(...) everywhere else. The onDismiss resolve is the part people forget: on Android the dialog can close with no button pressed, and a promise that never settles hangs the function that awaited it.

Example · tsx
import { Alert } from 'react-native';

export function confirm(title: string, message: string): Promise<boolean> {
  return new Promise((resolve) => {
    Alert.alert(
      title,
      message,
      [
        { text: 'Cancel', style: 'cancel', onPress: () => resolve(false) },
        { text: 'OK', onPress: () => resolve(true) },
      ],
      // without this, an outside tap on Android leaves the promise pending forever
      { cancelable: true, onDismiss: () => resolve(false) },
    );
  });
}

// at the call site the dialog reads like any other async step
export async function deleteNote(id: string, remove: (id: string) => void) {
  const ok = await confirm('Delete note?', 'This cannot be undone.');
  if (!ok) return;
  remove(id);
}
// Output: deleteNote resolves after the user taps; remove(id) runs only on OK

A toast on Android, something else on iOS

ToastAndroid is a platform module, not a cross-platform API, so every call needs a guard: on iOS the import succeeds and the call does nothing useful. Use this when the message is genuinely disposable, such as Copied or Saved. Note the second function: positioning a toast stopped working on Android 11, so code that relies on gravity behaves differently across the devices you support.

Example · tsx
import { Alert, Platform, ToastAndroid } from 'react-native';

export function notify(message: string) {
  if (Platform.OS === 'android') {
    // SHORT is roughly two seconds; LONG is roughly three and a half
    ToastAndroid.show(message, ToastAndroid.SHORT);
    return;
  }
  // iOS has no toast in core: a one-button alert is the quickest stand-in
  Alert.alert(message);
}

export function notifyAtTop(message: string) {
  if (Platform.OS !== 'android') return;
  // gravity is ignored for text toasts from Android 11 onwards
  ToastAndroid.showWithGravityAndOffset(
    message,
    ToastAndroid.LONG,
    ToastAndroid.TOP,
    0,
    120,
  );
}

Your own banner, identical on both platforms

Tapping Save shows a dark pill near the bottom of the screen that disappears after 2.5 seconds. This is what most apps ship instead of toasts: one appearance everywhere, your own typography, and no platform branch. Two details to copy: the early return is inside the effect rather than wrapped around the hook, and the cleanup clears the timer so a second message does not get cut short by the first one's timeout.

Example · tsx
import { useEffect, useState } from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';

export default function BannerDemo() {
  const [message, setMessage] = useState<string | null>(null);

  // the hook runs on every render; the condition lives inside it, never around it
  useEffect(() => {
    if (message === null) return;
    const timer = setTimeout(() => setMessage(null), 2500);
    return () => clearTimeout(timer);
  }, [message]);

  return (
    <View style={styles.screen}>
      <Pressable onPress={() => setMessage('Saved')}>
        <Text style={styles.link}>Save</Text>
      </Pressable>

      {message !== null && (
        <View style={styles.banner}>
          <Text style={styles.bannerText}>{message}</Text>
        </View>
      )}
    </View>
  );
}

const styles = StyleSheet.create({
  screen: { flex: 1, justifyContent: 'center', padding: 16 },
  link: { color: '#1f6feb', fontSize: 16 },
  banner: {
    // absolute here means relative to this View, not to the window
    position: 'absolute',
    bottom: 32,
    left: 16,
    right: 16,
    backgroundColor: '#222222',
    borderRadius: 8,
    padding: 12,
  },
  bannerText: { color: '#ffffff', textAlign: 'center' },
});

Discussion

  • Be the first to comment on this lesson.