Reactで、表の入力欄からMarkdownの表を作る方法
見出しとセルを入力欄に入れると、その場で Markdown の表の文字列ができるようにします。React では、列と行を state に持ち、レンダーのたびに文字列を組み立てるだけで作れます。気をつけるのは、セルの中に | が入ったときです。そのまま書くと、表の区切りとみなされてセルが分かれてしまいます。
検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは React 19.3.1、TypeScript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を tsc で型チェックしてから esbuild で本番用にビルドし、Playwright で入力欄を操作して確かめました。できた Markdown は、remark-gfm 4.0.1、markdown-it 15.0.3、marked 18.1.3、cmark-gfm(Python の cmarkgfm 2025.10.22)の4つで HTML にして比べています。Firefox、Safari、スマートフォンのブラウザ、日本語入力(IME)での入力は試していません。

Markdown の表の決まり
GitHub などで使われる Markdown の表は、GitHub Flavored Markdown(GFM)の仕様にある表の拡張で決まっています。作る側が守ることは、次の3つです。
- 1行目が見出しの行、2行目が区切りの行で、そのあとに本文の行が0行以上続く。
- 区切りの行は
---のようにハイフンだけで書き、左端に:を付けると左寄せ、右端に付けると右寄せ、両方に付けると中央寄せになる。 - 見出しの行と区切りの行は、セルの数が同じでなければならない。違うと表にならない。
セルの前後の空白は取り除かれ、列の幅を揃える必要もありません。仕様の例にも、| abc | defghi | の下に :-: | -----------: と、長さの揃っていない行が並んでいます。そこで、この記事では幅を揃えず、| りんご | 120 | のように1つの空白で区切って書きます。
見出しの行と区切りの行でセルの数が違う場合は、検証環境の4つの実装のどれでも表にならず、1つの段落として出ました。
セルの中の | の前に \ を足す
セルの値に | が入っていると、その位置でセルが分かれます。仕様には、\| と書けばセルの中に | を入れられ、`\|` のようにコードの中でも同じだと書かれています。
ただし、| を \| に置き換えるだけでは足りない場合がありました。入力した値がもともと a\|b のように \ と | の並びを含んでいると、置き換えたあとは a\\|b になります。4つの実装で HTML にしたところ、結果が分かれました([ ] は1つのセル、次のセルの値は「次」)。
入力:a\|b
| を \| にしただけ:| a\\|b | 次 |
remark-gfm [a\][b]
markdown-it [a|b][次]
marked [a\][b]
cmark-gfm [a|b][次]
remark-gfm と marked は \\ を \ という1文字として表示し、そのあとの | でセルを分けました。markdown-it と cmark-gfm はセルを分けず、表示は a|b でした。どちらでも、入力した a\|b のとおりには表示されていません。
そこで、| の直前にある \ の数を2倍にしてから、| の前に \ を足します。a\|b は a\\\|b になり、4つの実装とも [a\|b][次] と、入力したとおりに表示されました。
export type Align = 'none' | 'left' | 'center' | 'right';
export type Column = { id: number; title: string; align: Align };
// セルは列の id をキーにして持つ(列を消しても、ほかの列の値がずれない)
export type Row = { id: number; cells: Record<number, string> };
const DELIMITER: Record<Align, string> = {
none: '---',
left: ':---',
center: ':---:',
right: '---:',
};
// | の前に \ を足す。直前に \ が続いていたら、その \ も2つずつにする
export function escapeCell(text: string): string {
return text.trim().replace(/(\\*)\|/g, (_, bs: string) => `${bs}${bs}\\|`);
}
export function toMarkdown(columns: Column[], rows: Row[]): string {
const line = (cells: string[]) => `| ${cells.join(' | ')} |`;
return [
line(columns.map((c) => escapeCell(c.title))),
line(columns.map((c) => DELIMITER[c.align])),
...rows.map((r) => line(columns.map((c) => escapeCell(r.cells[c.id] ?? '')))),
].join('\n');
}
前後の空白を取ることと、| の前に \ を足すこと以外は書き換えないので、**太字** のような Markdown の書き方は、そのまま太字として表示されました。`x|y` のようにコードの中にある | も、`x\|y` になって、4つの実装とも HTML は <code>x|y</code> になりました。
1つだけ、入力したとおりに表示されない値がありました。コードの中にもともと \| を書いた `x\|y` は、`x\\\|y` になり、4つの実装とも HTML は <code>x\\|y</code> になり、\ が2つ表示されます。GFM の仕様の、バックスラッシュエスケープの節にあるとおり、コードの中ではバックスラッシュによるエスケープが働きません。表のために足した \| だけが | に戻り、2つの \ はそのまま残ります。
列と行は、id を付けて state に持つ
コンポーネントでは、列の配列 columns と、行の配列 rows を state に持ちます。列は見出しと寄せ方、行はセルの値を持ちます。Markdown の文字列は state にせず、レンダーのたびに toMarkdown() で作ります。
import { useId, useRef, useState } from 'react';
import { toMarkdown, type Align, type Column, type Row } from './markdownTable';
const ALIGN_LABELS: Record<Align, string> = { none: '指定なし', left: '左', center: '中央', right: '右' };
const ALIGNS = Object.keys(ALIGN_LABELS) as Align[];
export function TableEditor() {
const nextId = useRef(3);
const [columns, setColumns] = useState<Column[]>([
{ id: 0, title: '', align: 'none' },
{ id: 1, title: '', align: 'none' },
]);
const [rows, setRows] = useState<Row[]>([{ id: 2, cells: {} }]);
const [copiedText, setCopiedText] = useState<string | null>(null);
const outputId = useId();
const markdown = toMarkdown(columns, rows);
function updateColumn(id: number, patch: Partial<Column>) {
setColumns(columns.map((c) => (c.id === id ? { ...c, ...patch } : c)));
}
function updateCell(rowId: number, colId: number, value: string) {
setRows(rows.map((r) => (r.id === rowId ? { ...r, cells: { ...r.cells, [colId]: value } } : r)));
}
function addColumn() {
setColumns([...columns, { id: nextId.current++, title: '', align: 'none' }]);
}
function removeColumn(id: number) {
setColumns(columns.filter((c) => c.id !== id));
}
function addRow() {
setRows([...rows, { id: nextId.current++, cells: {} }]);
}
function removeRow(id: number) {
setRows(rows.filter((r) => r.id !== id));
}
async function copy() {
await navigator.clipboard.writeText(markdown);
setCopiedText(markdown);
}
return (
<div className="table-editor">
<table>
<thead>
<tr>
{columns.map((c, i) => (
<th key={c.id}>
<input
aria-label={`${i + 1}列目の見出し`}
placeholder="見出し"
value={c.title}
onChange={(e) => updateColumn(c.id, { title: e.target.value })}
/>
<select
aria-label={`${i + 1}列目の寄せ方`}
value={c.align}
onChange={(e) => updateColumn(c.id, { align: e.target.value as Align })}
>
{ALIGNS.map((a) => (
<option key={a} value={a}>{ALIGN_LABELS[a]}</option>
))}
</select>
<button
type="button"
aria-label={`${i + 1}列目を削除`}
disabled={columns.length === 1}
onClick={() => removeColumn(c.id)}
>
×
</button>
</th>
))}
<th />
</tr>
</thead>
<tbody>
{rows.map((r, ri) => (
<tr key={r.id}>
{columns.map((c, ci) => (
<td key={c.id}>
<input
aria-label={`${ri + 1}行目・${ci + 1}列目`}
value={r.cells[c.id] ?? ''}
onChange={(e) => updateCell(r.id, c.id, e.target.value)}
/>
</td>
))}
<td>
<button type="button" aria-label={`${ri + 1}行目を削除`} onClick={() => removeRow(r.id)}>
×
</button>
</td>
</tr>
))}
</tbody>
</table>
<p className="actions">
<button type="button" onClick={addRow}>行を追加</button>
<button type="button" onClick={addColumn}>列を追加</button>
</p>
<label htmlFor={outputId}>Markdown</label>
<textarea id={outputId} readOnly rows={rows.length + 2} value={markdown} />
<p className="actions">
<button type="button" onClick={copy}>コピー</button>
<span role="status">{copiedText === markdown ? 'コピーしました' : ''}</span>
</p>
</div>
);
}
行と列には、追加するときに nextId から番号を振ります。行のセルは、列の位置(何列目か)ではなく列の id をキーにした Record に入れます。こうしておくと、列を消しても、行のデータを書き換えずに済みます。消した列の値は行の中に残りますが、toMarkdown() は columns にある列しか読まないので、表には出ません。
state の配列は直接書き換えず、map() で作った新しい配列や、filter() で要素を除いた新しい配列、スプレッド構文([...rows, 新しい行])で末尾に足した新しい配列を渡します。React の公式ドキュメントにある、state の配列を書き換えずに更新する方法と同じ書き方です。
見出しの行と区切りの行のセルの数は、どちらも columns から作るので、いつも同じになります。列が0になると表を作れないので、列が1つのときは列の「×」のボタンを押せないようにしました。行の「×」のボタンはいつでも押せるので、行は0行まで消せます。見出しと区切りの行だけの Markdown も、4つの実装とも、見出しだけの表として HTML になりました。
Chromium で操作したときの Markdown
Playwright で入力欄を操作し、そのつど Markdown の欄の値を読みました。見出しに「名前」「値段」、1行目に「りんご」「120」を入れ、行と列を1つずつ足して値を入れると、次のようになりました。
| 名前 | 値段 | メモ |
| --- | --- | --- |
| りんご | 120 | 赤\|青 |
| みかん | 80 | |
まだ何も入れていないセルは、| | と空のセルになります。2列目の寄せ方を「右」、3列目を「中央」にすると、区切りの行が | --- | ---: | :---: | に変わりました。
続けて1行目の「×」のボタンを押すと、Markdown から「りんご」の行が消え、入力欄も「みかん」「80」の行だけになりました。2列目の「×」のボタンを押したあとは、見出しの入力欄が「名前」「メモ」になり、Markdown も2列になっています。そのあと「列を追加」を押すと、新しい列は空のままでした。新しい列には新しい id が振られるので、消した列の値が出てくることはありません。
コピーのボタンと、改行の入った値
コピーには navigator.clipboard.writeText() を使います。MDN によると、クリップボードに書き込めるのは安全なコンテキスト(HTTPS で配信されたページや、localhost のページなど)だけです。検証環境では、localhost で開いたページに Playwright でクリップボードの読み書きを許可し、「コピー」を押しました。すると、Markdown の欄と同じ文字列がクリップボードに入り、「コピーしました」と出ました。
「コピーしました」は、コピーした文字列を copiedText に入れておき、今の markdown と同じときだけ出しています。コピーのあとでセルの値を変えると、表示は消えました。表示を消すためのタイマーや Effect は使っていません。
セルは <input type="text"> なので、値に改行は入りません。HTML の仕様では、テキストの入力欄の値から改行を取り除くと決められています。検証環境の Chromium で試したところ、value に 1行目\n2行目 を入れると 1行目2行目 になり、改行を含む文字をクリップボードから貼り付けると 1行目 2行目 と空白に変わりました。
Markdown の表は、テキストの1行が表の1行になります。セルの値に改行が入った Markdown を4つの実装で HTML にすると、どれも改行の位置で表の行が分かれ、[1行目][] と [2行目][次] の2行になりました。セルが <input> なら、値に改行が入る心配はありません。





