Zenn
🌏

【React+i18next】<Trans> と ADR で実現する安全なi18n運用対応

に公開

はじめに

Dress Code株式会社で「DRESS CODE」を開発しているふるしょうです。
「DRESS CODE」は2025/04/02時点で「日本語・英語・インドネシア語・ベトナム語」に対応しており、続々と対応言語・地域を拡大しています。
創業したばかりのスタートアップが、i18n対応に取り組む過程で生じた「文字化け」と「安全にi18n運用するための取り組み」について今回ご紹介いたします!

TL:DR

  • 問題
    • i18next のデフォルトのエスケープ機能 (escapeValue: true) により、ユーザー入力値の ' などが &#39; に変換され、UI上で文字化けが発生した
  • 課題
    • 文字化けを防ぐために escapeValue: false にすると、XSS脆弱性のリスクが生じる
  • 解決策
    • react-i18next<Trans> コンポーネントと shouldUnescape={true} オプションを利用する
    • このオプションは i18next によるHTMLエンティティ化 (例: ' -> &#39;) のみを抑制し、文字化けを防ぐ
  • 安全性
    • <Trans>コンポーネントは、values プロパティ経由で渡された変数をReact要素の子要素として安全にレンダリングする
    • React自身のJSXレンダリング機構がXSSを防ぐため、shouldUnescape={true} を使用しても安全性が損なわれない
  • 採用した方法
    • ユーザー入力などの信頼できない値を表示し、かつ文字化けを防ぎたい場合に限定して、<Trans shouldUnescape={true}> をラップしたカスタムコンポーネントを使用
    • それ以外はデフォルトのエスケープ (escapeValue: true) を維持して多言語テキストを表示

文字化けは突然に

先日、CSチームから開発チームへ連絡がありました。
「お客様が入力したデバイス名 MacBook Air 13' が、画面上では MacBook Air 13&#39; と表示されている」という一報です。


再現したダイアログUI

該当箇所では、i18nextt関数を使用していたので、i18nextがデフォルトで行うHTMLエスケープ処理が原因であることは明らかでした。

DRESS CODEでは、lib/i18nという共通基盤の上でi18nextreact-i18next などのadapter, コアロジック, 辞書管理などを集約してReactアプリケーション内でi18n対応(多言語対応, 日時フォーマット...etc)をしています。(このアーキテクチャについてもいずれ公開したい...!)

この共通基盤では、セキュリティ観点からi18nextの設定はデフォルトのescapeValue: trueを採用しています。
https://www.i18next.com/translation-function/interpolation#all-interpolation-options

今回直面した課題は「セキュリティ(XSS防止のエスケープ)」と「表示の正確性」のトレードオフにあります。

  1. セキュリティの確保: ユーザー入力などの外部入力されたデータソースからのXSS脆弱性をリスクを生まないこと。i18nextのデフォルトの保護機能を可能な限り維持したい。
  2. 表示の正確性: ユーザーが入力した通りの文字列 (MacBook Air 13') を画面に表示すること。

調査と検討

Step1:選択肢の洗い出しと初期評価

Slackに対応スレッドを立てて、開発チーム内でブレインストーミングを行い、4つのアプローチを検討しました。

  1. t 関数で escapeValue: false
    • 各所で個別対応 → 手軽だが対応漏れが生じやすく、管理不能になりそう
  2. 翻訳キーで {{-variable}}
    • JSONで制御 → 一見良さそうだが、結局1と同じリスク。
  3. グローバル設定で無効化
    • i18next.init() で対応 → 絶対NG。セキュリティ全放棄。
  4. <Trans>+ shouldUnescape
    • prop: Reactコンポーネントで対応 → 安全性の確認が必要だが、最も有望か?

ReactのJSXはデフォルトで文字列をエスケープするため、1の「escapeValue: false を一時的に使うのはどうか?」という意見が出ましたが、escapeValue: falsei18next 内部での保護をなくすため、潜在的なリスクを高めることになります。
もしその値が dangerouslySetInnerHTMLのような React のエスケープ機構をバイパスする方法で使用された場合、または将来的にHTMLを直接レンダリングするような機能が追加された場合」に、XSSのリスクが生じるため、デフォルトの多層防御を無効にすることのリスクは許容しがたいため、不採用としました。3のグローバル設定変更は言うまでもありません。
(※ブレインストーミングなのでこういった選択肢のPros/Consをオープンにディスカッションできる風土は大事にしていきたい!)

Step2: <Trans>shouldUnescape の調査 - ドキュメントからコードへ

公式ドキュメントを読むだけでは、shouldUnescape={true} が本当に安全なのか、確信が持てませんでした。「unescape」という言葉自体が、どうしてもセキュリティ的な懸念を想起させたからです。
そこで、<Trans>コンポーネントの実装を読んで評価することにしました。以下はコードリーディングした内容です。

<Trans>レンダリング処理の流れ

アプリケーションで <Trans> を使用すると、react-i18next/src/Trans.js が呼び出されます。

https://github.com/i18next/react-i18next/blob/4cbe54d5eba98c57608017f63ff7ed41e2c9a5db/src/Trans.js

このコンポーネントは主に React Context を介して i18next インスタンスや設定を取得し、コアロジックを責務とする TransWithoutContext コンポーネントに必要な props を渡す役割を担っています。

そのため、TransWithoutContext が、 <Trans> の主要なレンダリング処理を実装しています。
https://github.com/i18next/react-i18next/blob/4cbe54d5eba98c57608017f63ff7ed41e2c9a5db/src/TransWithoutContext.js

TransWithoutContext のレンダリング処理の流れ

  1. 翻訳の取得
    まず props から i18nKey, values, count, components などを取得し、i18next.t を呼び出して、キーに対応する翻訳文字列(例: "説明: <bold>{{deviceName}}</bold>")を取得します。この際、t 関数は i18next の設定(escapeValue など)に従って変数を補間 (interpolate) します。

  2. AST (Abstract Syntax Tree) の構築
    取得した翻訳文字列と、<Trans> の子要素として渡されたReactコンポーネント(childrencomponents prop)を解析します。内部でhtml-parse-stringify のようなライブラリを使用し、翻訳文字列中のHTML風タグ (<0>, <bold> など) や変数 ({{var}}) を認識し、それらをReact要素と対応付けるためのASTを構築します。

  3. React要素への変換
    (renderNodes / mapAST): 構築されたASTを走査し、最終的なReact要素のツリーに変換します。この処理の中心が renderNodes 関数と、それが内部で呼び出す mapAST 関数です。
    以下のコード部分がASTのテキストノード (node.content) を処理する際に、shouldUnescape propが true かどうかをチェックし、trueの場合、 interpolateによって変数が埋め込まれた後のテキストに対して、さらに i18nOptions.unescape関数(通常はHTMLエンティティを元に戻す処理)を適用します。これにより、i18next.tescapeValue: true (デフォルト) で生成した &#39; などが ' に戻されます。

        // TransWithoutContext.js
        } else if (node.type === 'text') {
            const wrapTextNodes = i18nOptions.transWrapTextNodes;
            // --- ここが重要 ---
            const content = shouldUnescape
            ? i18nOptions.unescape( // unescape関数を適用 (例: &#39; -> ')
                i18n.services.interpolator.interpolate(node.content, opts, i18n.language),
                )
            : i18n.services.interpolator.interpolate(node.content, opts, i18n.language);
            // --- ここまで ---
            if (wrapTextNodes) {
            mem.push(createElement(wrapTextNodes, { key: \`${node.name}-${i}\` }, content));
            } else {
            mem.push(content); // contentをReact要素配列に追加
            }
        }
    
  4. 最終的なレンダリング
    renderNodes が返したReact要素の配列を、指定された親要素 (parent prop、デフォルトは div または Fragment) でラップしてレンダリングします。

上記のコードを読み解いた結果、以下を理解したことにより、<Trans shouldUnescape={true}> は、文字化けを防ぎつつ、React自身のXSS防御機構によって安全性が担保される、という確信を得ることができました。

  • shouldUnescape の役割: これは i18next が行ったHTMLエンティティ化 (' → &#39;) を元に戻す(デコードする)だけの役割。
  • 変数 (values) の扱い: <Trans> は values で渡された値を、直接HTMLとして解釈するのではなく、指定されたReactコンポーネントの子要素として渡す。
  • 最終防衛ライン: 実際に変数の値をDOMにレンダリングするのは React自身。ReactはJSXの {} 内の文字列をデフォルトでテキストとして扱い、自動的にエスケープしてくれる。

Step3:ADRによる意思決定

調査結果と技術的な確証を元に、ADR (Architecture Decision Record) を作成し、チームで最終的な合意形成を行いました。
弊社でのADRの取り組みについては、先日 かわうそさんが公開した創業期のスタートアップに入社した5ヶ月をふりかえるにも言及されていますので、ご興味持っていた抱けた方はぜひこちらも読んでいただけると幸いです!

https://zenn.dev/dress_code/articles/8f296deac5486e#何をしたのか-2

  • 今回のADRのポイント
    • 採用
      • <Trans> + shouldUnescape=true アプローチ
    • 根拠
      • 安全性 (Reactによる保護) と文字化け解消の両立
      • 保守性(利用箇所の特定容易性(grep容易性大事))
    • 他案の却下理由
      • XSSリスク、保守性の低さ
    • 運用
      • 専用のラッパーコンポーネント (UnescapedTrans) を作成し、lib/i18n 内で管理・提供
      • 利用ガイドラインを設ける

ADRによって、なぜこの決定に至ったのか、どのようなリスクを考慮したのかが明確に記録され、将来のメンバーにも判断の経緯が伝わるようになりました。

Step4: 解決策:<Trans> + shouldUnescape + 運用ルール

最終的に私たちが採用し、lib/i18n に組み込んだプラクティスは以下の通りです。

  1. ラッパーコンポーネント UnescapedTrans の導入
    以下のシンプルなコンポーネントを作成しました。
import { Trans, type TransProps } from "react-i18next";

/**
 * Unescapeが必要なユーザー入力を翻訳・補間するとき、明示的にshouldUnescapeをtrueにするコンポーネントです
 * Please see 【ADR】i18nにおけるエスケープ対応方針(文字化け防止)【2025-03】
 */
export const UnescapedTrans = (props: TransProps<any>) => {
  return <Trans {...props} shouldUnescape={true} />;
};

このコンポーネントの役割は、単なるショートカットではなく、意図の明示と利用箇所の限定です。「これは特別なケースで使っている」ということをコード上で示すシグナルとなります。

  1. 利用ガイドラインの策定と共有
    lib/i18n のドキュメントに、以下のようなルールを明記しました。
  • 原則: 翻訳にはuseTranslationフックのt関数、または通常の<Trans>を使用する
  • UnescapedTrans を使う時
    • 以下2つの条件を満たす場合に限定して UnescapedTrans を使用する
      • values に渡す値がユーザー入力や外部APIなど、信頼できない情報源である
      • その値に 'などが含まれ、文字化けが発生し、それを解消する必要がある
  • 使わない時
    • 静的な翻訳、values が信頼できる値、文字化けが許容できる、など上記条件を満たさない場合
  1. 実装例
    当初問題となったダイアログのコードは以下のように修正することで、文字化けを解消しました
import { UnescapedTrans } from '@/lib/i18n/components/UserInputTrans'; // lib/i18n から import

function DeviceDialog({ deviceName }) { // deviceName = "MacBook Air 13'"
  // const { t } = useTranslation... は不要になる場合も

  return (
    <Dialog>
      <DialogTitle>
        <UnescapedTrans
          i18nKey="deviceDialog.title" // "デバイス: {{name}}"
          values={{ name: deviceName }}
        />
        {/* 出力: デバイス: MacBook Air 13' */}
      </DialogTitle>
      {/* ... */}
    </Dialog>
  );
}

まとめ

今回のCSからの報告をきっかけとした一連の対応は、単なる不具合修正以上の価値がありました!

得られた教訓

  • 現場の声の重要性
    • CSチームからの報告が、潜在的なUXの問題と、i18n戦略を見直すきっかけを与えてくれました
  • 技術選定における多角的な評価
    • 安全性、保守性、開発効率など、複数の軸で選択肢を評価し、議論することの重要性を再認識しました
  • ドキュメントとソースコードの価値
    • 公式ドキュメントだけでは判断がつかない場合、ソースコードまで読み解くことで、技術的な確信を得て、自信を持った意思決定ができます)
  • プロセスと標準化の力
    • ADRによる意思決定の記録、ラッパーコンポーネントやガイドラインによる標準化が、チーム全体の開発品質と効率を高められます
  • セキュリティ意識の維持
    • セキュリティリスクを常に意識し、デフォルトのセキュリティ設定を尊重することの重要性を学びました

終わりに

今回の文字化け対応は、i18n対応に取り組む中で発生した課題の一つです。
また、今回の一連の対応工数は一時間程度で取り組んでおり、スタートアップならではのスピード感で機能開発に取り組みながら、課題解決をした事例でした。
プロダクトの価値と安全性を両立していくために、ADRを含む開発文化の醸成や開発組織の拡大に取り組んでいます。
Day0からグローバルにBtoB SaaSを開発する上でi18n対応で取り組んできた工夫や課題についても今後発信していきます!(決意表明)

GitHubで編集を提案
DRESS CODE TECH BLOG

Discussion

ログインするとコメントできます