RORK LABEN
MAX — Rork MaxはReact NativeではなくネイティブSwiftアプリを生成。iPhone・iPad・Watch・TV・Vision Pro・iMessageに対応しますNATIVE — AR/LiDAR・Metalの3Dゲーム・Dynamic Island・Live Activities・HealthKit・Core MLなどネイティブ機能を解放しますCORE — 通常のRorkはReact Native(Expo)でiOS/Androidアプリを生成。自然言語からストア公開まで到達できますFUNDING — Rorkはa16zから$2.8Mを調達しましたGROWTH — 月間743,000訪問・成長率85%と勢いを増していますPRICING — 無料で始められ、有料プランは$25/月〜ですMAX — Rork MaxはReact NativeではなくネイティブSwiftアプリを生成。iPhone・iPad・Watch・TV・Vision Pro・iMessageに対応しますNATIVE — AR/LiDAR・Metalの3Dゲーム・Dynamic Island・Live Activities・HealthKit・Core MLなどネイティブ機能を解放しますCORE — 通常のRorkはReact Native(Expo)でiOS/Androidアプリを生成。自然言語からストア公開まで到達できますFUNDING — Rorkはa16zから$2.8Mを調達しましたGROWTH — 月間743,000訪問・成長率85%と勢いを増していますPRICING — 無料で始められ、有料プランは$25/月〜です
記事一覧/開発ツール
開発ツール/2026-06-22上級

Rork(Expo)のトーストは、重なっても読み上げられても崩れない設計にする

Rork が生成した React Native アプリにトースト通知を足すとき、単純な実装は『同時に2つ出ると重なる』『スクリーンリーダーに無視される』『ノッチやホームインジケータに隠れる』の3点で破綻します。ルート1か所に置くキュー設計、本体の再レンダーから切り離すアニメーション、AccessibilityInfo による読み上げ、セーフエリア対応までを動くコードで示します。

Rork437React Native173Expo92アクセシビリティ2UX設計4

プレミアム記事

最初に作ったトーストは、画面のコンポーネントの中に useState で1つだけ持たせる、ごく素直なものでした。保存に成功したら「保存しました」と一瞬出る。それだけのつもりでした。

ところが本番で使い始めると、細かい綻びが次々に出てきます。同期処理が二重に走ったときに2つのトーストがぴったり重なって読めなくなる。VoiceOver をオンにしているユーザーには、そもそもトーストが出たことすら伝わっていない。iPhone のホームインジケータの上にちょうど被って、文字の下半分が切れている。どれも「動いてはいる」のに、肝心の人に届いていないのです。

トーストは小さな部品ですが、こうした破綻はすべて「どこに状態を持つか」という一点に根があります。今回は、個人開発で複数の Expo アプリを同じ作法で運用するために私自身が落ち着いた、崩れにくいトースト設計を、動くコードで順に組み立てていきます。

画面の中にトーストを持つと、なぜ必ず破綻するのか

素直な実装は、トーストの表示状態を「今いる画面」のコンポーネントに持たせます。これが破綻の出発点です。

問題は3つの層で起きます。1つ目は重なりです。状態が画面ごとに分散していると、別の画面遷移やバックグラウンド処理から飛んできた通知を1か所で調停できず、同時に複数が描画されます。2つ目は寿命です。トーストを出した画面がアンマウントされると、まだ表示中のトーストやその自動消去タイマーごと消えるか、逆に宙に浮きます。3つ目は再レンダーです。トーストの出入りアニメーションを画面コンポーネントの state で駆動すると、その画面の重いリスト全体が一緒に再レンダーされ、スクロール中にカクつきます。

解決の方向は1つです。トーストの状態は、アプリのルートに1か所だけ置く。画面はそこへ「出して」と依頼するだけにする。この単一の真実の源(single source of truth)に寄せると、上の3つはまとめて消えます。

ルートに1か所だけ置く ToastProvider

まず状態の入れ物を作ります。useReducer でトーストの配列を管理し、Context 経由で showhide を配ります。ポイントは、表示そのもの(オーバーレイ)をこのプロバイダの中で1度だけ描画することです。画面側は state を一切持ちません。

