Reactで、表のセルをその場で編集できるようにする方法
表のセルを、ふだんはボタン、編集中は入力欄として切り替えると、表の中でそのまま値を書き換えられます。Enter で保存、Escape で取り消し、入力欄の外をクリックしたときも保存、という動きにします。
ただ、入力欄を出して Enter で保存するだけの書き方では、日本語の変換を確定した Enter で編集が終わったり、保存したあとにフォーカスが行き場をなくしたりします。ここでは、そうした点を直した EditableCell コンポーネントを作り、何も手当てしない書き方と比べます。
検証環境は Ubuntu 24.04、Node.js 22.22.0、Chromium 153 です。使ったのは React 19.3.1、TypeScript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を tsc で型チェックしてから esbuild で変換し、Playwright で Chromium を操作して確かめました。日本語の変換は実際の IME ではなく、Chrome DevTools Protocol(CDP)の命令で再現しています。StrictMode を付けた開発ビルドでも、出力は本番ビルドと同じで、コンソールにエラーや警告は出ませんでした。Firefox と Safari、スクリーンリーダーでの読み上げは試していません。

セルは「表示中」と「編集中」を切り替える
1つのセルを受け持つ EditableCell です。編集中かどうかと、入力欄の中身を、1つの state draft で持ちます。null なら表示中、文字列なら編集中です。
import { useEffect, useId, useRef, useState } from 'react';
type Props = {
value: string;
label: string; // 入力欄の名前(「ミケの年齢」など)
validate?: (draft: string) => string | null; // 問題があればメッセージを返す
onCommit: (draft: string) => void;
};
export function EditableCell({ value, label, validate, onCommit }: Props) {
// null のときは表示中、文字列のときは編集中(入力欄の中身)
const [draft, setDraft] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
const focusCell = useRef(false);
const errorId = useId();
// 編集を終えたあと、Enter や Escape で終えたときだけセルにフォーカスを戻す
useEffect(() => {
if (draft === null && focusCell.current) {
focusCell.current = false;
buttonRef.current?.focus();
}
}, [draft]);
const close = (refocus: boolean) => {
focusCell.current = refocus;
setError(null);
setDraft(null);
};
const save = (refocus: boolean) => {
if (draft === null) return;
const message = validate?.(draft) ?? null;
if (message) {
setError(message);
return;
}
if (draft !== value) onCommit(draft);
close(refocus);
};
if (draft === null) {
return (
<button ref={buttonRef} type="button" className="cell" aria-label={`${label} ${value}`} onClick={() => setDraft(value)}>
{value}
</button>
);
}
return (
<>
<input
autoFocus
value={draft}
aria-label={label}
aria-invalid={error !== null}
aria-describedby={error ? errorId : undefined}
onFocus={(e) => e.currentTarget.select()}
onChange={(e) => {
setDraft(e.target.value);
setError(null);
}}
onKeyDown={(e) => {
// 日本語の変換を確定する Enter では、保存しない
if (e.key === 'Enter' && !e.nativeEvent.isComposing) {
e.preventDefault();
save(true);
} else if (e.key === 'Escape') {
e.preventDefault();
close(true);
}
}}
onBlur={() => save(false)}
/>
{error && <span id={errorId} className="error">{error}</span>}
</>
);
}
表示中のセルを <button> にしたのは、Tab キーで移動でき、Enter で押せるようにするためです。クリックでもキーボードでも、onClick で draft に今の値を入れると、編集中に切り替わります。
EditableCell は、編集中の文字列だけを持ちます。表のデータそのものは持たず、保存するときに onCommit で親に渡します。値が変わっていないときは、onCommit を呼びません。
表のデータは、親で行の配列として持つ
表を描くコンポーネントでは、行の配列を state に持ちます。onCommit で受け取った値で、map を使って該当する行だけを置き換えた新しい配列を作ります。React の公式ドキュメントの、配列の中のオブジェクトを更新する説明にある書き方です。__commits の行は、保存が呼ばれた回数を確かめるために入れたものです。
const validateName = (draft: string) => (draft.trim() === '' ? '名前を入力してください' : null);
const validateAge = (draft: string) => {
const s = draft.normalize('NFKC').trim();
return /^\d+$/.test(s) && Number(s) <= 30 ? null : '0〜30の整数で入力してください';
};
export function CatTable({ naive = false }: { naive?: boolean }) {
const [cats, setCats] = useState(initialCats);
const update = (id: number, patch: Partial<Cat>) => {
// 確かめるために、保存が呼ばれた回数を数える
(window as any).__commits = ((window as any).__commits ?? 0) + 1;
setCats((prev) => prev.map((c) => (c.id === id ? { ...c, ...patch } : c)));
};
1行には、名前と年齢の2つのセルを置きます。セルごとに、値をチェックする関数(validate)と、文字列を行のデータに直す処理(onCommit)を渡します。
<tr key={cat.id}>
<td>
<EditableCell value={cat.name} label={`${cat.name}の名前`} validate={validateName}
onCommit={(v) => update(cat.id, { name: v.trim() })} />
</td>
<td>
<EditableCell value={String(cat.age)} label={`${cat.name}の年齢`} validate={validateAge}
onCommit={(v) => update(cat.id, { age: Number(v.normalize('NFKC').trim()) })} />
</td>
</tr>
Cat 型(id、name、age)と、最初の3行 initialCats の定義は省いています。CatTable の naive は、このあと比べる書き方に切り替えるためのものです。
何も手当てしない書き方で起きること
比べるために、入力欄を出して Enter・Escape・フォーカスが外れたときの処理を書いただけの NaiveCell を用意しました。
import { useState } from 'react';
// 比べるための書き方:変換中の Enter の確認も、フォーカスを戻す処理もない
export function NaiveCell({ value, label, onCommit }: { value: string; label: string; onCommit: (draft: string) => void }) {
const [draft, setDraft] = useState<string | null>(null);
if (draft === null) {
return (
<button type="button" className="cell" aria-label={`${label} ${value}`} onClick={() => setDraft(value)}>
{value}
</button>
);
}
return (
<input
autoFocus
value={draft}
aria-label={label}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
onCommit(draft);
setDraft(null);
} else if (e.key === 'Escape') {
setDraft(null);
}
}}
onBlur={() => {
onCommit(draft);
setDraft(null);
}}
/>
);
}
2つの書き方で、同じ操作をしました。行の中身は「名前 年齢」の順で、[ ] は入力欄とその中身です。「選択 0-1」は、入力欄の中で選ばれている範囲の始まりと終わりの位置(0から数える)です。0-1 なら1文字目が選ばれていて、2-2 のように同じ数なら何も選ばれておらず、カーソルがその位置にあります。まず EditableCell です。
開いた直後: ミケ 3 / クロ 7 / シロ 1 | フォーカス: body | 保存 0回
「ミケの年齢」のセルをクリック: ミケ [3] / クロ 7 / シロ 1 | フォーカス: input「ミケの年齢」 選択 0-1 | 保存 0回
「4」と入力: ミケ [4] / クロ 7 / シロ 1 | フォーカス: input「ミケの年齢」 選択 1-1 | 保存 0回
Enter: ミケ 4 / クロ 7 / シロ 1 | フォーカス: button「ミケの年齢 4」 | 保存 1回
「クロの名前」をクリックして「クロスケ」と入力: ミケ 4 / [クロスケ] 7 / シロ 1 | フォーカス: input「クロの名前」 選択 4-4 | 保存 1回
Escape: ミケ 4 / クロ 7 / シロ 1 | フォーカス: button「クロの名前 クロ」 | 保存 1回
「シロの名前」に「ユキ」と入力して、表の外をクリック: ミケ 4 / クロ 7 / ユキ 1 | フォーカス: body | 保存 2回
Tab で先頭のセルに移り Enter: [ミケ] 4 / クロ 7 / ユキ 1 | フォーカス: input「ミケの名前」 選択 0-2 | 保存 2回
「とら」を「トラ」に変換して Enter で確定(CDP で再現): [トラ] 4 / クロ 7 / ユキ 1 | フォーカス: input「ミケの名前」 選択 2-2 | 保存 2回
もう一度 Enter: トラ 4 / クロ 7 / ユキ 1 | フォーカス: button「トラの名前 トラ」 | 保存 3回
次に NaiveCell です。
開いた直後: ミケ 3 / クロ 7 / シロ 1 | フォーカス: body | 保存 0回
「ミケの年齢」のセルをクリック: ミケ [3] / クロ 7 / シロ 1 | フォーカス: input「ミケの年齢」 選択 1-1 | 保存 0回
「4」と入力: ミケ [34] / クロ 7 / シロ 1 | フォーカス: input「ミケの年齢」 選択 2-2 | 保存 0回
Enter: ミケ 34 / クロ 7 / シロ 1 | フォーカス: body | 保存 1回
「クロの名前」をクリックして「クロスケ」と入力: ミケ 34 / [クロクロスケ] 7 / シロ 1 | フォーカス: input「クロの名前」 選択 6-6 | 保存 1回
Escape: ミケ 34 / クロ 7 / シロ 1 | フォーカス: body | 保存 1回
「シロの名前」に「ユキ」と入力して、表の外をクリック: ミケ 34 / クロ 7 / シロユキ 1 | フォーカス: body | 保存 2回
Tab で先頭のセルに移り Enter: [ミケ] 34 / クロ 7 / シロユキ 1 | フォーカス: input「ミケの名前」 選択 2-2 | 保存 2回
「とら」を「トラ」に変換して Enter で確定(CDP で再現): トラ 34 / クロ 7 / シロユキ 1 | フォーカス: body | 保存 3回
もう一度 Enter: トラ 34 / クロ 7 / シロユキ 1 | フォーカス: body | 保存 3回
違いは3つあります。
入力欄を出したら、中の文字を全部選んでおく
NaiveCell では、入力欄を出したときにカーソルが文字の後ろにあり、「4」と打つと「34」になりました。値を書き換えたい場面では、毎回いまの値を消す手間がかかります。
EditableCell は、入力欄にフォーカスが入ったときに select() を呼びます。MDN の select() の説明のとおり、入力欄の文字が全部選ばれた状態になるので、打った文字でそのまま置き換わります。
Enter と Escape のあとは、フォーカスをセルに戻す
NaiveCell では、Enter や Escape で入力欄が消えると、フォーカスが body に移りました。どのセルにもフォーカスがない状態になり、キーボードで操作している人には、いまどのセルにいるのかが画面から分からなくなります。
EditableCell では、Enter や Escape で編集を終えたとき、focusCell を true にしておき、表示中に戻ったあとの useEffect でボタンにフォーカスを移します。ボタンは入力欄と入れ替わりで新しく作られるので、編集を終える処理の中ではまだフォーカスを移せません。描画が終わってから useEffect で移します。
表の外をクリックして編集を終えたときは、フォーカスを戻しません。クリックした先にフォーカスを移したい操作なので、セルに引き戻すと邪魔になります。
変換を確定する Enter では保存しない
日本語の入力では、変換を確定するときにも Enter を押します。NaiveCell では、「とら」を「トラ」に変換して確定した Enter で、編集が終わってしまいました。「トラ」に続けて文字を打つことができません。
MDN の isComposing の説明によると、キーボードのイベントの isComposing は、変換の始まり(compositionstart)から終わり(compositionend)までの間に起きたイベントかどうかを表します。EditableCell では、React のイベントの nativeEvent(ブラウザの元のイベント)にある isComposing が true の Enter を無視するので、確定のための Enter では保存せず、もう一度 Enter を押したときに保存されました。
間違った値のときは、編集を続けさせる
validate がメッセージを返したときは、保存せずに入力欄のまま残します。入力欄には aria-invalid を付け、メッセージを aria-describedby で結びつけます。MDN の aria-invalid の説明には、入力チェックを自分で書くときは、aria-invalid を付けるだけでなく、どこが間違いかをメッセージで伝えるよう書かれています。MDN はメッセージを結びつける属性として aria-errormessage を挙げていますが、ここでは aria-describedby を使いました。
「ミケの年齢」に「abc」と入力して Enter: ミケ [abc](aria-invalid)「0〜30の整数で入力してください」 / クロ 7 / シロ 1 | フォーカス: input「ミケの年齢」 選択 3-3 | 保存 0回
Backspace: ミケ [ab] / クロ 7 / シロ 1 | フォーカス: input「ミケの年齢」 選択 2-2 | 保存 0回
「31」に打ち直して Enter: ミケ [31](aria-invalid)「0〜30の整数で入力してください」 / クロ 7 / シロ 1 | フォーカス: input「ミケの年齢」 選択 2-2 | 保存 0回
表の外をクリック: ミケ [31](aria-invalid)「0〜30の整数で入力してください」 / クロ 7 / シロ 1 | フォーカス: body | 保存 0回
入力欄に戻り、全角の「5」に打ち直して Enter: ミケ 5 / クロ 7 / シロ 1 | フォーカス: button「ミケの年齢 5」 | 保存 1回
「クロの名前」を空にして Enter: ミケ 5 / [](aria-invalid)「名前を入力してください」 7 / シロ 1 | フォーカス: input「クロの名前」 選択 0-0 | 保存 1回
Escape: ミケ 5 / クロ 7 / シロ 1 | フォーカス: button「クロの名前 クロ」 | 保存 1回
文字を打ち直すと、onChange でメッセージを消します。表の外をクリックしたときも、間違った値のままなら保存せず、入力欄とメッセージを残しました。取りやめたいときは、Escape で元の値に戻せます。
年齢は、normalize('NFKC') で全角の数字を半角に直してからチェックしています。全角の数字で入力されても、受け付けられるようにするためです。validateAge にいくつかの値を渡した結果は、次のとおりです。
"5" → "5" OK
"5" → "5" OK
" 12 " → "12" OK
"12" → "12" OK
"30" → "30" OK
"31" → "31" 0〜30の整数で入力してください
"-1" → "-1" 0〜30の整数で入力してください
"2.5" → "2.5" 0〜30の整数で入力してください
"abc" → "abc" 0〜30の整数で入力してください
"" → "" 0〜30の整数で入力してください
Escape で消した入力欄の onBlur は呼ばれなかった
EditableCell では、入力欄の onBlur でも保存します。そうすると、Escape で入力欄を消したときに、消える入力欄からフォーカスが外れて onBlur が呼ばれ、取り消したはずの値が保存されるのではないか、という心配があります。
そこで、Escape を押す前に、入力欄に blur と focusout のイベントを数える処理を直接付けて確かめました。1行目が EditableCell、?naive の行が NaiveCell の結果です。
EditableCell: 入力欄に届いたイベント ["blur","focusout"] / 入力欄の数 0 / 保存 0回
?naive: 入力欄に届いたイベント ["blur","focusout"] / 入力欄の数 0 / 保存 0回
Chromium 153 では、入力欄が消えるときに blur と focusout が起きていました。それでも、保存の回数は0回のままでした。NaiveCell の onBlur は必ず onCommit を呼ぶので、消えた入力欄の onBlur は、React 19.3.1 から呼ばれていないことになります。
EditableCell は、この動きを前提にしています。もし消えた入力欄の onBlur が呼ばれる環境があれば、Escape で取り消した値が保存されてしまいます。この環境では、Escape で取り消した値は保存されず、Enter で保存したときも保存の回数は1回ずつしか増えませんでした。





