skip to content

In a React Native photo-backup app, why can a confirm-delete Modal flash blank or crash on iOS while closing, but not on Android, and how do you fix it?

level: seniorimportance: should knowfreq 18%

answer

  1. same API, different lifetime
  2. iOS renders until the animation ends
  3. an iOS-only close callback
  4. state cleared with visible
  5. snapshot the target

basics

~20 s

On iOS a React Native Modal keeps rendering its children until the native dismissal finishes and onDismiss fires; Android stops at once. Clearing the selected photo alongside visible={false} re-renders the closing iOS dialog with null. Clear it in onDismiss or render from a snapshot.

solid answer

~40 s

`Modal` does not have the same render lifetime on both platforms. On Android its children disappear in the same render that sets `visible` to `false`. On iOS they stay rendered until the native side reports that the dismissal animation has finished, and then `onDismiss` (an iOS-only callback) fires. A handler that runs `setVisible(false)` and `setSelectedPhoto(null)` together therefore re-renders the still-visible iOS dialog with `null`: a blank flash, or a crash on `selectedPhoto.name`. Fix it by clearing the selection after dismissal, in `onDismiss` on iOS and immediately on Android, or by rendering from a snapshot of the photo taken when the dialog opened. A null guard stops the crash but not the flash. Use `onDismiss` on iOS to start whatever should be presented next.

code

tsx · 58 lines
tsx
import {useState} from 'react';
import {Modal, Platform, Pressable, Text, View} from 'react-native';

type Photo = {id: string; name: string};

type DeletePromptProps = {
  target: Photo | null;
  onDelete: (photo: Photo) => void;
  onClosed: () => void;
};

export function DeletePrompt({target, onDelete, onClosed}: DeletePromptProps) {
  const [prevTarget, setPrevTarget] = useState<Photo | null>(null);
  const [shownFor, setShownFor] = useState<Photo | null>(null);
  const [visible, setVisible] = useState(false);

  if (target !== prevTarget) {
    setPrevTarget(target);
    if (target != null) {
      setShownFor(target);
      setVisible(true);
    }
  }

  const close = () => {
    setVisible(false);
    if (Platform.OS !== 'ios') {
      onClosed();
    }
  };

  return (
    <Modal
      visible={visible}
      transparent
      animationType="fade"
      onRequestClose={close}
      onDismiss={onClosed}>
      {shownFor != null && (
        <View style={{flex: 1, justifyContent: 'center', padding: 24, backgroundColor: 'rgba(0,0,0,0.4)'}}>
          <View style={{backgroundColor: '#ffffff', padding: 16, gap: 12}}>
            <Text>Delete {shownFor.name} from your backup?</Text>
            <Pressable onPress={close}>
              <Text>Cancel</Text>
            </Pressable>
            <Pressable
              onPress={() => {
                onDelete(shownFor);
                close();
              }}>
              <Text>Delete</Text>
            </Pressable>
          </View>
        </View>
      )}
    </Modal>
  );
}

go deeper

for a junior

Recall that onDismiss exists on iOS and fires after a Modal has finished closing, and that state the modal displays should not be cleared too early.

for a middle

Explain the platform difference: Android stops rendering the children as soon as visible is false, while iOS keeps rendering them until the native dismissal completes.

for a senior

Diagnose one-platform close-animation bugs, fix them with snapshot rendering or onDismiss-based cleanup rather than a null guard, and sequence follow-up presentations from onDismiss on iOS.

for a principal

Wrap Modal in a shared dialog primitive that owns snapshotting and dismissal sequencing, so feature teams cannot reintroduce iOS-only lifecycle bugs.

