●MAX — Rork Max builds native Swift apps instead of React Native, supporting iPhone, iPad, Watch, TV, Vision Pro, and iMessage●NATIVE — It unlocks native capabilities: AR/LiDAR, Metal 3D games, Dynamic Island, Live Activities, HealthKit, and Core ML●CORE — Standard Rork generates iOS/Android apps with React Native (Expo), taking you from plain English to the app stores●FUNDING — Rork raised $2.8M from a16z (Andreessen Horowitz)●GROWTH — The platform now sees 743,000 monthly visits with 85% growth●PRICING — Free to start, with paid plans from $25/month●MAX — Rork Max builds native Swift apps instead of React Native, supporting iPhone, iPad, Watch, TV, Vision Pro, and iMessage●NATIVE — It unlocks native capabilities: AR/LiDAR, Metal 3D games, Dynamic Island, Live Activities, HealthKit, and Core ML●CORE — Standard Rork generates iOS/Android apps with React Native (Expo), taking you from plain English to the app stores●FUNDING — Rork raised $2.8M from a16z (Andreessen Horowitz)●GROWTH — The platform now sees 743,000 monthly visits with 85% growth●PRICING — Free to start, with paid plans from $25/month
Build a Toast System in Your Rork App That Survives Overlaps, Screen Readers, and Notches
When you add toast notifications to a React Native app generated by Rork, the naive version breaks in three places: two toasts overlap, screen readers stay silent, and the text hides under the notch or home indicator. This walks through a root-level queue, animations decoupled from your app tree, AccessibilityInfo announcements, and safe-area placement — all in working code.
The first toast I shipped was the obvious one: a single useState inside the screen component. Save succeeds, "Saved" flashes for a moment, done. That was the whole plan.
Then real usage started chipping away at it. A sync that fired twice stacked two toasts on top of each other, both unreadable. Users running VoiceOver had no idea a toast had appeared at all. On iPhone the toast landed right on the home indicator, clipping the bottom half of the text. Each one technically "worked," yet none of them reached the person who needed it.
A toast is a tiny component, but every one of these failures traces back to a single decision: where the state lives. Here I'll build up the resilient toast design I settled on as an indie developer running several Expo apps the same way, in working code, one layer at a time.
Why a toast that lives inside a screen always breaks
The naive version keeps the toast's visibility state in whatever screen you're on. That's the root of the trouble.
It fails on three levels. First, overlap: with state scattered per screen, a notification coming from a different route or a background task can't be coordinated in one place, so several render at once. Second, lifetime: when the screen that fired the toast unmounts, the still-visible toast and its dismiss timer either vanish with it or get orphaned. Third, re-renders: if you drive the enter/exit animation from a screen's state, the heavy list on that screen re-renders along with it and stutters mid-scroll.
There's one direction out. Keep toast state in exactly one place at the app root, and let screens only ask it to "show this." Move to that single source of truth and all three problems disappear together.
A ToastProvider that lives once, at the root
Start with the container for state. We manage an array of toasts with useReducer and hand out show and hide through Context. The key move: the visible overlay is rendered once, inside this provider. Screens hold no state at all.
// toast/ToastContext.tsximport React, { createContext, useCallback, useContext, useReducer, useRef } from "react";export type ToastVariant = "success" | "error" | "info";export type ToastInput = { message: string; variant?: ToastVariant; durationMs?: number; // time until auto-dismiss; 0 = manual only dedupeKey?: string; // identical keys won't duplicate};export type Toast = Required<Omit<ToastInput, "dedupeKey">> & { id: string; dedupeKey?: string;};type Action = | { type: "PUSH"; toast: Toast } | { type: "REMOVE"; id: string };function reducer(state: Toast[], action: Action): Toast[] { switch (action.type) { case "PUSH": { // skip if a toast with the same dedupeKey already exists (double-submit guard) if (action.toast.dedupeKey && state.some((t) => t.dedupeKey === action.toast.dedupeKey)) { return state; } // keep at most 3 visible so we never bury the screen const next = [...state, action.toast]; return next.slice(-3); } case "REMOVE": return state.filter((t) => t.id !== action.id); default: return state; }}type ToastApi = { show: (input: ToastInput) => string; hide: (id: string) => void;};const ToastContext = createContext<ToastApi | null>(null);export const ToastStateContext = createContext<Toast[]>([]);export function ToastProvider({ children }: { children: React.ReactNode }) { const [toasts, dispatch] = useReducer(reducer, []); const seq = useRef(0); const hide = useCallback((id: string) => { dispatch({ type: "REMOVE", id }); }, []); const show = useCallback((input: ToastInput) => { const id = `t${Date.now()}_${seq.current++}`; const toast: Toast = { id, message: input.message, variant: input.variant ?? "info", durationMs: input.durationMs ?? 3200, dedupeKey: input.dedupeKey, }; dispatch({ type: "PUSH", toast }); return id; }, []); return ( <ToastContext.Provider value={{ show, hide }}> <ToastStateContext.Provider value={toasts}> {children} </ToastStateContext.Provider> </ToastContext.Provider> );}export function useToast() { const ctx = useContext(ToastContext); if (!ctx) throw new Error("useToast must be used within ToastProvider"); return ctx;}
Pass a dedupeKey and you stop the same notification from piling up under button mashing or retries. Give your offline warning dedupeKey: "offline", for instance, and no matter how often it triggers, only one card shows. Pairing this with how you surface flaky-network states fits the approach in designing UX and error states for unstable connections.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦Why putting toast state inside a screen always breaks, and a minimal Context + reducer that centralizes it at the app root
✦How to handle several toasts arriving at once — stacking, de-duplication, auto-dismiss — without re-rendering your app tree
✦Making toasts reach screen readers via AccessibilityInfo, plus safe-area placement that dodges the notch and home indicator
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
This is the heart of it. The toast's enter/exit animation lives entirely inside an "overlay layer" that ToastProvider renders once, at the root. Because of that, toasts coming and going never re-render your screens — children (your app) and the toast stack are simply siblings.
The animation itself is left to Reanimated's entering / exiting. It runs on the UI thread rather than through a JS re-render, so it stays smooth even while a heavy list is scrolling.
// toast/ToastOverlay.tsximport React, { useContext } from "react";import { StyleSheet, View } from "react-native";import Animated, { FadeInDown, FadeOutUp, LinearTransition } from "react-native-reanimated";import { useSafeAreaInsets } from "react-native-safe-area-context";import { ToastStateContext } from "./ToastContext";import { ToastCard } from "./ToastCard";export function ToastOverlay() { const toasts = useContext(ToastStateContext); const insets = useSafeAreaInsets(); return ( // pointerEvents="box-none" lets taps outside a toast pass through to the screen below <View pointerEvents="box-none" style={[styles.host, { paddingTop: insets.top + 8 }]} > {toasts.map((toast) => ( <Animated.View key={toast.id} entering={FadeInDown.springify().damping(18)} exiting={FadeOutUp.duration(180)} layout={LinearTransition.springify()} style={styles.item} > <ToastCard toast={toast} /> </Animated.View> ))} </View> );}const styles = StyleSheet.create({ host: { ...StyleSheet.absoluteFillObject, alignItems: "center", }, item: { width: "100%", alignItems: "center", marginBottom: 8 },});
The layout={LinearTransition...} makes a lower toast glide up when the one above it dismisses. You never compute positions by hand — layout follows the array as it grows and shrinks.
Place ToastOverlay exactly once, outside ToastProvider's children and above navigation.
If you show toasts at the top, add insets.top; at the bottom, add insets.bottom. With useSafeAreaInsets you stop caring whether the device has a notch, and you stop caring about the Android status bar difference. The ToastOverlay above already adds paddingTop: insets.top + 8, so text never slips under the Dynamic Island or notch.
For bottom placement you also need the tab bar height. For a bottom toast I base it on insets.bottom + tabBarHeight + 8, because overlapping the tab bar leads to the "I wanted to dismiss it but hit a tab" misfire.
Always reach the screen reader
This is the most overlooked part. Plenty of toast implementations are visually present yet read nothing to VoiceOver / TalkBack users. An element that briefly appears and disappears doesn't naturally receive screen-reader focus.
The fix is two layers. One: explicitly trigger an announcement the moment the toast appears, using AccessibilityInfo.announceForAccessibility. Two: give the card an accessibilityRole and, on Android, an accessibilityLiveRegion, so it reads even when focused.
On iOS announceForAccessibility is queued onto the speech pipeline; on Android it reads immediately. For high-priority messages like error you can set accessibilityLiveRegion to "assertive" to interrupt. But interruptions get tiresome if overused, so I keep normal notices polite and reserve assertive for things I truly want to halt on, like a payment failure. For accessibility craft in general, see getting VoiceOver and Dynamic Type to production quality alongside this.
Auto-dismiss timers that don't break on background return
setTimeout-based auto-dismiss has a hole that's easy to miss. While the app is backgrounded, the OS pauses or delays JS timers. A toast meant to clear in 3.2 seconds can linger stale on return, or all clear at once the instant you come back.
To do it rigorously, watch for AppState returning to active and, if the app sat in the background a while, fold any visible toasts immediately.
import { AppState } from "react-native";// add inside ToastProviderconst bgAt = useRef<number | null>(null);useEffect(() => { const sub = AppState.addEventListener("change", (s) => { if (s === "background") { bgAt.current = Date.now(); } else if (s === "active" && bgAt.current) { // backgrounded for over 5s — sweep any lingering toasts if (Date.now() - bgAt.current > 5000) { toasts.forEach((t) => hide(t.id)); } bgAt.current = null; } }); return () => sub.remove();}, [toasts, hide]);
Whether you go this far depends on the app's character. For a momentary info notice a little lingering does little harm, but a post-action confirmation like "Copied" appearing after return loses its context and confuses.
When a toast is the right vessel — and when it isn't
The design judgment that matters most is whether a toast is even the right container. Toasts suit short notices that don't block the user and aren't fatal to miss. Put something that forces a choice, can't be undone, or must be read onto a toast, and you've built a miss-it accident.
What you're conveying
Right vessel
Why
Short completion notice (saved, copied)
Toast
Doesn't block; little harm if missed
Undoable action (delete, etc.)
Toast + Undo button
A few seconds' grace rescues mistakes
Network error needing retry
Inline or banner
If it vanishes, the retry path goes with it
Confirming a destructive action
Dialog
An explicit choice should be required
Persistent state (offline)
Sticky banner
Must stay up as long as the state lasts
To put an Undo button on a toast, take a longer durationMs and add a pressable area inside the card. This queue design extends to it directly.
The next step
Start by placing a single ToastProvider and ToastOverlay at the root, then replace the success notices you'd been handling with Alert.alert with show(). After that, turn on VoiceOver on a real device and confirm "Saved" is actually read aloud. The moment the announcement lands, your toast turns from "decoration for sighted users" into "a notice that reaches everyone." To check you're not swallowing error notices inside a toast, review it together with production design for Error Boundaries and unhandled promises, which makes gaps easier to spot.
Share
Thank You for Reading
Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.