Expo Router(React Native)の言語切り替えで入力が消える原因 ─ Stackのkey={locale}をやめ、再描画だけで翻訳を反映した話

当ページのリンクには広告が含まれています。
この記事の結論
表示言語を切り替えたときに入力中の内容や検索条件が消えていたのは、画面の土台に key={locale} を付けて、言語が変わるたびに全画面を作り直していたからでした。確認ダイアログや保存中の切り替え禁止で個別に塞ぐのをやめ、作り直し自体を廃止して、文言を使う場所それぞれが言語の変更を受け取って描き直す方式に変えました。作り直しをやめると、それまで隠れていた「stateに入った翻訳済みの文言」と「保存中の連打」が表に出てくるので、そこまで直して初めて完了だと考えています。

お疲れ様です!IT業界で働くアライグマです!

個人開発している技術記事キュレーションサービス「DevPick」のAndroidアプリに、英語表示を入れました。設定画面で日本語とEnglishを切り替えられるようにしたのですが、最初の実装では、言語を切り替えると画面全体がまっさらに作り直される作りになっていました。この記事では、その作りがなぜ問題だったのか、どう直したのか、直した結果どんな問題が新しく見えてきたのかを順に書きます。

目次

言語を切り替えると、入力中の内容まで消えていた

アプリを英語で表示できるようにした最初の段階では、表示言語が変わったら画面の土台にあたるルートの Stack(Expo Routerで画面を積み重ねて管理する部品)ごと作り直す、という方式を採っていました。コードの中には、画面の外側(モジュールの読み込み時など)で組み立てた文言もあります。全部を作り直せば、そういう文言も含めて新しい言語で引き直されるので、「確実に全部切り替わる」という安心感がありました。

問題が見えたのは、設定画面から言語を切り替えられるようにしたときです。マージ前のPRに対して、AIの自動レビューが同じ根っこの指摘を3回続けて出しました。中身は次のとおりです。

  • 未保存の入力が消える:マイページで名前やメールアドレスを編集している途中に言語を選ぶと、画面が作り直され、編集中の内容が確認なしで捨てられる
  • 保存中に切り替えると古い値に戻る:プロフィールなどの保存待ちの最中に作り直されると、保存の進行状態が失われ、キャッシュに残っていた保存前のプロフィールで入力欄が初期化される。保存が成功しても古い設定が表示され、次の保存で成功済みの変更を上書きしてしまう
  • 保存完了の直後もまだ危ない:保存処理は、プロフィールの再取得を待たずに「完了」扱いになる作りだった。そのため「保存中」の判定が外れてから再取得が終わるまでの短い間に切り替えると、やはり保存前の値で初期化される

DevPickのPRには、pushのたびにAIが自動でレビューを返す仕組みがあります(仕組みはAIコードレビューを前回のレビュー以降の差分に絞った話で書いています)。このPRでは、1つ直してpushするたびに次の指摘が出る、という流れになりました。このときはそれぞれを個別に塞いでいます。切り替える前に入力が破棄されることを伝える確認ダイアログを出す、保存中は言語を切り替えられないようにする、保存の応答をそのままプロフィールのキャッシュに書き込んでから完了扱いにする、の3つです。

ただ、これで塞げたのは見つかった穴だけです。作り直しそのものは残っていて、消えるのはマイページの入力だけではありませんでした。各タブの検索や絞り込みの条件、記事一覧のスクロール位置、保存の結果表示なども、言語を切り替えるたびに全部初期化されます。そこで、作り直しをやめる方向で改めて直すことにしました。

IT女子 アラ美
確認ダイアログで「消えますよ」って言われても、消えること自体は変わらないもんね。

ITアライグマ
そうなんです。警告で納得してもらうより、消えない作りにする方が筋が良いと考えました。

原因は「keyを変えると画面ごと作り直される」こと

修正前のルートのレイアウトは、次のようになっていました。

function RootContent() {
  const { resolvedTheme } = useTheme();
  // 表示言語が切り替わったら画面全体を作り直し、モジュール外で組み立てた文言も引き直す
  const locale = useLocale();

  return (
    <SafeAreaProvider>
      <StatusBar style={resolvedTheme === "dark" ? "light" : "dark"} />
      <Stack key={locale} screenOptions={{ headerShown: false }}>
        {/* タブや各画面 */}
      </Stack>
    </SafeAreaProvider>
  );
}