// 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; // 自動消去まで。0 で手動のみ
  dedupeKey?: string;  // 同じキーは重複させない
};
 
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": {
      // 同じ dedupeKey が既にあるなら追加しない(二重送信対策)
      if (action.toast.dedupeKey &&
          state.some((t) => t.dedupeKey === action.toast.dedupeKey)) {
        return state;
      }
      // 画面を埋め尽くさないよう、同時表示は最大3件に保つ
      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;
}

show を呼ぶ画面側は、これだけになります。

const { show } = useToast();
 
async function onSave() {
  try {
    await save();
    show({ message: "保存しました", variant: "success" });
  } catch {
    show({ message: "保存に失敗しました。通信状況をご確認ください", variant: "error", durationMs: 5000 });
  }
}

dedupeKey を渡せば、ボタン連打や再試行で同じ通知が積み上がるのを防げます。たとえばオフライン警告には dedupeKey: "offline" を付けておくと、何度トリガーされても画面には1枚しか出ません。ネットワーク不安定時のエラー表示と組み合わせる設計は、通信が不安定なときの UX とエラー状態の設計の考え方と相性が良いです。

ここまでお読みいただきありがとうございます。

この記事の続きを読む

この先には、実装コードやベンチマーク結果など、実務でお役に立てる内容をご用意しています。このサイトは広告を掲載しておらず、サーバーや開発にかかる費用はメンバーの皆様のご支援で成り立っています。もしお役に立てていましたら、ご支援いただけますと大変ありがたいです。

この記事で得られること
画面ごとにトーストを置くと必ず破綻する理由と、ルート1か所のキューに集約する Context + reducer の最小実装
複数のトーストが同時に飛んできたときの重なり・重複・自動消去を、本体ツリーの再レンダーを起こさずに捌く設計
スクリーンリーダーに黙殺されないための AccessibilityInfo 読み上げと、ノッチ/ホームインジケータを避けるセーフエリア配置の実装
Stripe による安全な決済 · いつでもキャンセル可能

この記事を購入する

この先の内容をすべてお読みいただけます。一度のご購入で、いつでも何度でもアクセスできます。このサイトは広告を掲載しておらず、皆さまのご支援がサーバー費用などの運営を支えています。

または
メンバーシップなら全記事が読み放題 →
シェア

お読みいただきありがとうございます

Rork Lab は広告なしで運営しており、サーバー費用などの運営コストはメンバーシップのご支援で賄っています。実装コード・ベンチマーク・本番設計パターンなど、実務でお役立ていただける記事を毎日更新しています。もし読んでよかったと感じていただけましたら、ぜひご覧ください。

  • コピー&ペーストで使える実装コード付き
  • 毎日新しい上級ガイドを追加
  • ¥580/月 または ¥1,480 の永久アクセス
メンバーシップを見る →

関連記事

開発ツール2026-06-16
通知を「開かなくても片付く」ものにする — Rork アプリのインタラクティブ通知アクション設計
通知を長押しすると現れるボタンやテキスト入力。Rork で作った Expo アプリにこのインタラクティブ通知アクションを実装し、アプリを開かずに完了できる体験を設計します。バックグラウンド処理の落とし穴まで踏み込みます。
開発ツール2026-06-20
Rork の一覧が増えるほどスクロールで重複と欠落が出る——カーソルページネーションと再取得の状態設計
Rork が生成する素朴な offset ページネーションは、リストが更新されるたびに重複や欠落を起こします。カーソル方式の契約設計、取得状態を一つに束ねる usePaginatedList フック、失敗時の指数バックオフ再試行まで、本番運用で詰まらない一覧の作り方を実装中心に解説します。
開発ツール2026-06-20
Rork が直せるバグと自分で直すバグを見分ける — エクスポートコードのトリアージ手順
Rork が自力で直すバグと、エクスポートしたReact Native/Expoコードを自分で手当てすべきバグを切り分けるトリアージ手順を、動くコードとともに整理しました。
📚RECOMMENDED BOOKS
大規模言語モデル入門
山田育矢
LLM開発
生成AIプロンプトエンジニアリング入門
我妻幸長
プロンプト
Claude CodeによるAI駆動開発入門
平川知秀
AI駆動開発
※ アフィリエイトリンクを含みます
もっと見る →