「追加を押したら addMode
が true のはずなのに、なぜか false に戻ってる…」
この“あるある”の正体は、だいたい useMemo の依存配列と列定義(columns)の中で読む stateにあります。この記事では、まず用語をサクッと整理し、その後「どこで何を入れればハマらないか」をシンプルなサンプルコードで解説します。
useMemoとは?——“計算結果”を覚えておくメモ帳
useMemo
は「依存値が変わった時だけ再計算して、それ以外は前回の結果を再利用」するフックです。
- 毎回新しいオブジェクト/配列を作ると子コンポーネントが無駄に再レンダリングしがち →
useMemo
で安定化 - ただし!**依存配列に入れてない値は“古いまま”**になります(=stale closure)
ダメな例(古い値を見続ける)
const [count, setCount] = useState(0);
// ❌ countを中で使っているのに、依存配列が []
const expensive = useMemo(() => {
console.log('計算:count =', count); // ← ここがずっと古いcountを見ることに
return count * 2;
}, []); // ← 依存が空
<button onClick={() => setCount(c => c + 1)}>+1</button>
良い例(常に最新の値を使う)
const [count, setCount] = useState(0);
// ✅ 依存に count を入れる
const expensive = useMemo(() => {
console.log('計算:count =', count);
return count * 2;
}, [count]);
columnsとは?——テーブルの“設計図”配列
@tanstack/react-table
などで使う 列定義の配列が columns
。
1列ごとに「ヘッダはこう表示」「セルはこう描画」「ソートできる?」などの振る舞いを書きます。
いちばんシンプルな例(Todo一覧)
import React, { useMemo, useState } from 'react';
import { useReactTable, getCoreRowModel, flexRender, createColumnHelper } from '@tanstack/react-table';
type Todo = { id: number; title: string; done: boolean };
export default function TodoTable() {
const [todos] = useState<Todo[]>([
{ id: 1, title: '牛乳を買う', done: false },
{ id: 2, title: '本を読む', done: true },
]);
const columnHelper = createColumnHelper<Todo>();
// ✅ columnsはuseMemoで安定化(今回はstate参照がないので依存配列は[]でOK)
const columns = useMemo(() => [
columnHelper.accessor('title', {
header: 'タイトル',
cell: info => info.getValue(),
}),
columnHelper.accessor('done', {
header: '完了?',
cell: info => (info.getValue() ? 'はい' : 'いいえ'),
}),
], []);
const table = useReactTable({
data: todos,
columns,
getCoreRowModel: getCoreRowModel(),
});
return (
<table>
<thead>
{table.getHeaderGroups().map(hg => (
<tr key={hg.id}>
{hg.headers.map(h => (
<th key={h.id}>{flexRender(h.column.columnDef.header, h.getContext())}</th>
))}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map(r => (
<tr key={r.id}>
{r.getVisibleCells().map(c => (
<td key={c.id}>{flexRender(c.column.columnDef.cell, c.getContext())}</td>
))}
</tr>
))}
</tbody>
</table>
);
}
ポイント:この例では
columns
の中で外部 state を参照していないので、useMemo
の依存配列は[]
でもOK。
どこでハマる?——columnsの中で state を読むとき
「追加モード(addMode
)の時は編集ボタンを無効にする」みたいなUI分岐を列定義の中に書くと、列定義が古いstateを握りっぱなしになりがちです。
ハマる例(addMode を参照しているのに依存配列に入れていない)
const [addMode, setAddMode] = useState(false);
const columns = useMemo(() => [
{
id: 'actions',
header: '操作',
cell: ({ row }) => (
<button
// ❌ ここでaddModeを読んでいるのに…
disabled={addMode}
onClick={() => {
if (addMode) return; // ここもaddModeを参照
alert(`編集: ${row.original.title}`);
}}
>
編集
</button>
)
}
], []); // ❌ 依存配列にaddModeがない
この場合、**ボタンは「columnsを作った時点の addMode」**を見続けます。
= 追加モードに入っても 古い false を読んで編集できちゃう → 別の処理で setAddMode(false)
が実行…という事故が起きます。
正しい例(参照する値は依存に必ず入れる)
const [addMode, setAddMode] = useState(false);
const columns = useMemo(() => [
{
id: 'actions',
header: '操作',
cell: ({ row }) => (
<button
type="button"
disabled={addMode} // ✅ 最新の値が入る
onClick={() => {
if (addMode) return;
alert(`編集: ${row.original.title}`);
}}
>
編集
</button>
),
}
], [addMode]); // ✅ addModeを依存に入れる
実戦パターン:追加行と通常行の“編集中UI”を分ける
「追加中は既存行は通常UI」「通常編集は編集中の行だけ“保存/戻る”」という行単位の切替が鉄板です。
ページ全体の editMode
だけで“保存/戻る”にしないのがコツ。
type RowData = { id: number; title: string };
const [addMode, setAddMode] = useState(false);
const [editMode, setEditMode] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const columns = useMemo(() => [
{
id: 'title',
header: 'タイトル',
cell: (info: any) => info.row.original.title,
},
{
id: 'actions',
header: '操作',
cell: ({ row }: any) => {
const r = row.original as RowData;
// ✅ 行単位で判定:追加モードなら既存行は通常UI
const isEditingThisRow = !addMode && editMode && editingId === r.id;
if (isEditingThisRow) {
return (
<>
<button type="button" onClick={() => {/* 保存 */}}>保存</button>
<button
type="button"
onClick={() => { setEditMode(false); setEditingId(null); }}
>
戻る
</button>
</>
);
}
return (
<>
<button
type="button"
disabled={addMode || (editMode && editingId !== r.id)}
onClick={() => {
if (addMode) return; // 追加中は編集に入らない
setEditMode(true);
setEditingId(r.id);
}}
>
編集
</button>
<button
type="button"
disabled={addMode || (editMode && editingId !== r.id)}
onClick={() => {/* 削除 */}}
>
削除
</button>
</>
);
},
},
], [addMode, editMode, editingId]);
さらに
type="button"
を付けておくと、フォーム内でも意図せぬ submitを防げます。
これでhandleCancelEdit()
が勝手に走ってsetAddMode(false)
される事故も回避。
チェックリスト(ハマりを未然に防ぐ)
columns
のuseMemo
依存配列に、中で参照しているすべての state/props(addMode
,editMode
,editingId
など)を入れているか?- “編集中UI”は行単位で切り替えているか?(ページ全体の
editMode
だけで切り替えない) - ボタンは
type="button"
を付け、フォーム submit 暴発を防いでいるか? - 「追加モード中に既存行の編集を無効化する」分岐がUIとロジックの両方に入っているか?(UIだけ無効化してもイベントが通ると事故る)
まとめ
- useMemo は“結果のキャッシュ”。依存に入れない値は古いまま=stale closure。
- columns はテーブルの設計図。中で state を読んだら必ず依存に入れる。
- “編集中UI”は行単位で出し分け、追加モード中は既存行を編集不可に。
type="button"
と早期return
で副作用の入口を塞ぐ。
この4点を守れば、「追加ボタン押下後に addMode
が勝手に false になって見える」系のトラブルはグッと減ります。シンプルに、堅実に、いきましょう。
コメント