ポイントは <Stack key={locale}> の部分です。Reactは、画面を描き直すときに「前回と同じ部品か」を見て、同じなら中身(stateなど)をそのまま残します。key はその判定に使う目印で、key の値が変わると、Reactは「これは前とは別の部品だ」とみなします。そして古い部品を丸ごと捨て(アンマウント)、新しい部品を一から作ります(マウント)。

useState で持っている値は、その部品と一緒に生きています。部品が捨てられれば、入力中の文字も、絞り込み条件も、スクロール位置も一緒に消えます。Stack はアプリのほぼ全画面を抱えている土台なので、ここで key が変わると、アプリ中の画面の状態がまとめて初期化されます。前の章で並べた問題は、どれもこの1行から来ていました。

では、なぜわざわざ key を付けていたのか。理由は、翻訳関数 t() の作りにあります。DevPickの t() は、モジュール内の変数に入っている「いまの表示言語」を読んで、その言語の辞書から文言を返すだけの関数です。

let currentLocale: Locale = "ja";

export function t(key: MessageKey, params?: MessageParams): string {
  return interpolate(dictionaries[currentLocale][key] ?? ja[key], params);
}

t() を呼んでも、言語の変更を購読することにはなりません。言語が変わっても、すでに描画済みの画面は自分から描き直さないので、古い言語の文言が残ります。そこで「土台ごと作り直せば、全員が t() を呼び直すので確実に切り替わる」というのが key={locale} の狙いでした。切り替えの確実さと引き換えに、画面の状態を全部手放していたわけです。

IT女子 アラ美
全部の文言を確実に変えたいから、家ごと建て直してたってこと?大胆すぎない?

ITアライグマ
はい、まさにそういう状態でした。手軽で確実な分、中にいた人の荷物まで全部捨てていました。

keyを外し、文言を使う場所それぞれで言語の変更を受け取る

方針は単純で、土台の key を外して作り直しをやめる代わりに、文言を表示している部品それぞれが「言語が変わった」という通知を受け取って、自分だけを描き直すようにしました。

通知を受け取る入口は、もともと用意していた useLocale() というフックです。中身はReactの useSyncExternalStore で、React の外にある値(ここではモジュール変数の表示言語)を購読するための仕組みです。言語を切り替えると登録されている購読者全員に通知が飛び、useLocale() を呼んでいる部品だけが描き直されます。

function applyLocale(locale: Locale) {
  if (locale === currentLocale) return;
  currentLocale = locale;
  for (const listener of listeners) listener();
}

/** 表示言語を購読する。切り替わると呼び出し元が再描画される */
export function useLocale(): Locale {
  return useSyncExternalStore(subscribe, getLocale, getLocale);
}

描き直しは「状態を捨てて作り直す」のではなく「状態を残したまま中身を描き直す」動きなので、入力中の文字や絞り込み条件は残ります。ルートのレイアウトの変更自体は、key を消すだけです。

// 表示言語の切り替えは各画面・コンポーネントの再描画で反映し、画面全体を作り直さない
useLocale();

return (
  <SafeAreaProvider>
    <StatusBar style={resolvedTheme === "dark" ? "light" : "dark"} />
    <Stack screenOptions={{ headerShown: false }}>
      {/* タブや各画面 */}
    </Stack>
  </SafeAreaProvider>
);

大変なのはここからで、key に頼っていた間は「どこで t() を呼んでいても勝手に切り替わる」状態でした。key を外すと、useLocale() を呼んでいない部品は古い言語のまま残ります。そのため、文言を出している場所を洗い出して、1つずつ購読させる必要がありました。この修正で手を入れた主な場所は次のとおりです。

  • タブのレイアウト:下部タブの見出し(ニュース・ブックマーク・マイページ)を出している部品で useLocale() を呼ぶ
  • 各画面:ニュース、ブックマーク、マイページ、後で読む、いいね、閲覧履歴、お問い合わせなどの画面で useLocale() を呼ぶ
  • 部品・シート:記事カード、画面ヘッダー、カテゴリのタブ、各種の絞り込みシート、バックアップ用のモーダルなどで useLocale() を呼ぶ
  • 一覧:FlatList に extraData={locale} を渡す
  • 計算済みの文言:useMemo で組み立てている文言の依存配列に locale を足す

後ろの2つは、useLocale() を呼ぶだけでは足りない場所です。FlatList は性能のために、渡された data が変わらない限り各行を描き直しません。言語を変えても記事の配列そのものは同じなので、記事カードの「1時間前/1 hour ago」のような相対日時は古いまま残ります。extraData は「この値が変わったら行も描き直してほしい」と伝えるための引数で、ここに locale を渡しました。

