Munchable is an Expo app that also ships as a web build, which is a very good deal right up to the point where a React Native API is a stub on the web and nothing tells you.
Here is the whole of Alert in react-native-web 0.21.2, from dist/exports/Alert/index.js:
class Alert { static alert() {} } export default Alert; No warning, no throw, no console message. Every confirm in the app was built on Alert.alert, so on the web build sign out did nothing, delete account did nothing, and the "this answer looks wrong" report flow did nothing. The buttons were not broken in any way you could see: they were wired to a function whose body is empty.
The replacement is 144 lines of store plus a 160 line host component, and almost none of that is the card on screen. It is the three things an Alert replacement has to get right that the OS was doing for you.
1. One dialog, one answer, every exit path
The call sites want to await an answer. There are 12 of them across 7 screens, six confirm and six showAlert:
const ok = await confirm({ title: 'Delete your account?', message: '...', confirmLabel: 'Delete', destructive: true, }); if (!ok) return; If that promise can ever fail to settle, the caller is stranded forever, which is the same do-nothing button we set out to fix. There are five ways out of a dialog: a button, the backdrop, the Android hardware back, being replaced by another dialog, and the host unmounting because the screen closed. All five have to resolve the promise.
The two that are easy to miss are the last two. Opening a second dialog over a first one has to cancel the first:
show: (request) => { // Settle the dialog being replaced before it is gone, so its awaiting // caller is released instead of being left pending forever. const evicted = cancelOf(get().current); set({ current: request }); evicted?.(); }, And unmounting has to do the same thing, which happens in the host's effect cleanup:
useEffect(() => { registerHost(id); return () => { const state = useDialog.getState(); const wasActive = state.hosts[state.hosts.length - 1] === id; unregisterHost(id); if (wasActive && state.current) state.dismiss(); }; }, [id, registerHost, unregisterHost]); confirm() guards on its own side too, with a settled flag, because a caller must never see a second answer to one question:
let settled = false; const answer = (value: boolean) => { if (settled) return; settled = true; resolve(value); }; The contract that falls out of this is the thing worth copying: confirm() resolves false for cancel, dismissal, eviction and unmount, with no rejection path at all. That is what lets every call site be if (!(await confirm(...))) return; and lets the await sit inside a try/finally that clears a busy flag.
2. On iOS, a root-level host cannot appear over a native modal
This is the bug that cost the most time. Three screens in the app are declared with presentation: 'modal', which on iOS is a real native modal presentation:
<Stack.Screen name="capture" options={{ presentation: 'modal', animation: 'slide_from_bottom' }} /> A React Native <Modal> presents from its nearest view controller. A single host mounted at the app root is therefore asking the root controller to present while it is already presenting the modal screen, and UIKit refuses. Result: on the two screens with the most consequential buttons in the app, the dialog never appeared, on iOS only, while working fine on the web and on Android.
So hosts register themselves and only the innermost one renders:
hosts: number[], // mounted hosts, in mount order // ... const isActive = hosts[hosts.length - 1] === id; Mount order is the nesting order, so the last registered host is the one nearest the screen the user is looking at. The app root still mounts a host for ordinary screens, and each modal screen mounts its own. There are five <DialogHost /> elements in the codebase for exactly this reason (the app root, the result screen, and the capture flow's three render branches), and at most two of them are mounted at once.
The nice property of this arrangement is how it degrades. A modal screen added later that forgets to mount a host falls back to a dialog that may not appear, rather than to a crash or a stranded promise.
3. The card has to survive its own exit animation
The first version read the title, message and buttons straight off current:
<Text variant="h2">{current.title}</Text> visible flips off the instant current becomes null, but the modal's fade takes a few hundred milliseconds, so an empty card faded out over the backdrop every single time. On a confirm, that is the last thing the user sees after tapping Delete.
The fix is two lines and a ref:
const lastRequest = useRef<DialogRequest | null>(null); if (current) lastRequest.current = current; const shown = current ?? lastRequest.current; The animation itself is opacity plus a translateY on the card only, because the Modal's own fade already handles the scrim, and it resets to its starting value on close so the next dialog arrives the same way. useReducedMotion() from Reanimated snaps it to the end state rather than skipping the dialog, which is the distinction that matters: reduced motion means less movement, not less information.
What we got for it that Alert never offered
- It looks like the app. Cream surface, cocoa ink, the app's own radii and type, instead of three different OS dialogs.
- The destructive action is coloured from the same token the rest of the app uses for an avoid, so the button that deletes your account is recognisably the serious one on all three platforms.
-
accessibilityRole="button"on every action, and a backdrop press that runs the cancel handler rather than just closing, so dismissing is identical to tapping Cancel. - One place to change the wording policy. The dialogs in this app ask a question and name the consequence, and that is now reviewable in 12 call sites rather than scattered across three OS behaviours.
If you ship an Expo app to the web
Grep your repo for Alert.alert today. Then grep for every other React Native API you call from shared code and check it against react-native-web/dist/exports: the no-op stub is a real pattern there, and a stub never shows up in an error report, in Sentry, or in a test that is not a browser.
Munchable's web build is at app.munchable.app and the phone build is linked from munchable.app. The confirm this whole thing was written for is the one on account deletion, and what happens after you tap through it is written out in plain terms in our privacy policy: the link between you and anything you contributed is replaced with an anonymous marker, and the copy on the device is wiped separately.

