ReactでObject.groupByを使い種類別に一覧を出す方法

​​React​ECMAScript,JavaScript,Playwright,TypeScript

Object.groupBy(配列, (item) => item.category) を使うと、配列を種類ごとの配列にまとめた1つのオブジェクトが返ります。React では、見出しを出したい順に種類の名前を並べた配列を用意し、それを map してグループを取り出すと、決まった順番の見出し付きリストになります。

商品のリストを「果物」「野菜」「飲み物」に分けて表示するコンポーネントを TypeScript で作り、名前での絞り込みと組み合わせて Chromium で動かしました。あわせて、年のような数値でまとめると見出しの順番が変わってしまう点と、そのときに使う Map.groupBy も確かめています。

補足

検証環境は 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 で Chromium を操作して確かめました。開発用ビルドで動かしています。Firefox と Safari、スマートフォンのブラウザ、本番用ビルド、Object.groupBy を持たない古いブラウザ向けの polyfill は試していません。

Object.groupBy が返すもの

まず、まとめる前の商品のデータです。種類はわざとばらばらに並べました。

ts
export type Category = '果物' | '野菜' | '飲み物';

export type Product = {
  id: string;
  name: string;
  category: Category;
  price: number;
};

// 種類はわざとばらばらに並べてある
export const products: Product[] = [
  { id: 'p1', name: 'りんごジュース', category: '飲み物', price: 180 },
  { id: 'p2', name: 'バナナ', category: '果物', price: 198 },
  { id: 'p3', name: 'にんじん', category: '野菜', price: 120 },
  { id: 'p4', name: 'みかん', category: '果物', price: 350 },
  { id: 'p5', name: '緑茶', category: '飲み物', price: 140 },
  { id: 'p6', name: 'キャベツ', category: '野菜', price: 210 },
  { id: 'p7', name: 'りんご', category: '果物', price: 128 },
  { id: 'p8', name: 'トマトジュース', category: '飲み物', price: 160 },
];

Object.groupBy には、まとめる配列と、要素ごとに「どのグループに入れるか」を返す関数を渡します。

ts
import type { Category, Product } from './data';

// 見出しを出す順番。Object.groupBy の結果のキーの順番には頼らない
export const CATEGORY_ORDER: Category[] = ['果物', '野菜', '飲み物'];

export function groupByCategory(items: Product[]) {
  return Object.groupBy(items, (item) => item.category);
}

groupByCategory(products) の結果を Node.js で調べたところ、次のようになりました。

text
[1] キーの順番: [ '飲み物', '果物', '野菜' ]
[1] 果物: [ 'バナナ', 'みかん', 'りんご' ]
[2] 同じオブジェクトか: true
[3] プロトタイプ: null
[3] hasOwnProperty の型: undefined
[3] Object.hasOwn(..., "果物"): true
[3] "toString" in 結果: false

キーは、元の配列で最初に出てきた種類の順(飲み物、果物、野菜)に並びました。グループの中の並びも、元の配列の順のままです。そのため、見出しを「果物、野菜、飲み物」の順に出したいなら、結果のキーをそのまま使わず、順番を決めた CATEGORY_ORDER を別に持ちます。

グループの配列に入っているのは、元の配列と同じオブジェクトです。MDN の Object.groupBy() のページにも、ディープコピーではないと書かれています。グループ側の要素(オブジェクト)を書き換えると、元の配列の要素も書き換わります。

返ってくるオブジェクトは、プロトタイプが null のオブジェクトです。種類の名前が toString でも、Object.prototype のメソッドと取り違えることはありません。その代わり、result.hasOwnProperty('果物') は使えません。あるかどうかを調べるときは Object.hasOwn(result, '果物') を使います。

種類の順番どおりに見出しを出すコンポーネント

コンポーネントでは、絞り込んだ商品を groupByCategory でまとめ、CATEGORY_ORDER の順に section を出します。1件もない種類は、見出しごと出しません。

tsx
import { useMemo, useState } from 'react';
import type { Product } from './data';
import { CATEGORY_ORDER, groupByCategory } from './group';

export function ProductList({ products }: { products: Product[] }) {
  const [query, setQuery] = useState('');

  const groups = useMemo(() => {
    const hits = products.filter((p) => p.name.includes(query));
    return groupByCategory(hits);
  }, [products, query]);

  return (
    <div>
      <label>
        名前で絞り込む
        <input value={query} onChange={(e) => setQuery(e.target.value)} />
      </label>

      {CATEGORY_ORDER.map((category) => {
        const items = groups[category];
        if (!items) return null; // 1件もない種類は、見出しごと出さない
        return (
          <section key={category} aria-labelledby={`h-${category}`}>
            <h2 id={`h-${category}`}>
              {category}({items.length}件)
            </h2>
            <ul>
              {items.map((p) => (
                <li key={p.id}>
                  {p.name} <span>{p.price}円</span>
                </li>
              ))}
            </ul>
          </section>
        );
      })}
    </div>
  );
}

まとめ直すのは、商品のリストか絞り込みの文字が変わったときだけでよいので、useMemo に入れました。

「名前で絞り込む」の入力欄の下に、「果物(3件)」「野菜(2件)」「飲み物(3件)」の3つの見出しが並び、それぞれの下に商品名と価格の行がある。果物はバナナ、みかん、りんご、野菜はにんじん、キャベツ、飲み物はりんごジュース、緑茶、トマトジュース
絞り込む前の表示。見出しは CATEGORY_ORDER の順に並んだ

