◉RORK LABEN
●GPT-6.1 SOL — Rork のモデルメニューに追加(09-29)。Low/Medium/High を選べ、Pro・Max に含まれます●SONNET 5.5 — UI づくりが得意で、Sonnet 5 より出力が速いモデルが選べます(09-28)●SONNET 4.5 — Claude API での提供終了は 11/30、残り26日。API 連携を足したアプリは移行先の確認を●SDK 58 — React Native 0.88 に更新されました。Android のリリース版で R8 が既定オンになり、iOS は SceneDelegate を生成します●OBSERVE — EAS Observe がネイティブクラッシュも記録します(SDK 57.0.21 以降・10/07)●NEW — 起動広告が、背面から戻った回にしか出ていませんでした。「前面に戻った」合図が、…●GPT-6.1 SOL — Rork のモデルメニューに追加(09-29)。Low/Medium/High を選べ、Pro・Max に含まれます●SONNET 5.5 — UI づくりが得意で、Sonnet 5 より出力が速いモデルが選べます(09-28)●SONNET 4.5 — Claude API での提供終了は 11/30、残り26日。API 連携を足したアプリは移行先の確認を●SDK 58 — React Native 0.88 に更新されました。Android のリリース版で R8 が既定オンになり、iOS は SceneDelegate を生成します●OBSERVE — EAS Observe がネイティブクラッシュも記録します(SDK 57.0.21 以降・10/07)●NEW — 起動広告が、背面から戻った回にしか出ていませんでした。「前面に戻った」合図が、…
記事一覧/開発ツール
⬡ 開発ツール/2026-06-22上級

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

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

Rork612React Native243Expo234アクセシビリティ5UX設計7

✦ プレミアム記事

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

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

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

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

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

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

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

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

まず状態の入れ物を作ります。useReducer でトーストの配列を管理し、Context 経由で show と hide を配ります。ポイントは、表示そのもの(オーバーレイ)をこのプロバイダの中で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-09-04
EAS の secret は「アプリに入れない」設定ではありません — 接頭辞と可視性を別々に決める
EXPO_PUBLIC_ の接頭辞は成果物に入るかを決め、EAS の可視性は誰が読めるかを決めます。二つを重ねたときに OTA 更新でだけ値が消える筋道と、出荷前に自分の手で確かめる手順をまとめました。
⬡ 開発ツール2026-08-22
一括置換は全ファイル成功しました。壊れたのは、消さなかった行のほうです
生成コードに一括置換をかけると、一致した行ではなく隣の行が壊れます。運用中のプロジェクトで実際に起きた破損と、置換の不変条件を検査する20行のガードスクリプトをまとめました。
⬡ 開発ツール2026-07-30
Renovate が触ってよい依存と、触ってはいけない依存 — Expo SDK 57 の123パッケージを機械的に切り分ける
Rork 生成アプリに依存の自動更新を入れると、Expo が版を管理している123パッケージまで巻き込まれます。2026年7月30日時点の実測では、うち6件が npm の最新版へメジャー更新される差でした。除外リストを手で書かず SDK から機械生成する手順をまとめます。