「APIからデータを取ってきてテーブルに表示する」——PythonならmatplotlibだったりplotlyのPythonライブラリで図にするだけです。そんな感覚でNext.jsに入ったら、思ったより全然できませんでした。
チーム開発でフロントエンドを初めて担当したとき、最初のタスクが「一覧データをテーブルで表示するページを作る」でした。シンプルに見えたのに、詰まったポイントが2つありました。同じところで悩んでいる方の参考になれば幸いです。
前提:どんな状況だったか
環境はNext.js 14のApp Routerです。バックエンドにAPIがあり、そこからデータを取得して一覧表示するページを作るタスクでした。
最初に自分が書いたのはこんなコードです。「動けばいいでしょ」という発想で、1ファイルにすべて詰め込みました。
// 最初に書いた「全部入り」のコード(これがレビューで指摘された)
"use client";
import { useState, useEffect } from "react";
type Product = { id: number; name: string; price: number; stock: number };
export default function ProductListPage() {
// useState: 「状態(値)」をコンポーネントの中に保持するための仕組み
// Python の「インスタンス変数」に近いイメージ
const [products, setProducts] = useState<Product[]>([]);
// useEffect: ページが表示されたタイミングで「副作用(API呼び出しなど)」を実行する仕組み
// 第2引数の [] は「ページ表示時に1回だけ実行」という意味
useEffect(() => {
fetch("/api/products")
.then((r) => r.json())
.then(setProducts);
}, []);
// ここからテーブルの表示。全部1ファイルに書いている
return (
<table>
<thead>
<tr>
<th>商品名</th><th>価格</th><th>在庫</th>
</tr>
</thead>
<tbody>
{products.map((p) => (
<tr key={p.id}>
<td>{p.name}</td>
<td>{p.price}円</td>
<td>{p.stock}</td>
</tr>
))}
</tbody>
</table>
);
}
動作はしましたが、レビューで「コンポーネントに分けてください」「ソートも追加してほしい」と言われてから迷走しました。
詰まり①:コンポーネントをどう分割するか
「コンポーネントに分ける」と言われても、最初はどう分ければいいのかイメージできませんでした。
Reactでは「UIの部品」をコンポーネント(関数)として切り出す考え方があります。Pythonで言うと「大きな関数を小さな関数に分割する」感覚に近いです。
今回のテーブルで言うと、役割は3つに分けられます。
- ページ(Page):APIからデータを取ってくる担当
- テーブル(Table):テーブル全体の構造を描く担当
- 行(Row):1行分の表示を担当
コードに落とすとこうなります。
// ProductListPage.tsx ── データ取得だけに専念
"use client";
import { useState, useEffect } from "react";
import { ProductTable } from "./ProductTable";
type Product = { id: number; name: string; price: number; stock: number };
export default function ProductListPage() {
const [products, setProducts] = useState<Product[]>([]);
useEffect(() => {
fetch("/api/products")
.then((r) => r.json())
.then(setProducts);
}, []);
// データを取得したら ProductTable に渡すだけ。表示は一切関与しない
return <ProductTable products={products} />;
}
// ProductTable.tsx ── テーブルの骨格だけを担当
import { ProductRow } from "./ProductRow";
type Props = { products: Product[] };
export function ProductTable({ products }: Props) {
return (
<table>
<thead>
<tr>
<th>商品名</th><th>価格</th><th>在庫</th>
</tr>
</thead>
<tbody>
{/* 行の描画は ProductRow に任せる */}
{products.map((p) => (
<ProductRow key={p.id} product={p} />
))}
</tbody>
</table>
);
}
// ProductRow.tsx ── 1行の表示だけを担当
type Props = { product: Product };
export function ProductRow({ product }: Props) {
return (
<tr>
<td>{product.name}</td>
<td>{product.price.toLocaleString()}円</td>
<td>{product.stock}</td>
</tr>
);
}
「テーブル1枚のためにファイル3つ?」と最初は思いました。でもこの分割をしておくと、後から機能を追加するときに「どのファイルを触ればいいか」が明確になります。
たとえば「在庫が0の行を赤くしたい」なら ProductRow だけ修正すれば済みます。「列を追加したい」なら ProductTable と ProductRow だけ。APIの取得方法を変えたいなら ProductListPage だけです。1ファイルに全部書いていると、どこを直せばいいかわからなくなります。
今回のテーブルで言うと、「APIからデータを取ってくる」「テーブルの骨格を描く」「1行を表示する」は別々の役割です。これを1ファイルに混ぜると、修正のたびに関係ない部分まで読み解く必要が出てきます。
分割すれば「どこを直せばいいか」が一目でわかります。
詰まり②:列ヘッダーをクリックしてソートする実装
「ヘッダーをクリックしたらその列でソートできるようにしてほしい」という追加要件が来たとき、まず「何を状態として持てばいいか」がわかりませんでした。
考えてみると、ソートに必要な情報は2つだけです。
- どの列でソートするか(例:「価格」列)
- 昇順か降順か(▲か▼か)
この2つを useState で持てばいい、ということに気づくと実装の見通しが立ちます。
// ProductTable.tsx にソート機能を追加
// ソートで useState を使うため、このファイルにも "use client" が必要
"use client";
import { useState, useMemo } from "react";
// ソートできる列の名前を型として定義しておく
type SortKey = "name" | "price" | "stock";
type SortOrder = "asc" | "desc"; // asc = 昇順(小→大), desc = 降順(大→小)
type Props = { products: Product[] };
export function ProductTable({ products }: Props) {
const [sortKey, setSortKey] = useState<SortKey>("name"); // どの列でソートするか
const [sortOrder, setSortOrder] = useState<SortOrder>("asc"); // 昇順 or 降順
// useMemo: 「重い計算の結果」をキャッシュする仕組み
// sortKey か sortOrder か products が変わったときだけソートを再計算する
const sortedProducts = useMemo(() => {
return [...products].sort((a, b) => {
// [...products] でコピーしてからソート(元の配列を書き換えないため)
if (a[sortKey] < b[sortKey]) return sortOrder === "asc" ? -1 : 1;
if (a[sortKey] > b[sortKey]) return sortOrder === "asc" ? 1 : -1;
return 0;
});
}, [products, sortKey, sortOrder]);
// ヘッダーをクリックしたときの処理
const handleSort = (key: SortKey) => {
if (key === sortKey) {
// 同じ列をもう一度クリック → 昇降順を反転
setSortOrder((prev) => (prev === "asc" ? "desc" : "asc"));
} else {
// 別の列をクリック → その列で昇順ソート
setSortKey(key);
setSortOrder("asc");
}
};
const COLUMNS: { key: SortKey; label: string }[] = [
{ key: "name", label: "商品名" },
{ key: "price", label: "価格" },
{ key: "stock", label: "在庫" },
];
return (
<table>
<thead>
<tr>
{COLUMNS.map(({ key, label }) => (
<th key={key} onClick={() => handleSort(key)} style={{ cursor: "pointer" }}>
{label}
{/* 現在ソート中の列だけ ▲▼ を表示 */}
{sortKey === key ? (sortOrder === "asc" ? " ▲" : " ▼") : ""}
</th>
))}
</tr>
</thead>
<tbody>
{sortedProducts.map((p) => (
<ProductRow key={p.id} product={p} />
))}
</tbody>
</table>
);
}
実装して気づいたポイントをまとめます。
- ソート状態は2つのstateで持つ:「どの列か」と「昇降順か」の2変数に分けると管理しやすい
[...products]でコピーしてからソート:products.sort()と直接書くと元の配列が書き換わってしまう。スプレッド構文でコピーが必要- 同じ列クリックで昇降反転の分岐:
handleSortの中の if 分岐がこれ。最初は思いつかずレビューで教えてもらいました
まとめ
「テーブルにデータを表示するだけ」のタスクで詰まった2点をまとめます。
- コンポーネントの3層分割:ページ(データ取得)→ テーブル(構造)→ 行(表示)で役割を分ける。最初は大げさに見えますが、機能追加のたびに効いてきます
- 列ソート:「どの列か」「昇降順か」の2つを
useStateで管理し、ソート済みリストはuseMemoで計算する。コピーを忘れずに
参考になれば幸いです。
