RORK LABJP
MAX — Rork Max builds native Swift apps instead of React Native, supporting iPhone, iPad, Watch, TV, Vision Pro, and iMessageNATIVE — It unlocks native capabilities: AR/LiDAR, Metal 3D games, Dynamic Island, Live Activities, HealthKit, and Core MLCORE — Standard Rork generates iOS/Android apps with React Native (Expo), taking you from plain English to the app storesFUNDING — Rork raised $2.8M from a16z (Andreessen Horowitz)GROWTH — The platform now sees 743,000 monthly visits with 85% growthPRICING — Free to start, with paid plans from $25/monthMAX — Rork Max builds native Swift apps instead of React Native, supporting iPhone, iPad, Watch, TV, Vision Pro, and iMessageNATIVE — It unlocks native capabilities: AR/LiDAR, Metal 3D games, Dynamic Island, Live Activities, HealthKit, and Core MLCORE — Standard Rork generates iOS/Android apps with React Native (Expo), taking you from plain English to the app storesFUNDING — Rork raised $2.8M from a16z (Andreessen Horowitz)GROWTH — The platform now sees 743,000 monthly visits with 85% growthPRICING — Free to start, with paid plans from $25/month
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.

Rork438React Native173Expo92AccessibilityUX Design6

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-06-20
Why Your Rork List Starts Duplicating and Dropping Rows as It Grows — Cursor Pagination and Resilient Refetch State
The naive offset pagination Rork scaffolds for you quietly breaks the moment your list changes underneath the user. Here is how to move to a cursor contract, fold every fetch state into one usePaginatedList hook, and recover failed page loads with exponential backoff — implementation first.
Dev Tools2026-06-20
Bugs Rork Can Fix vs. Bugs You Should Fix Yourself: A Triage Workflow for Exported Code
A practical triage workflow for telling apart the bugs Rork resolves on its own from the ones you should hand-fix in exported React Native/Expo code, with working examples.
📚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 →