結論
const today = useMemo(() => getTodayJst(), []);
このコードには問題があります。依存配列が空なので、マウント時に1回しか評価されません。
アプリを閉じずに日付が変わっても today は古いままです。そしてiOSは、アプリを何日でもメモリに残します。
「今日やること」を表示する画面でこれをやっていたため、権利落ち日の朝に開いても前日の内容が表示されるバグになっていました。
何が起きるか
株主優待のクロス取引を管理するアプリで、ホーム画面に「今日やること」を表示していました。今日が権利落ち日の銘柄があれば、カードとボタンが出ます。
実際のユーザーの導線はこうなります。
- 夜、アプリを開いて明日の予定を確認する
- そのままアプリを閉じずにホームボタンを押す
- 翌朝、通知が届く
- 通知をタップしてアプリを開く
4のとき、iOSはアプリを再起動せずバックグラウンドから復帰させます。Reactコンポーネントは再マウントされないため、useMemoは再評価されません。
結果、権利落ち日の朝に開いたのに、前日の「今日やること」が表示される。通知は正しく届いているのに、開いた画面が古い。アプリの中心機能が半分死んでいる状態でした。
iOSがアプリをメモリに保持する期間に上限はありません。メモリ圧迫がなければ何日でも残ります。「どうせ1日1回は再起動されるだろう」という想定は成り立ちません。
そもそも getTodayJst は正しかった
先に言っておくと、日付を求める関数自体に問題はありませんでした。
const JST_OFFSET_MS = 9 * 60 * 60 * 1000;
export function getTodayJst(now: Date = new Date()): DateStr {
const jstTimestamp = now.getTime() + JST_OFFSET_MS;
const d = new Date(jstTimestamp);
return `${d.getUTCFullYear()}-${...}-${...}`;
}
UTC時刻に9時間足してから UTC系アクセサで読むことで、端末のタイムゾーン設定に依存せずJSTの日付を得ています。海外で使われてもズレません。
問題は関数ではなく、それをいつ呼ぶかでした。純粋関数として正しく書けていても、呼び出し契機が間違っていれば結果は古くなります。
修正:更新契機を2つ追加する
必要な契機は2つです。
- 画面がフォーカスされたとき(タブ切り替えでホームに戻った)
-
アプリがバックグラウンドから復帰したとき(
AppStateがactiveになった)
前者だけでは足りません。ホームタブを開いたままアプリをバックグラウンドに送り、翌朝そのまま復帰した場合、フォーカスイベントは発生しないからです。
// フォーカス時
useFocusEffect(
useCallback(() => {
refresh();
}, [refresh]),
);
// アプリ復帰時
useEffect(() => {
const subscription = AppState.addEventListener('change', (nextAppState) => {
if (nextAppState === 'active') {
refresh();
}
});
return () => subscription.remove();
}, [refresh]);
AppState のリスナーはアンマウント時に必ず解除します。subscription.remove() を返り値の cleanup で呼ぶ形です。
refresh() の中で today を再計算し、あわせてレコードと設定値も読み直します。
実装上の注意点
多重読み込みを防ぐ
フォーカスとアプリ復帰は同時に発火しうる組み合わせです。通知をタップしてアプリを開くと、復帰とフォーカスがほぼ同時に来ます。
useRef のフラグで、処理中の再入をスキップします。
const isRefreshingRef = useRef(false);
const refresh = useCallback(async () => {
if (isRefreshingRef.current) return;
isRefreshingRef.current = true;
try {
// 読み込み処理
} finally {
isRefreshingRef.current = false;
}
}, []);
state ではなく ref を使うのは、フラグの更新で再レンダリングを起こしたくないのと、同一ティック内の判定に state の非同期更新では間に合わないためです。
ちらつきを防ぐ
2回目以降の読み込みでローディングスピナーを出すと、タブを切り替えるたびに画面が一瞬消えます。
初回かどうかを別の ref で持ち、2回目以降は既存の表示を保ったまま裏で読み直します。
const hasLoadedOnceRef = useRef(false);
失敗しても画面を壊さない
再読み込みが失敗したときは、直前の表示を維持するのが正解でした。
権利落ち日の朝、通知をタップして開いた瞬間にエラーバナーが出るのは最悪です。古いデータでも表示されている方がマシですし、次のフォーカスで自然に回復します。
初回ロードの失敗だけはエラーを表示します。表示すべきものが何もない状態なので、黙って空画面を出す方が不親切です。
派生バグ:今日から導出した state も古くなる
today を更新するようにしたあと、実機で別の不具合が出ました。
ホーム画面には「予定」セクションがあり、今月 / 来月 のチップで表示を切り替えられます。日付を8月から9月に飛ばして開いたところ、こうなりました。
- チップのラベルは
今月(9月) / 来月(10月)に更新されている - しかしどちらのチップも選択されていない
- 表示されているのは8月のレコード
選択中の月を保持する state が2026-08のまま残っていたためです。新しい「今月」「来月」のどちらにも一致しないので、どのチップもハイライトされませんでした。
today を直しても、そこから派生した state は別途面倒を見る必要があるという話です。
修正は選択月の補正を入れるだけでした。
useEffect(() => {
setScheduleMonth((current) =>
current === thisMonth || current === nextMonth ? current : thisMonth,
);
}, [thisMonth, nextMonth]);
thisMonth と nextMonth は today から useMemo で導出しているため、この useEffect は既存の再読み込み契機にそのまま乗ります。新しい更新契機を増やさずに済みました。
ここで注意したのは、毎回リセットしないことです。ユーザーが「来月」を見ている最中にタブを行き来しただけで今月に戻されるのは煩わしいので、新しい今月・来月のいずれかに一致していれば選択を維持します。
テストで守る
このバグは純粋関数のテストでは検出できません。画面のライフサイクルに紐づいた不具合だからです。
コンポーネントテストで次を確認しました。
-
todayが変わった状態で再フォーカスすると、表示されるアクションが更新される - アプリが
activeになったときも再読み込みされる - フォーカスと復帰がほぼ同時に発火しても多重読み込みされない
- アンマウント時に
AppStateリスナーが解除される - 再読み込みが失敗しても例外が漏れず、直前の表示が維持される
- 選択月が新しい今月・来月のいずれにも一致しない場合、今月に戻る
- 「来月」選択中に同月内で再読み込みされても選択が維持される
チップの選択状態は、スタイル配列を覗くのではなくprops.valueとprops.itemsを直接検証しました。スタイルの構造に依存するテストは、実装を少し変えただけで壊れます。
まとめ
-
useMemo(() => getTodayJst(), [])は「今日」を固定する。 依存配列が空なら再評価されない - iOSはアプリを何日でもメモリに残す。 日付が変わっても再マウントは起きない
- フォーカスとアプリ復帰の両方が必要。 どちらか片方では漏れる経路がある
-
今日から導出した state も一緒に更新する。
todayだけ直しても片手落ちになる -
純粋関数が正しくても、呼び出し契機が古ければ結果は古い
日付を扱うアプリで「今日」を状態として持つときは、それがいつ更新されるかを必ず設計してください。日付は放っておいても勝手に変わりますが、useMemoの中身は変わりません。