プログラミング実践

【Next.js】PythonエンジニアがNext.jsで「テーブルにデータを表示する」だけで詰まった2つのこと

記事内に商品プロモーションを含む場合があります

「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 だけ修正すれば済みます。「列を追加したい」なら ProductTableProductRow だけ。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 で計算する。コピーを忘れずに

参考になれば幸いです。

COMMENT

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

CAPTCHA