Chromium で表示したところ、元のデータでは飲み物が先頭にあっても、見出しは果物、野菜、飲み物の順に並びました。入力欄に文字を入れたときの見出しは、次のとおりです。

入力した文字 表示された見出し
(なし) 果物(3件)、野菜(2件)、飲み物(3件)
ジュース 飲み物(2件)
りんご 果物(1件)、飲み物(1件)
メロン (見出しも section も0個)
入力欄に「りんご」と入っている。見出しは「果物(1件)」と「飲み物(1件)」の2つだけで、果物の下に「りんご 128円」、飲み物の下に「りんごジュース 180円」がある。野菜の見出しはない
「りんご」で絞り込んだ表示。1件もない野菜は見出しごと消えた

TypeScript では lib と undefined に気をつける

Object.groupBy の型は、TypeScript の lib に ES2024 以降を入れたときに使えます。lib を ["ES2022", "DOM"] にして group.ts を型チェックすると、次のエラーになりました。

text
src/group.ts(7,17): error TS2550: Property 'groupBy' does not exist on type 'ObjectConstructor'. Do you need to change your target library? Try changing the 'lib' compiler option to 'es2024' or later.
src/group.ts(7,33): error TS7006: Parameter 'item' implicitly has an 'any' type.

検証では、target は ES2022 のまま、lib だけを ["ES2024", "DOM"] にしました。MDN のブラウザー互換性の表によると、Object.groupBy に対応したのは Chrome 117、Firefox 119、Safari 17.4、Node.js 21.0.0 からです。

TypeScript 7.0.2 に入っている lib.es2024.object.d.ts では、戻り値の型が Partial<Record<K, T[]>> になっています。どのキーも「ないかもしれない」型なので、確かめずに使うとエラーになります。次のコードは import を省いた抜粋です。

ts
const groups = groupByCategory(products);

// 確かめずに使う
console.log(groups['果物'].length);
text
tserr/use.ts(7,13): error TS2532: Object is possibly 'undefined'.

コンポーネントで if (!items) return null; を入れたのは、この型に合わせるためでもあります。実行したときにも、1件もない種類はキーそのものが作られません。「りんご」で絞った商品をまとめると、キーは飲み物と果物の2つだけで、野菜のキーを Object.hasOwn で調べると false、値は undefined でした。

年のような数値でまとめるなら Map.groupBy

記事を年ごとに分けるように、数値でまとめるときは並び方に注意が要ります。2026年、2025年、2026年、2024年、2025年の順に並んだ記事を Object.groupBy で年ごとにまとめると、キーは小さい順になりました。

text
[4] Object.groupBy のキー: [ '2024', '2025', '2026' ]
[4] キーの型: string
[5] Map.groupBy のキー: [ 2026, 2025, 2024 ]
[5] キーの型: number
[6] 混ざったキー: [ '2', '10', 'b', 'a', '01' ]

オブジェクトのキーは、'2' や '2026' のように配列の添え字として読める文字列だけが先に、小さい順に並びます。残りのキーは作られた順です。これは ECMAScript の仕様の OrdinaryOwnPropertyKeys で決まっている順番です。上の [6] でも、'2' と '10' が先に来て、'01' は添え字として読めないので最後に残りました。また、キーは文字列になり、数値の 2026 は '2026' に変わります。

Map.groupBy() は、同じまとめ方で Map を返します。キーは最初に出てきた順のまま、型も数値のままでした。新しい順に並べてから渡せば、新しい年から見出しを出せます。

tsx
type Post = { id: string; title: string; year: number };

// posts は新しい順に並んでいるものとする
export function YearArchive({ posts }: { posts: Post[] }) {
  const byYear = Map.groupBy(posts, (p) => p.year);

  return (
    <>
      {Array.from(byYear, ([year, items]) => (
        <section key={year}>
          <h2>{year}年</h2>
          <ul>
            {items.map((p) => (
              <li key={p.id}>{p.title}</li>
            ))}
          </ul>
        </section>
      ))}
    </>
  );
}

2026年、2025年、2024年の順に並べた5件の記事を renderToStaticMarkup で描画して、見出しを比べました。Object.groupBy の結果を Object.entries で回し、値が undefined のときは空の配列にした版は、古い年が先になりました。

text
Map.groupBy   : [ '2026年', '2025年', '2024年' ]
Object.groupBy: [ '2024年', '2025年', '2026年' ]

Map から取り出した items は T[] の型になるので、undefined を確かめる必要もありません。一方、Object.groupBy の結果を Object.entries で回すと、年のような数値のキーでは値が undefined を含む型になり、tsc で error TS18048: 'items' is possibly 'undefined'. になりました。同じ書き方でも、種類の名前のような文字列のキーではエラーになりませんでした。

Map.groupBy は、オブジェクトそのものをキーにもできます。ただし、キーが同じかどうかは中身ではなく同じオブジェクトかどうかで決まります。担当者のオブジェクトでまとめた検証では、byOwner.get(alice) は2件を返し、同じ中身の { name: 'Alice' } を新しく作って渡すと undefined でした。

ポイント

見出しの順番が決まっているなら、順番の配列を持って Object.groupBy の結果から取り出します。データの並び順のまま見出しを出したいときや、キーが数値のときは Map.groupBy を使います。

​Posted by 86