## The symptom A photo-backup app has a **confirm-delete modal**: "Delete IMG_0042.jpg from your backup?" with Cancel and Delete. The parent holds `selectedPhoto` in state and closes the dialog with one handler: ```tsx const close = () => { setVisible(false); setSelectedPhoto(null); }; ``` On Android this works. On iOS, testers report that the dialog's text **flashes blank** as it fades out. If the content reads `selectedPhoto.name` without a guard, the app **throws** reading a property of `null`, and only on iOS. ## The cause: iOS keeps the modal rendered until dismissal finishes React Native's `Modal` source in 0.87 decides whether to render its children differently per platform: - **Android**: children render only while `visible` is `true`. The render that sets `visible={false}` returns nothing, so the cleared `selectedPhoto` is never rendered inside the modal. - **iOS**: children render while `visible` is `true` **or** while an internal "still rendered" flag is set. That flag is cleared when the native side reports that the **dismissal has finished**, and at that point `Modal` calls **`onDismiss`**. So on iOS, the render caused by `setSelectedPhoto(null)` happens **while the modal is still on screen**, animating out. The children re-render with `null`, which gives either a blank dialog or a crash. | | Android | iOS | |---|---|---| | Children after `visible={false}` | gone in the same render | kept until native dismissal completes | | `onDismiss` | not called (iOS-only) | called after dismissal | | Clearing data together with `visible` | safe | re-renders the dismissing modal with cleared data | ## Fixes, from most to least robust 1. **Clear data after the dismissal.** On iOS, reset the selection in `onDismiss`. Because `onDismiss` is iOS-only, clear it immediately on Android, or simply leave the stale selection in place until the next open overwrites it. 2. **Render from a snapshot.** Keep the photo being confirmed in its own state or ref, set when the modal opens and never cleared while it can still be on screen. 3. **Guard the content** (`target ? <Dialog/> : null`). This stops the crash but still shows the blank flash, so treat it as a safety net, not the fix. Keep the `Modal` mounted and toggle `visible` rather than wrapping it in a conditional render. The documented close animation and `onDismiss` belong to that controlled path. ## Sequencing what comes next The same timing matters when closing one overlay should lead to another, for example a "Deleting..." spinner overlay or an `Alert` with the result. On iOS, start the next presentation from `onDismiss`, when the first modal is known to be gone. On Android the modal is gone as soon as `visible` is `false`, so the next step can follow directly. ## Reproducing it on purpose A reliable reproduction makes the fix easy to verify: 1. Run the app on an iOS simulator and enable slow animations, so the dismissal takes seconds instead of a fraction of one. 2. Open the confirm-delete dialog and tap Cancel. 3. Watch the dialog while it fades: with the bug, the text changes or disappears before the fade ends. 4. Remove the null guard temporarily. The same steps now produce a red-box error from the modal's content. 5. Apply the fix and repeat. The text should stay intact until the modal is gone, and `onDismiss` should fire once. Run the same steps on Android to confirm that the fix did not change Android's behaviour, where the dialog disappears as soon as `visible` is `false`. ## How to find this class of bug - It reproduces only on one platform and only during the close animation. Slow animations in the simulator or use `animationType="slide"` to widen the window. - Stack traces point at the modal's children, not at the handler that cleared the state. - The tell-tale sign is state cleared in the same handler that sets `visible={false}`. ## The takeaway for an interview The `Modal` API looks identical on both platforms, but its render lifetime is not. On iOS the content outlives `visible={false}` until `onDismiss`, so data the content depends on must outlive it too.

  • Why not just unmount the React Native Modal with a conditional render when the selection is cleared?
    The documented close path is the controlled one: keep `Modal` mounted and set `visible` to `false`, which gives the configured close animation and, on iOS, `onDismiss`. Removing the component skips that contract, so you lose the reliable point at which the modal is known to be gone.
  • After confirming a delete, the app should show a 'Deleting...' overlay Modal. When should it be shown on iOS?
    Start it from the first modal's `onDismiss`, when iOS has finished dismissing the confirm dialog, so the two presentations do not overlap. On Android the first modal is gone as soon as its `visible` is `false`, so the overlay can be shown straight after, since `onDismiss` never fires there.

saying these in an interview costs you the question

  • A Modal's children unmount on iOS the moment visible becomes false.
  • onDismiss fires on both iOS and Android.
  • A null guard around the content fully fixes the problem.
  • The bug must be in the delete handler because the stack trace points at the Modal.
  • Modal behaves identically on both platforms because the API is shared.