<FlatList
  testID="news-feed-list"
  data={displayArticles}
  extraData={locale}
  keyExtractor={(item) => String(item.article.id)}
  renderItem={/* 記事カード */}
/>

useMemo も同じ理屈です。ニュース画面には、いま効いている絞り込み条件を「新着順」「未読のみ」のような短いラベルにして並べる部分があります。このラベルは useMemo で計算結果を覚えているので、依存配列に locale がないと、言語を変えても前回の計算結果(古い言語のラベル)が返り続けます。依存配列に locale を加えて、言語が変わったら計算し直すようにしました。

作り直しをやめたので、前の段階で入れた「切り替える前の確認ダイアログ」も役目がなくなり、撤廃しました。いまは言語を選んだ時点でそのまま反映されます。テストには、言語を切り替えるとタブ見出しと記事カードの文言が新しい言語になること、マイページで編集中のフォームの入力が切り替え後も残ることを追加しました。モバイル側のテストは102スイート・1,175件がすべて通っています。

IT女子 アラ美
keyを外すだけで終わりじゃなくて、文言を出してる場所を全部探すのが本番なのね。

ITアライグマ
はい。特にFlatListとuseMemoは、購読していても古い表示が残るので見落としやすいです。

作り直しが隠していた問題:stateに入った翻訳済みの文言

key を外す変更をpushしたあと、マージ前の自動レビューで新しい指摘が出ました。マイページで表示名を空のまま保存しようとすると、「表示名を入力してください。」というエラーが出ます。その状態でEnglishに切り替えても、このエラーだけが日本語のまま残る、という指摘です。

原因は、エラー文言を t() で翻訳したあとの文字列としてstateに入れていたことでした。

// 修正前:翻訳済みの文字列をstateに入れる
const [nameError, setNameError] = useState<string | null>(null);

if (!trimmed) {
  setNameError(t("profile.nameRequired")); // この時点の言語で文字列が確定する
  return;
}

{nameError ? <Text>{nameError}</Text> : null}

stateに入った時点で、文言は「その瞬間の言語の文字列」として固定されます。画面を描き直しても、stateの中身は同じ文字列のままなので、言語は切り替わりません。作り直していた頃は、言語を変えるとstateごと捨てられていたので、この問題は表に出ていませんでした。作り直しをやめて状態を残すようにしたことで、「残してはいけない形で持っていた状態」まで残るようになったわけです。

直し方は、stateには翻訳前のキーを持たせ、描画するときに翻訳することです。

// 修正後:翻訳キーをstateに入れ、描画時に翻訳する
const [nameErrorKey, setNameErrorKey] = useState<MessageKey | null>(null);

if (!trimmed) {
  setNameErrorKey("profile.nameRequired");
  return;
}

{nameErrorKey ? <Text>{t(nameErrorKey)}</Text> : null}

同じ形は、マイページの表示名だけではありませんでした。ブックマークの一括削除に失敗したときのエラー、アカウント復旧やメール確認の画面のエラー、バックアップ用のモーダル、カスタムRSSやキーワード設定の入力チェックなどでも、翻訳済みの文字列をstateに入れていました。これらも同じ修正の中で、キーを持つ形にそろえています。フォローキーワードの入力チェックのように「キーワードは40文字以内で入力してください」「『〇〇』は登録済みです」のように、文言の中に値を差し込む文言は、キーだけでは足りません。そこでチェック結果に、エラーの種類と差し込む値(上限の文字数や重複したキーワードなど)を持たせ、描画時に組み立てられるようにしました。

選択肢の一覧のように、モジュールの外側で定義している文言は、以前から labelKey としてキーで持っていたので、今回の問題にはなりませんでした。「翻訳した文字列を保存しない。保存するのはキーと値まで」というルールが、言語を切り替えても状態を残す作りの前提になります。

IT女子 アラ美
状態を残すようにしたら、残っちゃいけないものまで残ったってこと?皮肉な話ね。

ITアライグマ
そうなんです。作り直しは乱暴でしたが、こういう持ち方の甘さを毎回リセットで隠していました。

保存中の連打は、disabledではなくrefで止める

エラー文言をキーで持つ修正をpushすると、同じPRのマージ前レビューで、今度は「保存中に選択肢をもう一度押せてしまう」という指摘が2件出ました。表示言語と、ニュースソースの範囲(日本語ソース/海外ソース/両方)の選択肢です。

