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

状態間の同期は一般的な話題ではあるのですが, 例として 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 アプリケーション全般においても). 必要かどうか十分考察した上で, 本当に必要な場面でのみ使いましょう.