Crashlytics に見慣れないクラッシュが並んでいるのに気づいたのは、壁紙アプリの定例確認をしていた朝でした。
TypeError: params.onFavorite is not a function
手元の実機では一度も出たことがありません。詳細画面は毎日触っています。それでも、前日だけで19件。しかも全て Android で、全て「アプリを開き直した直後」に集中していました。
再現できないクラッシュほど気の重いものはありません。ただ、この件は原因までたどり着いてみると、私自身が見て見ぬふりをしていた黄色い警告がそのまま答えでした。
症状 — 落ちるのは「戻ってきたとき」だけ
整理すると、こういう形です。
| 場面 | 結果 |
|---|---|
| 一覧 → 詳細へ遷移 | 正常 |
| 詳細でお気に入りボタンをタップ | 正常 |
| ホームに戻してすぐ復帰 | 正常 |
| ホームに戻して数十分放置 → 復帰 | クラッシュ |
| 開発中(Expo Go / dev build) | 黄色い警告のみ |
「数十分放置してから戻る」。ここが鍵でした。
その間に OS がアプリのプロセスを回収しています。ユーザーから見れば、アプリはまだ開いたまま。タスク一覧にも残っています。しかし中身は一度死んでいて、復帰時に OS が「さっきの画面」を復元しようとします。
このとき復元されるのが、保存されていたナビゲーション状態です。
再現条件 — 開発者オプションを1つ入れるだけ
数十分待たずに手元で再現できます。Android の開発者向けオプションを開いて、次を有効にします。
- アクティビティを保持しない(Don't keep activities)
これで、ホームボタンを押した瞬間にプロセスが破棄されます。復帰させれば即座に同じクラッシュが出ます。私の場合、有効にしてから最初の1回目で再現しました。
iOS でも同じことは起きますが、破棄のタイミングがメモリ状況に左右されるため、手元での再現はやや粘りが必要です。まずは Android で確かめるのが早道でした。
原因 — ナビゲーション状態は文字列に変換されて保存される
Rork が生成した遷移コードは、こういう形でした。
// 生成されたまま使っていたコード
const openDetail = (item: Wallpaper) => {
router.push({
pathname: "/wallpaper/detail",
params: {
item, // オブジェクトごと
publishedAt: item.publishedAt, // Date オブジェクト
onFavorite: () => toggleFavorite(item.id), // 関数
},
});
};読みやすく、素直です。詳細画面は params.onFavorite() を呼ぶだけで済みます。実際、開発中はこれで何も困りませんでした。
問題は、ナビゲーション状態が 永続化のために JSON へ変換される ことです。
JSON.stringify は関数を静かに捨てます。Date は文字列になります。つまり復元後の params は、こうなっています。
// 復元後に実際に入っていたもの
{
item: "[object Object]", // 文字列化されて壊れている
publishedAt: "2026-07-01T00:00:00.000Z", // Date ではなく string
onFavorite: undefined, // 消えた
}onFavorite は undefined になり、呼んだ瞬間に落ちます。publishedAt.getTime() を呼んでいる箇所も同様です。
そして開発中に出ていた警告が、これでした。
Non-serializable values were found in the navigation state.
Check: wallpaper/detail > params.onFavorite
React Navigation は最初から教えてくれていたわけです。私はこの警告を「動いているから後で」と流していました。復元まで含めた話だと結びつけられていなかったのが正直なところです。
解決策 — params は ID だけにする
直し方は複数ありますが、優先順位ははっきりしています。
対処1: ルートパラメータには識別子だけを載せる(推奨)
一番地味で、一番効きます。params に入れてよいのは、文字列・数値・真偽値だけと決めてしまいます。
// 遷移側
const openDetail = (item: Wallpaper) => {
router.push({
pathname: "/wallpaper/detail",
params: { id: item.id }, // これだけ
});
};// 受け側: app/wallpaper/detail.tsx
import { useLocalSearchParams } from "expo-router";
import { useWallpaperStore } from "@/store/wallpaper";
export default function WallpaperDetail() {
const { id } = useLocalSearchParams<{ id: string }>();
const item = useWallpaperStore((s) => s.byId[id]);
const toggleFavorite = useWallpaperStore((s) => s.toggleFavorite);
// 復元直後はストアがまだ空の可能性がある
if (!item) return <DetailSkeleton />;
return (
<DetailView
item={item}
publishedAt={new Date(item.publishedAt)}
onFavorite={() => toggleFavorite(id)}
/>
);
}ID から本体を引き直す。関数はストア側に置く。この形なら、プロセスが死んで復元されても id という文字列さえ残っていれば画面が再構成できます。
なぜ ID だけが安全かというと、ID は URL に書けるからです。ディープリンクで外から /wallpaper/detail?id=zen-042 と叩かれても成立する形。それが、復元にも耐える形と一致します。ここが腑に落ちてから、判断に迷わなくなりました。
if (!item) return <DetailSkeleton /> の一行は省かないでください。復元直後はストアの再読み込みが間に合わないことがあり、ここを抜くと今度は item.uri で落ちます。私は最初これを忘れ、クラッシュの形が変わっただけで喜びかけました。
対処2: Date は ISO 文字列で渡し、受け側で戻す
どうしても日時を params に載せる必要があるなら、文字列で渡します。
router.push({
pathname: "/wallpaper/detail",
params: { id: item.id, publishedAt: item.publishedAt.toISOString() },
});
// 受け側
const { publishedAt } = useLocalSearchParams<{ publishedAt: string }>();
const date = new Date(publishedAt);往路と復路で同じ変換を通るため、復元後も同じ値になります。
対処3: 警告を「見なかったことにできない」ようにする
根本の問題は、警告が黄色いままだったことです。開発中だけ、これを例外に昇格させました。
// app/_layout.tsx
if (__DEV__) {
const origWarn = console.warn;
console.warn = (...args: unknown[]) => {
const msg = String(args[0] ?? "");
if (msg.includes("Non-serializable values were found")) {
throw new Error(`[navigation] ${msg}`);
}
origWarn(...args);
};
}乱暴に見えますが、効果は明確でした。以後、生成コードが同じ形を書いた瞬間に手元で止まります。本番では __DEV__ が false なので影響しません。
予防策 — 生成コードは「復帰」まで通してから受け取る
Rork の生成コードが悪いわけではありません。動く形として素直ですし、開発中は実際に動きます。ただ、生成側は「プロセスが死んだ後に復元される」という状況までは面倒を見てくれません。そこは受け取る側の仕事だと考えています。
私は実機確認の手順に、次の一行を足しました。
- 「アクティビティを保持しない」を ON にしたまま、主要画面を一巡する
所要はアプリ1本あたり3分ほどです。6本を回しても20分足らず。この確認を入れてから、復帰時のクラッシュは Crashlytics 上で見なくなりました。
個人開発では、手元で動いた時点をゴールにしてしまいがちです。私自身、そこで安心して次の機能に移っていました。けれど本当の初回起動は、ユーザーの端末で、しかもプロセスが一度死んだ後に訪れます。その一瞬を自分の手で作ってから渡す。今はそう決めています。
あわせて、遷移まわりの型を先に縛っておくと生成コードの逸脱に気づきやすくなります。
// 許すのはこれだけ、と型で宣言してしまう
type SerializableParams = Record<string, string | number | boolean>;
export const pushDetail = (params: { id: string }) =>
router.push({ pathname: "/wallpaper/detail", params });遷移を関数に集約しておけば、生成コードがオブジェクトを直接渡そうとした時点で型エラーになります。
切り分けの目安
似た症状に当たったときの、最初に見る順番です。
| 症状 | 疑う場所 |
|---|---|
復帰後だけ is not a function | params に関数を載せていないか |
復帰後だけ getTime is not a function | params の Date が文字列化されている |
| 復帰後だけ画面が空 | ID から本体を引き直せているか |
復帰後に [object Object] | オブジェクトを params に載せている |
| 常に落ちる | これとは別件。復元は無関係 |
「復帰後だけ」という条件が付くなら、ほぼこの一族です。
次にやること
まず、開発中のコンソールに Non-serializable values were found が出ていないかを確認してみてください。出ているなら、その画面が復帰時に落ちる候補です。
そのうえで、遷移コードを ID だけの形に寄せる。この2つで、私の手元では片付きました。
ナビゲーションの構成そのものを見直すなら Expo Router の設計手順 が土台になります。プロセス復帰まわりの状態については 壁紙グリッドに戻ると一番上に戻ってしまう問題 でも、保存と復元のタイミングを扱っています。
警告を1つ潰すだけの、静かな作業です。それでも、再現できないクラッシュが消えていくのは気持ちのよいものでした。お読みいただきありがとうございました。