RORK LABJP
VERIFY — Android developer verification becomes fully mandatory in September 2026. On Android 17 devices verification lives in the OS, so unverified apps can be blocked from new installs at the OS levelREVIEW — From September, responses are required when submitting new apps or updates to the App Store, and when notarizing for alternative distributionSDK — iOS 27 and Xcode 27 are expected to ship in September, but the requirement to submit iOS 27 SDK builds does not land until spring 2027TIMELINE — Rork's $15M seed and the Paperline acquisition were announced on April 9, 2026 — worth dating precisely rather than treating as breaking newsMAX — Rork Max emits native Swift for iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and iMessage, while standard Rork generates cross-platform apps with React Native (Expo)EXPORT — If the automated Publish step fails, you can sync to GitHub and export the full React Native source for free, then finish the submission by handVERIFY — Android developer verification becomes fully mandatory in September 2026. On Android 17 devices verification lives in the OS, so unverified apps can be blocked from new installs at the OS levelREVIEW — From September, responses are required when submitting new apps or updates to the App Store, and when notarizing for alternative distributionSDK — iOS 27 and Xcode 27 are expected to ship in September, but the requirement to submit iOS 27 SDK builds does not land until spring 2027TIMELINE — Rork's $15M seed and the Paperline acquisition were announced on April 9, 2026 — worth dating precisely rather than treating as breaking newsMAX — Rork Max emits native Swift for iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and iMessage, while standard Rork generates cross-platform apps with React Native (Expo)EXPORT — If the automated Publish step fails, you can sync to GitHub and export the full React Native source for free, then finish the submission by hand
Articles/Dev Tools
Dev Tools/2026-06-22Advanced

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.

Rork528React Native220Expo159Accessibility4UX Design9

Premium Article

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.tsx
import 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;
}

The calling screen shrinks to this:

const { show } = useToast();
 
async function onSave() {
  try {
    await save();
    show({ message: "Saved", variant: "success" });
  } catch {
    show({ message: "Couldn't save. Please check your connection.", variant: "error", durationMs: 5000 });
  }
}

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.

or
Unlock all articles with Membership →
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.

  • Copy-paste ready implementation code
  • New advanced guides published daily
  • $5/mo or $10 for lifetime access
View Membership →

Related Articles

Dev Tools2026-06-16
Notifications You Can Finish Without Opening the App — Interactive Notification Actions for Rork Apps
Those buttons and text fields that appear when you long-press a notification. Here is how to implement interactive notification actions in a Rork-built Expo app for an experience that completes without launching, including the background-execution pitfalls.
Dev Tools2026-07-30
What Renovate may bump in an Expo app, and what it must never touch
Turning on automated dependency updates in a Rork-generated app also hands Renovate the 123 packages Expo SDK 57 pins. Measured on 2026-07-30, six of them sit a full major version behind npm latest. Here is how to generate the ignore list from the SDK instead of maintaining it by hand.
Dev Tools2026-07-28
Counting what prebuild --clean will erase before you upgrade to Expo SDK 57
A raw diff between two generated ios/ trees showed 649 changed lines; only 3 were real edits. How to count what prebuild --clean erases, and move it into a config plugin.
📚RECOMMENDED BOOKS
Build a Large Language Model (From Scratch)
Sebastian Raschka
LLM Dev
Prompt Engineering for LLMs
Berryman & Ziegler
Prompting
AI Engineering
Chip Huyen
AI Eng
* Contains affiliate links
See all →