非対称な状態間の同期の実装メモ

状態間の同期は一般的な話題ではあるのですが, 例として React の制御コンポーネントの実装を題材にします. ここで非対称な状態とは, 各状態のとり得る値の集合の間に一対一の対応がないことを指しています. というか一対一に対応するのであればふつうは状態を分けなくてよろしい.

まずはおさらいとして, 基本的な制御コンポーネントを見てみましょう (sandbox).

const MyTextInput: React.VFC<{
  text: string;
  onChange: (text: string) => void;
}> = ({ text, onChange }) => (
  <input
    type="text"
    value={text}
    onChange={(e) => {
      onChange(e.target.value);
    }}
  />
);

この場合はユーザーが入力するデータと, このコンポーネント外部で状態として管理されるデータ (text) がともに string なので, 特に変わったことはありません.

ではユーザー入力と状態でデータの種類が異なるような場合はどうでしょうか. ここでは例として, ユーザー入力が string, 状態は Date であるような時刻入力コンポーネントを考えてみます (sandbox).

function stringify(date: Date): string {
  try {
    return date.toISOString();
  } catch {
    return "";
  }
}

function parse(text: string): Date {
  if (text === "") {
    return new Date(NaN);
  } else {
    return new Date(text);
  }
}

const MyDateInput: React.VFC<{
  date: Date;
  onChange: (date: Date) => void;
}> = ({ date, onChange }) => (
  <input
    type="text"
    value={stringify(date)}
    onChange={(e) => {
      onChange(parse(e.target.value));
    }}
  />
);

これは動作確認してみるとすぐにわかりますが, まともにテキスト入力することができず, ほぼ役に立ちません. よくある実装ミスです.

この例の実装では, 状態として管理されているのは date: Date のみです. 一方で,

  • 不正な時刻を表す Date は 1 種類だが, 時刻の表現として不正な string は無数に存在する
  • 同じ時刻を表す Date は 1 種類だが, 同じ時刻を表す string"2006-01-02T15:03:04.999Z""2006-01-03T00:03:04.999+0900" など, いくつも存在する

といったように, Datestring の値は一対一に対応せず, Date の値の種類よりも string の値の種類の方が多くなっています. このため, 例えばユーザーの入力途中の時刻として不正な string などは保持することができず, 入力に支障をきたしてしまいます.

ということで, 少なくとも date: Date とは別に, ユーザー入力の string も状態として管理する必要があります. 実際にやってみましょうs (sandbox).

const MyDateInput: React.VFC<{
  date: Date;
  onChange: (date: Date) => void;
}> = ({ date, onChange }) => {
  // string で入力データを保持するための状態
  const [text, setText] = useState(() => stringify(date));

  const textRef = useRef(text);
  useEffect(() => {
    if (text !== textRef.current) {
      // text が変更されたら通知 (date に反映)
      onChange(parse(text));
    }
    textRef.current = text;
  }, [text, onChange]);

  return (
    <input
      type="text"
      value={text}
      onChange={(e) => {
        setText(e.target.value);
      }}
    />
  );
};

一見うまく動くように見えるのですが, これでは非制御コンポーネントとなってしまっているので, 当初の制御コンポーネントを作るという要件を満たしていません. 例えば date が変更された場合, その変更はこのコンポーネントには反映されません. よくある実装ミスその 2 です.

では date が変更されたときに, コンポーネント内部の状態に対して反映が行われるようにしてみましょう (sandbox).

const MyDateInput: React.VFC<{
  date: Date;
  onChange: (date: Date) => void;
}> = ({ date, onChange }) => {
  // string で入力データを保持するための状態
  const [text, setText] = useState(() => stringify(date));

  const textRef = useRef(text);
  const dateRef = useRef(date);
  useEffect(() => {
    if (text !== textRef.current) {
      // text が変更されたら通知 (date に反映)
      onChange(parse(text));
    } else if (date !== dateRef.current) {
      // date が変更されたら text に反映
      setText(stringify(date));
    }
    textRef.current = text;
    dateRef.current = date;
  }, [text, date, onChange]);

  return (
    <input
      type="text"
      value={text}
      onChange={(e) => {
        setText(e.target.value);
      }}
    />
  );
};

ところがこれでは最初と同じように, まともにテキスト入力できないようになってしまいました. 複雑になった分より悪くなったといえます.

ここで残っている問題は date が変更されたときに, 無条件で text を更新していることです. 先程見たように, Datestring に比べて値の種類の数が少ないので,

  1. ユーザー入力によって text が更新され, date に反映
  2. これによって date が更新されたので text に反映

と相互に反映を行う過程で, Date で表現できない text の違いが潰されて, 結果としてユーザー入力が破壊されるような結果となってしまうのです.

これを解消するためには, 2 での text への反映に条件をつけて, すでに datetext が同じ時刻 (不正な場合を含む) を表しているような場合はスキップするようにしてやればよいです (sandbox).

function equal(a: Date, b: Date): boolean {
  return Object.is(a.getTime(), b.getTime());
}

