地下鉄に乗っているとき、自分のアプリでメモにチェックを付けようとして、くるくる回る読み込み表示が止まらなくなりました。電波が切れていたのです。チェックという一瞬の操作が、通信の往復を待つ作りになっていたために、トンネルを抜けるまでアプリが何も応えてくれませんでした。利用者からすれば、これは「壊れている」のと変わりません。
このとき痛感したのは、通信を前提に画面を組むと、通信がないだけでアプリが死ぬということです。電車・エレベーター・地下の店舗。日常には電波の切れる場所がいくらでもあります。個人開発で複数のアプリを運用していると、こうした環境での挙動に関する低評価レビューが、じわじわとストアの星を削っていきます。
なぜ「待つ」設計はユーザーを失うのか
多くのアプリは、操作をサーバーに送り、成功の返事を受け取ってから画面を更新します。順番としては自然ですが、この順番だと通信の遅さや断絶がそのまま操作の遅さになります。AdMob のバナーが裏で読み込まれているような場面では、なおさら回線が混み、待ち時間が伸びます。
利用者が求めているのは、タップした瞬間にチェックが付くことです。サーバーの返事は、本当はあとからでかまいません。ならば順番を逆にします。先に画面を更新し、サーバーへの送信はあとから追いかける。これが楽観的更新の考え方です。
まず画面を更新し、送信は後ろに回す
楽観的更新の骨格は単純です。タップされたら、ローカルの状態を即座に変え、同時に「サーバーへ送るべき操作」をキューに積みます。送信が成功すればキューから消し、失敗すれば残して後で再送します。
// optimisticStore.ts — 先に画面を変え、送信はキューに積む
type Mutation = { id : string ; type : "toggle" ; itemId : string ; value : boolean };
const pending : Mutation [] = [];
let items : Record < string , boolean > = {};
export function toggleItem ( itemId : string , value : boolean , render : () => void ) {
// 1) 画面を先に更新(体感は即時)
items[itemId] = value;
render ();
// 2) 送信すべき操作をキューに積む
const mutation : Mutation = {
id: `${ itemId }-${ Date . now () }` ,
type: "toggle" ,
itemId,
value,
};
pending. push (mutation);
void flushQueue ();
}
利用者の指から見れば、チェックは0秒で付きます。通信は裏側で静かに進みます。体感速度の改善は劇的で、私のアプリでは操作からフィードバックまでの待ち時間が、計測上およそ400ミリ秒からほぼ0へ縮みました。
送信キューを永続化して、アプリが落ちても消さない
ここで1つ目の落とし穴があります。キューをメモリにだけ置くと、利用者がアプリを切り替えた拍子に OS がプロセスを破棄したとき、送信待ちの操作がまるごと消えます。本人は「チェックした」と思っているのに、サーバーには届いていない。これは静かなデータ消失で、いちばん信頼を損なう種類のバグです。
ですからキューは必ずディスクへ永続化します。Expo なら AsyncStorage で十分です。
// queuePersist.ts — 送信待ちキューをディスクに保存し、起動時に復元する
import AsyncStorage from "@react-native-async-storage/async-storage" ;
const KEY = "pending_mutations_v1" ;
export async function savePending ( pending : Mutation []) {
await AsyncStorage. setItem ( KEY , JSON . stringify (pending));
}
export async function loadPending () : Promise < Mutation []> {
const raw = await AsyncStorage. getItem ( KEY );
if ( ! raw) return [];
try {
return JSON . parse (raw) as Mutation [];
} catch {
// 壊れたデータは捨てる。キュー全体を道連れにしない
await AsyncStorage. removeItem ( KEY );
return [];
}
}
キューを積むたびに savePending を呼び、アプリ起動時に loadPending で復元してから再送を試みます。これで、トンネルに入ったままアプリを閉じても、次に開いたときに送信が再開します。
戻ってきたときの食い違いをどう解くか
2つ目の、そして本当に難しい落とし穴が競合です。オフラインの間に、自分は項目を「完了」にした。けれど同じ時間に、別の端末や同期処理がサーバー側で同じ項目を「未完了」に戻していた。通信が戻った瞬間、ローカルとサーバーは食い違っています。
ここで「あとから来たほうが勝つ」と単純に上書きすると、利用者の操作が黙って消えるか、サーバーの正しい状態が踏み潰されます。私が本番で採っているのは、各変更にタイムスタンプとデバイス識別子を持たせ、より新しい変更を採用しつつ、捨てたほうを記録するやり方です。
// resolveConflict.ts — タイムスタンプ優先。ただし捨てた側を記録して可視化する
type Change = { value : boolean ; updatedAt : number ; device : string };
export function resolve ( local : Change , server : Change ) : {
winner : Change ; discarded : Change | null ;
} {
if (local.updatedAt === server.updatedAt) {
// 完全同時刻は稀。device 名の辞書順で機械的に決め、ループを避ける
return local.device < server.device
? { winner: local, discarded: server }
: { winner: server, discarded: local };
}
return local.updatedAt > server.updatedAt
? { winner: local, discarded: server }
: { winner: server, discarded: local };
}
肝は discarded を握りつぶさないことです。捨てた側の変更をログに残し、もし重要な操作(課金状態の変更など)が競合で消えたら、あとから検知して救済できる状態にしておきます。私は金額やアカウントに関わる項目だけは、自動の上書きに任せず、競合が起きたら利用者に確認を出す設計を推奨します。お金に関わる食い違いを機械が黙って決めるのは、本番では危険だと考えています。
ネットワーク復帰を検知して自動で再送する
最後に、これらを繋ぐのが接続状態の監視です。@react-native-community/netinfo で復帰を検知し、オンラインに戻った瞬間にキューを流します。
アプリ起動時に永続キューを復元する
接続が回復したらキューを順に送信する
各送信のレスポンスでサーバー状態を受け取り、競合解決にかける
解決の結果でローカルを最終確定し、キューから該当操作を消す
この4ステップを1本の流れにしておくと、利用者は「電波が切れていた」という事実にすら気づかなくなります。トンネルの中で付けたチェックは、地上に出た数秒後に、何事もなかったように同期されています。
実装してから変わった、レビューの内容
この設計を入れる前と後で、App Store のレビューに現れる言葉が変わりました。以前は「電車で固まる」「読み込みが終わらない」という指摘が一定数ありました。楽観的更新と永続キューを入れたあとの数か月で、その種のレビューはほぼ見かけなくなり、代わりに「サクサク動く」という短い感想が増えました。個人開発では一件一件のレビューが順位に効くので、この変化は売上にも静かに跳ね返ってきます。
実装上の現実的な注意も一つ。楽観的更新は「あとで失敗しうる」前提なので、失敗したときに画面をどう戻すかを最初に決めておくのが安全です。私の場合は、再送を3回試して全て失敗した操作だけ、控えめなトーストで「保存できませんでした。再試行しますか」と利用者に知らせる形に落ち着きました。黙って戻すと混乱を生むため、戻すなら必ず一言添える、というのが本番で得た判断です。
次の一手
まずは一番よく使われる操作を1つだけ選び、それを楽観的更新に置き換えてみてください。全画面を一度に変える必要はありません。チェックや「いいね」のような可逆な操作から始めると、競合解決の難所に踏み込む前に、体感速度の改善だけを安全に確かめられます。そのうえでキューの永続化を足せば、オフラインでアプリが死ぬという最悪の体験は、ほぼ消すことができます。
電波の弱い場所で使われるアプリほど、この設計は静かに効いてきます。同じ課題に向き合う方の助けになれば幸いです。