Rork で生成したアプリを TestFlight に出した直後、Crashlytics に「JavaScriptError: undefined is not an object」とだけ書かれたクラッシュが、ユーザー数十人ぶん毎日積み上がっていったことがあります。スタックトレースは <anonymous> の連続で、自分が書いた行は一切見当たりません。再現条件も分からず、3日ほど胃を痛めました。
原因を突き止めて分かったのは、Rork が生成するコードの傾向と、React Native のエラー伝播モデルが噛み合わないという構造的な問題でした。?. で安全アクセスは徹底されているのに、await した非同期関数の中で投げられた例外はどこにも捕捉されておらず、結果として「JS スレッドが silently 死ぬ」状態が量産されていたのです。
ここではRork で作ったアプリ特有のクラッシュパターンと、本番運用に耐える Error Boundary・Unhandled Promise Rejection 捕捉の最小構成を、コピペで使える形で残しておきます。AI 生成コードを商用に持っていく方の参考になれば幸いです。
なぜ Rork 生成コードはサイレントクラッシュを起こしやすいのか
Rork は React Native + Expo のコードを生成しますが、画面遷移の構造や API 呼び出しのパターンには、AI 特有の偏りが見られます。具体的には次のような傾向です。
try/catchよりも?.と??で防御する書き方が多い(ランタイム例外を「投げる前に握り潰す」発想)- 非同期関数を
useEffect内でawaitではなく即時実行する書き方が多い(fire-and-forget) - エラー時のフォールバック UI が、画面コンポーネント内ではなくテキスト表示に閉じている
この3つが重なると、ネットワークが不安定な実機では「画面が真っ白のまま固まる」「次の画面に遷移できない」「ボタンを押しても何も起きない」という症状が起きます。Crashlytics には何も上がらないか、上がっても役に立たない情報しか出ないことが多く、ユーザーは黙ってアプリを削除してしまいます。
私の運営しているアプリ事業では、累計 5,000 万ダウンロードのうち、こうした「サイレントクラッシュ」が一番のレビュー★1 量産機でした。逆に言えば、ここを潰せばレビュー評価が目に見えて改善します。エラーハンドリング全般の基本から押さえたい方は、先に Rork アプリのエラーハンドリングとクラッシュ対策入門 に目を通しておくと、この記事の内容がより腹落ちすると思います。
Error Boundary は「最後の砦」で、最初に置くもの
React Native には標準で Error Boundary 相当の仕組みがありません。React.Component の componentDidCatch を実装したクラスコンポーネントを自分で用意するか、react-error-boundary パッケージを使うことになります。
2026年時点の現場感覚では、react-error-boundary の利用が明確に楽です。関数コンポーネントで書けますし、リセット機構も整っています。
// app/_layout.tsx の最上位に置く想定
import { ErrorBoundary } from 'react-error-boundary';
import { Stack } from 'expo-router';
import { Text, View, Pressable, StyleSheet } from 'react-native';
function FallbackScreen({ error, resetErrorBoundary }: {
error: Error;
resetErrorBoundary: () => void;
}) {
return (
<View style={styles.container}>
<Text style={styles.title}>申し訳ありません、問題が発生しました</Text>
<Text style={styles.message}>{error.message}</Text>
<Pressable style={styles.button} onPress={resetErrorBoundary}>
<Text style={styles.buttonText}>もう一度試す</Text>
</Pressable>
</View>
);
}
export default function RootLayout() {
return (
<ErrorBoundary
FallbackComponent={FallbackScreen}
onError={(error, info) => {
// ここで Sentry/Crashlytics へ送る
console.error('[ErrorBoundary]', error, info.componentStack);
}}
>
<Stack />
</ErrorBoundary>
);
}
const styles = StyleSheet.create({
container: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 24 },
title: { fontSize: 18, fontWeight: '600', marginBottom: 12 },
message: { fontSize: 14, color: '#666', marginBottom: 24, textAlign: 'center' },
button: { backgroundColor: '#000', paddingHorizontal: 24, paddingVertical: 12, borderRadius: 8 },
buttonText: { color: '#fff', fontWeight: '600' },
});
// 期待する挙動: 子ツリー内のレンダーエラーを必ず捕捉し、ユーザーに「もう一度試す」を提示する
// resetErrorBoundary を呼ぶと再マウントされ、状態が初期化されるポイントは Stack 全体を1つの ErrorBoundary でラップしている点です。expo-router を使っていると、ルートごとに try してしまいがちですが、ルート画面の遷移そのもので落ちる場合があるため、最上位に置く方が捕捉率が高くなります。
onError のコールバックでログ送信を行う設計にしておくと、後で Sentry に差し替える際に呼び出し箇所を変えずに済みます。
expo-router v3+ なら ErrorBoundary export も併用できる
expo-router は v3 から、各ルートファイルに ErrorBoundary を named export することで、そのルート配下のレンダーエラーを個別に拾えるようになりました。
// app/(tabs)/profile.tsx
export { ErrorBoundary } from 'expo-router';
// 必要なら自前のコンポーネントも書ける最上位の react-error-boundary で全体を守りつつ、ユーザー体験的にリセットしやすい画面(プロフィール編集など)にだけ個別の ErrorBoundary を置く、という二段構えが現実的です。
Unhandled Promise Rejection は ErrorBoundary では拾えない
ここが Rork 生成コード対策の肝です。Error Boundary はレンダー中に投げられた例外しか捕まえられません。 つまり、useEffect の中で発火した非同期処理の中で投げられた例外は、Error Boundary を素通りしてしまいます。
// これは ErrorBoundary に拾われない
useEffect(() => {
fetch('/api/me')
.then(r => r.json())
.then(data => setUser(data.user.profile.name)); // user が null なら TypeError
}, []);React Native では、こうした unhandled promise rejection は警告ログには出るものの、本番ビルドでは LogBox が無効化されているため、ユーザーには何も表示されないまま JS の状態だけが壊れます。
これを拾うには、グローバルな unhandledrejection ハンドラと、React Native 標準の ErrorUtils を組み合わせます。
// utils/installGlobalErrorHandlers.ts
import { Alert, Platform } from 'react-native';
type ErrorReporter = (error: unknown, isFatal: boolean) => void;
export function installGlobalErrorHandlers(report: ErrorReporter) {
// 1) 同期的な未捕捉エラー(JS スレッドの致命的エラー)
const defaultHandler = ErrorUtils.getGlobalHandler();
ErrorUtils.setGlobalHandler((error, isFatal) => {
report(error, !!isFatal);
defaultHandler(error, isFatal);
});
// 2) Promise の未捕捉 reject
const tracking = require('promise/setimmediate/rejection-tracking');
tracking.enable({
allRejections: true,
onUnhandled: (id: number, error: unknown) => {
report(error, false);
},
onHandled: () => {
// 後から catch されたケース(無視で OK)
},
});
if (__DEV__ && Platform.OS !== 'web') {
// 開発時はトーストでも気づけるようにしておく
// 本番では Alert を出さない(UX を壊すため)
}
}
// app/_layout.tsx の冒頭で1回だけ呼ぶ
// installGlobalErrorHandlers((err, fatal) => {
// sendToSentry(err, { fatal });
// });ErrorUtils は React Native の global に存在しますが TypeScript 型が用意されていないため、グローバル宣言を追加するか (global as any).ErrorUtils で参照することになります。promise/setimmediate/rejection-tracking は React Native が内部的に使っているライブラリで、追加インストールは不要です。
私のチームでは、このグローバルハンドラを入れた直後の週に、Sentry に上がってくるエラー件数が3倍に増えました。それまで「届いていなかった」エラーが、ようやく可視化された瞬間でした。最初は数字を見て焦りますが、これが本来の状態です。
Sentry と Crashlytics、Rork アプリならどっち
両方使えるなら両方が理想ですが、リソースが限られている個人開発では、私は Sentry を先に入れることをお勧めしています。理由はシンプルで、JavaScript レイヤのエラーを構造化して送るには Sentry の方が一日の長があるからです。
// app/_layout.tsx
import * as Sentry from '@sentry/react-native';
Sentry.init({
dsn: 'https://YOUR_SENTRY_DSN@o0.ingest.sentry.io/0',
enableNative: true,
enableNativeCrashHandling: true,
// Rork が出力する .map ファイルを EAS でアップロードしておくとシンボル化される
tracesSampleRate: 0.1,
beforeSend(event, hint) {
// 個人情報を含みそうなフィールドはここで落とす
if (event.request?.headers) delete event.request.headers['Authorization'];
return event;
},
});
// グローバルハンドラに渡すレポーター
const report = (error: unknown, isFatal: boolean) => {
Sentry.captureException(error, { tags: { isFatal: String(isFatal) } });
};Sentry の enableNative: true を有効にしておけば、Hermes 由来のネイティブクラッシュも拾えます。Rork は Hermes 前提でビルドされるため、ここを切ると JS 側の例外しか拾えなくなり、原因不明クラッシュの半分を見逃します。
Crashlytics は Android のネイティブ・クラッシュ集計に強いので、規模が大きくなってきたら追加する、という順序が落ち着きます。Sentry のセットアップ手順は Rork × Sentry でクラッシュ監視を導入する手順 にまとめてあるので、本格的に組み込むタイミングで参照してください。
押さえておきたい3つの実装上の落とし穴
実装してみて初めて気づくポイントが3つあります。先に書いておきます。
__DEV__中は ErrorBoundary を意図的に通り抜けさせるオプションがあります。react-error-boundaryのonErrorを実装したからといって、開発時の赤画面(LogBox)は無効化されません。本番ビルドでのみ ErrorBoundary が働く感覚に近いので、TestFlight でテストする際は必ずリリースビルドで確認してください。onError内でさらに throw すると ErrorBoundary が再帰します。 Sentry 送信に失敗した場合に throw すると、ErrorBoundary 自身がエラーになって fallback が表示されない事故を起こします。try/catchで囲むか、captureExceptionのオプションで送信失敗を握り潰してください。resetErrorBoundaryだけでは状態は元に戻らありません。 Error Boundary は React のコンポーネントツリーをアンマウント→再マウントするだけなので、Zustand や Jotai のストアに残った壊れた状態は持ち越されます。リセットボタンの中でストアの clear も呼ぶ設計にしておくと、ユーザーが本当に「やり直せた」状態になります。
私自身、3 番目の落とし穴で半日溶かしました。ストアに残った null データを参照し続けて、ErrorBoundary がリセット後に再び発火する無限ループになっていたのです。
仕掛けがちゃんと働いているか確認する隠しルート
本番に出す前に、どの層が捕まえているかを目で確認できる小さな仕掛けを入れておくと安心です。私は環境変数で開発時にだけ表示される隠しルートを毎プロジェクトに置いています。
// app/_dev/error-test.tsx — EXPO_PUBLIC_DEV_TOOLS=1 のときだけルートに登録
import { Pressable, View, Text } from 'react-native';
export default function ErrorTest() {
return (
<View>
<Pressable onPress={() => { throw new Error('render-time test'); }}>
<Text>レンダー中に投げる</Text>
</Pressable>
<Pressable onPress={async () => { await Promise.reject(new Error('async test')); }}>
<Text>Promise を reject</Text>
</Pressable>
<Pressable onPress={() => { setTimeout(() => { throw new Error('timer test'); }, 0); }}>
<Text>setTimeout 内で投げる</Text>
</Pressable>
</View>
);
}リリースビルドで3つのボタンをそれぞれ押すと、1つ目は ErrorBoundary が、2つ目は Promise rejection ハンドラが、3つ目は ErrorUtils のグローバルハンドラが捕まえるはずです。どれか1つでも黙って通り抜けたら、配線が抜けている証拠です。15分のチェックを習慣にすると、計測が抜けたまま本番に出す事故が減ります。
まずは最上位の _layout.tsx に react-error-boundary を1つ置いてみてください
完全な構成を一気に入れる必要はありません。まずは react-error-boundary をインストールして、app/_layout.tsx の <Stack /> を <ErrorBoundary> で包む。その1コミットだけでも、ユーザーが目にする「真っ白な画面のまま戻れない」体験は確実に減ります。
Unhandled Promise の捕捉やSentry連携は、その次の週末に取り組めば十分です。すでにアプリが白画面で固まっている症状がある方は、原因切り分けのフローを Rork で起きる白画面クラッシュのデバッグガイド にまとめてあるので、合わせて読んでみてください。React Native のエラー伝播はクセがあるぶん、一度コードに住まわせてしまえば、その後は同じパターンを使い回せます。お読みいただきありがとうございました。AI 生成のコードを商用品質まで磨き上げる過程で、同じ壁に当たっている方の参考になれば幸いです。