言語を選ぶと、setLocale() がまず画面の言語を切り替え、続けて端末のストレージ(AsyncStorage)に選択を書き込みます。書き込みは非同期で、終わるまで少し時間がかかります。ところが、選択肢を押したときの処理はこの書き込みを待っておらず、「保存中」を表す状態も持っていませんでした。画面にあった「保存中」の判定は、サーバーへの保存(React Queryのmutation)だけを見ていたので、端末への書き込み中は「保存中ではない」扱いのまま、選択肢を押し直せました。ソース範囲の選択も、保存のPromiseを void で捨てていて、選択肢に disabled も渡していませんでした。

そこで次のpushで、言語とソース範囲それぞれに「保存中」のstateを用意し、書き込みの完了を await で待つようにしました。保存中は選択肢を disabled にします。これで直ったように見えましたが、次のレビューでもう1件指摘が出ました。

// stateだけで判定していた段階
async function handleSelectLocale(next: Locale) {
  if (next === locale || displaySaving) return; // displaySaving はこの描画時点の値
  setSettingLocale(true); // 反映されるのは次の再描画から
  try {
    await setLocale(next);
  } finally {
    setSettingLocale(false);
  }
}

displaySaving は、この画面が最後に描画されたときの値です。setSettingLocale(true) を呼んでも、値が変わるのは次に描き直されたときで、いま動いている処理から見える値はすぐには変わりません。そのため、描き直しが起きる前に素早く2回押されると、2回とも displaySaving が false のまま判定を通り、同じ保存が二重に走ったり、言語とソース範囲の保存が同時に走ったりします。disabled も描き直されて初めて効くので、この隙間は埋まりません。

最後の修正では、言語とソース範囲の両方の処理で共有する useRef を足しました。refの値は描き直しを待たず、書き換えたその場で次の処理から見えます。保存を始める前に同期的にフラグを立て、finally で必ず下ろします。

const displaySavingInFlightRef = useRef(false);

async function handleSelectLocale(next: Locale) {
  if (next === locale || displaySaving || displaySavingInFlightRef.current) return;
  displaySavingInFlightRef.current = true; // 描き直しを待たずに、次の呼び出しから見える
  setSettingLocale(true); // 見た目の無効化はstateで続ける
  try {
    await setLocale(next);
  } finally {
    displaySavingInFlightRef.current = false;
    setSettingLocale(false);
  }
}

// handleSelectSourceScope も同じ ref を見て、言語とソース範囲を同時に保存しない

stateは「画面の見た目」を変えるためのもの、refは「処理の途中で今すぐ判定する」ためのもの、と役割を分けた形です。この修正のあとのレビューでは指摘がなくなり、マージしました。ここまで並べたレビュー指摘は、どれもマージ前のPRで出たもので、本番の利用者に起きた不具合ではありません。

IT女子 アラ美
ボタンをグレーにしたから大丈夫、って思っちゃいそう。グレーになる前に押されたら意味ないのね。

ITアライグマ
はい。見た目の無効化は描き直しのあとにしか効かないので、判定にはrefを使います。

まとめ

表示言語を切り替えると入力や絞り込み条件が消えていたのは、ルートの Stack に key={locale} を付けて、言語が変わるたびに全画面を作り直していたからでした。確認ダイアログや保存中の切り替え禁止で個別に塞いでも、作り直しが残る限り、消える状態は他にも残ります。そこで key を外し、文言を出している部品それぞれが useLocale() で言語の変更を受け取って描き直す方式に変えました。FlatList の extraData と useMemo の依存配列にも locale を渡す必要がありました。

作り直しをやめると、それまでリセットで隠れていた問題が2つ見えました。1つは翻訳済みの文字列をstateに入れていたことで、キーと差し込む値だけを持ち、描画時に翻訳する形にそろえました。もう1つは保存中の連打で、見た目の無効化はstateで続けつつ、判定は同期的に書き換わるrefで行うようにしました。

「全部作り直せば確実」という方法は手軽ですが、作り直しが前提になると、状態の持ち方の甘さに気づけなくなります。言語の切り替えに限らず、key を変えて強制的に作り直している箇所は、何を一緒に捨てているのかを一度見直す価値があると、今は考えています。

IT女子 アラ美
リセットで全部なかったことにしてたのが、一番の問題だったのかもね。

ITアライグマ
そう思います。消えない作りにしてみて、初めて状態の持ち方を見直すきっかけになりました。

運営サービス・姉妹メディア

この記事をシェアする
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

ITアライグマのアバター ITアライグマ ITエンジニア / PM

都内で働くPM兼Webエンジニア(既婚・子持ち)です。
AIで作業時間を削って実務をラクにしつつ、市場価値を高めて「高年収・自由な働き方」を手に入れるキャリア戦略を発信しています。

目次