const MyDateInput: React.VFC<{
  date: Date;
  onChange: (date: Date) => void;
}> = ({ date, onChange }) => {
  // string で入力データを保持するための状態
  const [text, setText] = useState(() => stringify(date));

  const textRef = useRef(text);
  const dateRef = useRef(date);
  useEffect(() => {
    if (text !== textRef.current) {
      // text が変更されたら通知 (date に反映)
      onChange(parse(text));
    } else if (date !== dateRef.current) {
      // date が変更されたら text に反映
      // ただし text にすでに date が反映されているとみなせる場合は無視
      if (!equal(date, parse(text))) {
        setText(stringify(date));
      }
    }
    textRef.current = text;
    dateRef.current = date;
  }, [text, date, onChange]);

  return (
    <input
      type="text"
      value={text}
      onChange={(e) => {
        setText(e.target.value);
      }}
    />
  );
};

こうすることでユーザー入力は破壊されず, かつ制御コンポーネントとして適切な振る舞いをするようになりました. めでたしめでたし.

2021-11-06 追記 このパターンを簡単に実装するためのライブラリを作ったのでよろしければどうぞ. github.com


最初に書いたとおり状態間の同期は一般的な話題なので, もっと抽象的に議論することもできるはず. 私は自分が考えている問題にそのまま適用できるように一般化していたら話題が散らかってきたのと, 数式や図が書きづらい環境で厳密に書くのが面倒になってきたので今回はここまで. 先行研究はいくらでもありそうなので, もし参考になりそうな文献をご存知であれば教えて下さい.

あと注意したいのは, 状態を複数持って同期するのは大抵アンチパターンです (React に限らず, GUI アプリケーション全般においても). 必要かどうか十分考察した上で, 本当に必要な場面でのみ使いましょう.

コード中の定数の greppability と TypeScript の進化

TL; DR

コード中の定数の greppability

以下のように翻訳の ID を引数にとって, 各言語に翻訳されたメッセージを返す関数というのはよくあるのではないかと思います.

declare function translate(id: string): string;

そして (形式は何でも良いのですが) 各言語に対応した翻訳が書かれているファイルを, (方法は何でも良いのですが) translate が読み込んで使うとしましょう.

{
  "direction": "方向",
  "direction.left": "",
  "direction.right": ""
}

こうして画面上に各言語に対応したメッセージが表示できました. よかったですね.

const Direction: FC<{ dir: "left" | "right" }> = ({ dir }) => (
  <p>{translate("direction")}: {translate(`direction.${dir}`)}</p>
);

ところでソフトウェアは生き物なので, 生きている限りは画面上の表示も変更されたりして, 使わなくなった翻訳というのが往々にして発生します. こういったものが積もっていくと資源の無駄なので, 必要がなくなったら削除していきたいはずです.

ということで, 例えば direction.left という ID の翻訳が現在使われているか調べてみましょう. 最も素朴な方法はこの ID でソースコードを検索 (grep) することですが, 検索してみたところソースコード中には direction.left という文字列は見つかりませんでした. どうやら使われていないようなので削除しましょう!

...

となっては困りますね. とはいえ単純な grep で見つけられないとなると自動化できませんし, 人間が探すにしても空気を読む必要があって大変です.

ここで問題となっているのは translate(`direction.${dir}`) のように動的に ID 文字列を生成している部分です. もし greppability (grep 可能性) を確保したいのであれば, ここを translate(dir === "left" ? "direction.left" : "direction.right") のようにリテラルで与えるようにする必要があります.

このような greppability を保証するために translate の型定義を以下のように変更してみます.

declare function translate<T extends string>(id: StringLiteral<T>): string;

type StringLiteral<T extends string> = string extends T ? never : T;

ここで StringLiteral<T>Tstring の場合のみ never となり, string literal type やその union type であれば T 自体となる型です. こうして作られた translate は, 引数に漠然とした string は受け付けず, 必ず string literal type またはその union type といった, より具体的なものを受け取るようになります.

なぜこれで greppability を保証できるかというと, 動的に ID を生成すると型が曖昧になる = string 型になるということを利用しています.

// OK
translate("direction.left"); 

declare const dir: "left" | "right";
// `direction.${dir}` の型は string なのでエラー
translate(`direction.${dir}`);

よかったですね.

ここまでが一年前に考えていた話.

TypeScript の進化

さて TypeScript の進化はここ一年もめざましく, 先日 beta 版がリリースされた TypeScript 4.2 からは template literal expression に対して template literal type が付与されるようです.

どういうことかというと, こういうことです.

declare const dir: "left" | "right";
// `direction.${dir}` の型は `"direction.left" | "direction.right"` なので OK
translate(`direction.${dir}`); 

終了. 正直 TypeScript 4.1 の template literal types の導入の時点でこうなる予感はしてました.

ということで TypeScript の型を使った方法はもう未来がないので, せっかく紹介したところですが使わないでください. 他の方法, たとえば ESLint で no-restricted-syntax を使って雑に検査するのであれば以下のような感じになるかと思います.

{
  "rules": {
    "no-restricted-syntax": [
      "error",
      {
        "selector": "CallExpression[callee.type='Identifier'][callee.name='translate'][arguments.length>0][arguments.0.type!='Literal']",
        "message": "Translation ID must be given as a literal expression."
      }
    ]
  }
}

良いニュースと悪いニュースがある, みたいな話でした. こちらからは以上です.

2020-02-13 追記

TypeScript 4.2 RC で上記の変更は一部 revert されました. とはいえ as const をつけることで beta と同様のより詳細な型の推論は行われるので, やはり型推論が不可能であるという前提に立った方法は不適当であることは変わらなそうです.