定義
日本語で「こんにちは」と入力して別の場所をクリックしたら「こんにち」だけが保存されていた — そんな経験があるならこの概念が必要だ。
コミット境界 とは、編集中の値を最終確定として保存する瞬間のことだ。日本語・韓国語・中国語には、複数のキー入力が集まって一文字になる 変換(composition) の区間がある。その区間が終わる前に blur や Enter がコミットを実行すると、画面に見えている値と保存された値がずれる。これは 値を整形して直す問題ではなく、確定の瞬間を後ろへずらして直す問題 だ。
なぜ必要か
英字入力だけを想定して書かれた編集 UI は、慣習的に onBlur で保存し Enter で確定する。日本語ではそれらのイベントが、最後の一文字がまだ変換中のうちに届きうる。値は静かに誤って保存されるので、利用者はずっと後になって気づく。
さらに厄介なのは、compositionend・keydown・blur の到着順序がブラウザや IME によって異なる ことだ。ある環境の順序を仮定して直すと、別の環境でそのまま再発する。だから判断は順序ではなく「いま変換中か」という 状態 に基づかなければならない。
動作原理
- 日本語入力が始まるとブラウザが
compositionstartを発火し、未確定文字を表示する。 - 変換中に発火するキーイベントは
event.isComposing === trueを持つ。 - この区間で blur が起きたら、ハンドラは保存せず「コミット保留」フラグを立てるだけにする。
- 変換が終わると
compositionendが発火し、値が確定する。 compositionendのハンドラが保留フラグを見て、実際の保存を行う。
| イベント | 変換中の扱い | 理由 |
|---|---|---|
keydown(Enter) | 無視する | フィールドの確定ではなく変換の確定かもしれない |
blur | 保留にして遅延 | 値がまだ未完成 |
compositionend | 保留があればコミット | これが本当の境界 |
change / input | ローカル状態の更新のみ | サーバーへ送るのはコミット境界だけ |
実務適用
function EditableField({ initial, onCommit }: Props) {
const composingRef = useRef(false); // フックは条件付き早期リターンより前に呼ぶ
const pendingBlurRef = useRef(false);
const [value, setValue] = useState(initial);
const commit = () => onCommit(value);
return (
<textarea
value={value}
onChange={(e) => setValue(e.target.value)}
onCompositionStart={() => {
composingRef.current = true;
}}
onCompositionEnd={() => {
composingRef.current = false;
if (pendingBlurRef.current) {
pendingBlurRef.current = false;
commit();
}
}}
onKeyDown={(e) => {
if (e.nativeEvent.isComposing) return; // 変換中の Enter は確定ではない
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
commit();
}
}}
onBlur={() => {
if (composingRef.current) {
pendingBlurRef.current = true; // 変換終了まで遅延
return;
}
commit();
}}
/>
);
}
回帰テストは、変換中の blur のあと一定時間 保存リクエストが飛ばないこと を検証する。値を比較するのではなくリクエストを見るのが要点だ。
トレードオフ
- ネイティブ
textarea+ コミット境界: コード量が少なく、IME の挙動はブラウザ既定そのままになる。代わりにリッチな書式やインラインウィジェットは諦める。 - リッチテキストエディタの導入: 書式は得られるが IME 問題は消えない。変換セッションがエディタの再レンダーと衝突し、バンドルも増える。
- コミットの遅延: 保存が数十〜数百ミリ秒遅れる。体感できない差だが、保留中にコンポーネントがアンマウントされると保存が失われるので、アンマウント経路でもフラグを確認する必要がある。
使ってはいけない場合
- 数値・日付・列挙など、IME が関与しない入力。変換状態の追跡は不要な複雑さになる。
- 未確定の文字をそのまま送信し続けるリアルタイム共同編集。これはコミットを遅らせる設計ではなく、テキストを暫定として扱う設計が必要だ。
よくある間違い
- 文字列の後処理でごまかす。 「文字がおかしいから重複を除去しよう」は、利用者が本当に同じ文字を二回打った入力まで壊す。境界の問題を値の問題と誤診している。
keyCode === 229に頼る。 ブラウザごとに異なる旧来の回避策だ。isComposingと composition イベントを使う。- イベント順序を仮定する。 「blur のあとに必ず compositionend が来る」は環境依存だ。状態で判断する。
- 条件付き早期リターンの後でフックを呼ぶ。 レンダー間でフックの順序が変わり状態が絡まる。
- jsdom のユニットテストだけで検証する。 変換のライフサイクルを忠実には再現しないので、実ブラウザでの E2E が必要だ。
関連概念
- ime-composition-contenteditable — リッチテキストエディタ内で壊れる変換セッション
- contenteditable-keyboard-history-delegation — キーイベントと編集履歴の境界
- race-safe-async-ui-requests — 遅延コミットとリクエストの競合