Architecture2026.08.22 · 5 min read

通知権限を確認したら、React Native の AppState が無限に往復した

NCP認証基盤・クラウドインフラ

React Native と Expo で作った Android アプリが、稼働中だけちらつき続けた。StatusBar も、時刻と関係のないボタンも描き直されているように見える。一方、iOS では起きない。時計の更新頻度と購読範囲を見直しても止まらず、原因は読み取り API のsnapshot() に紛れ込んだ通知権限の確認だった。

Android だけ、画面が止まらなくなった

最初に疑ったのは時計だった。画面には稼働時間や週の残り時間があり、React 側では現在時刻を定期的に更新していた。親で時刻を購読すると、その値を直接使わない StatusBar やボタンまで同じ再描画に巻き込まれる。Android でだけ目立つこともあり、描画負荷の問題に見えた。

ただ、症状は時刻表示の更新では説明できない頻度で続いていた。コンポーネントに一時的な描画計測を入れると、時計を表示する子だけでなく、その親から繰り返し描き直されていることが分かった。見るべきものは「どの子をmemo するか」ではなく、「親の state を誰が更新し続けているか」に変わった。

時計の購読範囲を狭めても止まらない

先に入れた修正では、1秒ごとだった React 側の時計を分の切り替わりに合わせ、購読者を画面全体から必要な画面へ移した。日付変更の監視も独立したコンポーネントへ分け、時刻に依存しないボタンは安定した callback とmemo で切り離した。これは再描画の責務としては正しいし、不要な更新も減らせる。

しかし、ちらつきは残った。時計が原因なら、分単位に落とした時点で連続した描画は止まるはずだ。止まらない以上、時計の最適化は症状を弱める修正でしかなく、state を更新している別の入口がある。ここで memo を増やして押さえ込む案は捨てた。親が更新を続ける限り、新しい子を足すたびに同じ問題を踏むからだ。

再描画が多いとき、最初に memo を足すと原因が見えにくくなる。子の描画回数より先に、親の state を更新したイベントの経路を追う必要がある。

snapshot() が AppState を自分で動かしていた

状態の起点を追うと、フォアグラウンド復帰時の処理に着いた。日付が変わった可能性があるため、AppStateactive になったらsnapshot() で保存済み状態を読み直していた。問題は、その読み取りが純粋な読み取りではなかったことだ。

修正前の経路を縮めたもの
// 修正前: 読み取りにも通知同期を付けていた
const api = {
  ...core,
  snapshot: resync(core.snapshot),
};

AppState.addEventListener("change", (state) => {
  if (state === "active") void run(api.snapshot);
});

// resync の中から到達する
await Notifications.requestPermissionsAsync();

ストアには「処理後にローカル通知を予約し直す」ラッパーがあり、打刻や設定変更だけでなくsnapshot() にも付いていた。通知同期は Expo のrequestPermissionsAsync() を呼ぶ。Android では権限確認が別の GrantPermissionsActivity を開くため、アプリは一度active を離れ、戻る。その復帰イベントが再びsnapshot() を呼んでいた。

01
AppState: activeフォアグラウンド復帰を受けて snapshot() を呼ぶ。
02
snapshot() -> notification sync読み取りの完了後に、通知予約と権限確認まで実行する。
03
GrantPermissionsActivityAndroid の権限画面が開き、アプリの AppState が active を離れる。
04
AppState: active権限画面から戻ると、同じ復帰処理がもう一度始まる。
読み取りが権限 Activity を開き、その復帰が次の読み取りを始める

iOS でちらつかなかったのは、React の差ではなく、この Android 固有の Activity 遷移がループの接続点だったからだ。各処理は単独では不自然に見えない。「復帰したら読み直す」「状態を読んだら通知を同期する」「必要なら権限を確認する」が、つながったときだけ自己循環になる。

読み取り、副作用、busy を3つに分ける

修正の中心は snapshot() を軽くすることではなく、契約を変えることだった。通常のsnapshot() は保存済み状態を読むだけに戻す。通知同期を伴うinitialize() は起動時にだけ使い、打刻や設定変更の command はこれまでどおり通知を予約し直す。

src/app/data/store.ts
// 通常の snapshot は純粋な読み取りのまま残す
const initialize = resync(core.snapshot);

const api = {
  ...core,
  initialize,
  punch: resync(core.punch),
  undoPunch: resync(core.undoPunch),
  updateSettings: resync(core.updateSettings),
};

UI 側でも、処理の直列化と busy を分けた。enqueue() は snapshot の取得順を守って state を反映する共通経路、run() はボタンを押した command の経路、refresh() は画面復帰や日付変更の passive refresh とした。復帰時の読み直しでボタンの busy 表示を切り替えないため、正常な復帰処理も静的な UI を巻き込まない。

src/app/hooks/useSnapshot.ts(要点)
const enqueue = useCallback((action: () => Promise<Snapshot>) => {
  // 結果の反映と直列化だけを担当する。busy は触らない
  const task = queue.current.then(async () => {
    const next = await action();
    setSnapshot(next);
    return true;
  });
  queue.current = task;
  return task;
}, []);

const run = useCallback(async (action) => {
  pending.current += 1;
  setBusy(true);
  const ok = await enqueue(action);
  pending.current -= 1;
  if (pending.current === 0) setBusy(false);
  return ok;
}, [enqueue]);

// 画面復帰はユーザー操作ではない
const refresh = useCallback(() => void enqueue(api.snapshot), [enqueue]);

通知側も、いきなり権限を要求しない。まず現在の権限を読み、許可済みか、もう質問できない状態ならその結果を使う。質問できて未許可のときだけ request を出す。権限 Activity を開く操作が、必要な場面に限定された。

src/app/data/notifications.ts
const currentPermission = await Notifications.getPermissionsAsync();
const permission =
  currentPermission.status === "granted" || !currentPermission.canAskAgain
    ? currentPermission
    : await Notifications.requestPermissionsAsync();

再発防止では、通常の api.snapshot()が通知同期を一度も呼ばないことと、起動用の api.initialize()が一度だけ呼ぶことをテストにした。修正後はこの2件を含む83件のテストが成功した。画面のちらつきを直接テストするのではなく、ループを再接続してしまう依存関係をテストしている。

まとめ

今回の不具合は「React の再描画が多い」のではなく、読み取り API がライフサイクルイベントを発生させ、そのイベントが同じ読み取りを呼ぶ循環だった。描画最適化だけでは、循環の出口は閉じない。

snapshot のような名前の API は、呼び出し側が何度でも安全に読めると期待する。端末権限、通知予約、Activity 遷移のような副作用を混ぜるなら、起動や command として別の入口にするべきだ。同じように、ユーザー操作の busy と passive refresh も分ける。状態の種類ではなく、何がその更新を始めたかで経路を分けると、React の描画境界も自然に決まる。

React NativeAndroidExpoAppState