Reactで、Setの集合メソッドを使って複数のタグで絞り込む方法

​​React​JavaScript,Playwright,TypeScript,useState

記事のタグと、画面で選んだタグをどちらも Set で持つと、複数のタグでの絞り込みは intersection() の結果の数を比べるだけで書けます。共通部分の数が選んだタグの数と同じなら「すべて含む(AND)」、1つ以上なら「どれかを含む(OR)」です。全部のタグの一覧は union() で集めます。

その2つのメソッドを使い、AND と OR を切り替えられるタグの絞り込みを React で作りました。AND のときに0件になってしまうタグは、ボタンを押せなくしています。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を tsc で型チェックしてから esbuild で変換し、開発用ビルドを StrictMode で動かしました。Firefox と Safari、スマートフォンのブラウザ、本番用ビルド、記事が数千件あるときの速さは試していません。

タグの絞り込みの画面。条件は「すべて含む(AND)」が選ばれている。タグのボタンは React と テスト が水色で塗られて選ばれた状態、TypeScript は押せる状態、JavaScript と 入門 は灰色で押せない状態。その下に「1件」と、記事「Testing Library の使い方」が1つだけ出ている
AND で React と テスト を選んだところ。0件になるタグは灰色で押せない

intersection() と union() が返すもの

intersection() は、2つの Set の両方にある要素を、union() は、どちらかにある要素を、それぞれ新しい Set にして返します。元の Set は変わりません。

Node.js で試した結果です。

txt
a = Set { 'React', 'TypeScript', 'テスト' }
b = Set { '入門', 'React' }

a.union(b)        = ["React","TypeScript","テスト","入門"]
a.intersection(b) = ["React"]
a は変わらない     = ["React","TypeScript","テスト"]
a.union(b) === a  = false

union() の結果は、新しい Set です。この「元を変えずに新しい Set を返す」性質が、React の state にそのまま合います。理由は後の節で説明します。

MDN のブラウザ互換性データによると、使えるのは Chrome 122、Firefox 127、Safari 17、Node.js 22.0.0 以降です。

タグの判定を関数にする

絞り込みの判定と、タグを集める処理と、タグを入れたり外したりする処理を、React から切り離して関数にしました。

ts
// tagSets.ts
export type Mode = 'and' | 'or';

export type Post = {
  id: number;
  title: string;
  tags: ReadonlySet<string>;
};

// 選んだタグで、記事が残るかどうかを決める
export function matches(postTags: ReadonlySet<string>, selected: ReadonlySet<string>, mode: Mode): boolean {
  if (selected.size === 0) return true; // 何も選んでいなければ全部出す
  const common = postTags.intersection(selected); // 記事のタグと、選んだタグの共通部分
  return mode === 'and' ? common.size === selected.size : common.size > 0;
}

// 記事のタグを全部まとめる
export function allTags(posts: readonly Post[]): Set<string> {
  return posts.reduce((acc, p) => acc.union(p.tags), new Set<string>());
}

// タグを1つ入れたり外したりした、新しい Set を返す(元の Set は変えない)
export function toggle(selected: ReadonlySet<string>, tag: string): Set<string> {
  const one = new Set([tag]);
  return selected.has(tag) ? selected.difference(one) : selected.union(one);
}

matches は、記事のタグと選んだタグの共通部分を intersection() で求めます。選んだタグが全部その記事にあれば、共通部分の数は選んだタグの数と同じになります。これが AND です。OR は、共通部分が1つでもあれば残します。

タグ React、TypeScript、テスト を持つ記事で試した結果です。

選んだタグ 条件 結果
(なし) AND true
React AND true
React、テスト AND true
React、入門 AND false
React、入門 OR true
入門、JavaScript OR false

toggle は、押したタグだけを入れた Set を作り、すでに選んでいれば difference()(差)で外し、選んでいなければ union() で足します。Set(['React']) に テスト を足すと ["React","テスト"] になり、元の Set は ["React"] のままでした。

引数には配列を渡せない

union() や intersection() の引数は、Set か、Set のように振る舞うオブジェクト(MDN の説明では set-like)です。size と has() と keys() を持っている必要があり、配列はこれに当たりません。配列を渡すと、例外になりました。

txt
union(配列): TypeError - The .size property is NaN
intersection(配列): TypeError - The .size property is NaN
union(Map) = ["a","b"]

Map は set-like なので、キーの集まりとして扱われました。タグを配列で持っているデータなら、new Set(post.tags) にしてから渡します。

React のコンポーネントにする

選んだタグは Set のまま state に入れます。

tsx
// TagFilter.tsx
import { useMemo, useState } from 'react';
import { allTags, matches, toggle, type Mode, type Post } from './tagSets';

export function TagFilter({ posts }: { posts: readonly Post[] }) {
  const [selected, setSelected] = useState<ReadonlySet<string>>(() => new Set());
  const [mode, setMode] = useState<Mode>('and');

  const tags = useMemo(() => [...allTags(posts)].sort(), [posts]);
  const visible = posts.filter((p) => matches(p.tags, selected, mode));
  // 今の結果に残った記事のタグ。AND のとき、ここにないタグを足すと0件になる
  const reachable = allTags(visible);

  return (
    <div className="tag-filter">
      <fieldset>
        <legend>条件</legend>
        <label>
          <input type="radio" name="mode" checked={mode === 'and'} onChange={() => setMode('and')} />
          すべて含む(AND)
        </label>
        <label>
          <input type="radio" name="mode" checked={mode === 'or'} onChange={() => setMode('or')} />
          どれかを含む(OR)
        </label>
      </fieldset>

      <div className="tags">
        {tags.map((tag) => (
          <button
            key={tag}
            type="button"
            aria-pressed={selected.has(tag)}
            disabled={mode === 'and' && !selected.has(tag) && !reachable.has(tag)}
            onClick={() => setSelected((prev) => toggle(prev, tag))}
          >
            {tag}
          </button>
        ))}
      </div>

      <p className="count">{visible.length}件</p>
      <ul>
        {visible.map((p) => (
          <li key={p.id}>{p.title}</li>
        ))}
      </ul>
    </div>
  );
}

state の型は ReadonlySet<string> にしました。add() や delete() を呼ぶと型エラーになるので、うっかり書き換えるのを防げます。それでも、intersection()、union()、difference() は呼べます。

reachable は、いま表示されている記事のタグを union() で集めたものです。AND では、記事が増えることはありません。ここにないタグを足すと、残っている記事のどれにもそのタグがないので、0件になります。そこで、AND のときは、選んでいないタグのうち reachable にないものを disabled にしました。OR では、並んでいるタグはどれも、どれかの記事に付いているものなので、足しても0件にはなりません。そのため、押せなくはしていません。

Chromium で操作した結果

次の6件の記事を渡し、Playwright でボタンを押して、表示を調べました。

記事 タグ
useState の基本 React、入門
Set のメソッド一覧 JavaScript、入門
props に型を付ける React、TypeScript
Vitest でテストを書く TypeScript、テスト
Testing Library の使い方 React、TypeScript、テスト
型の絞り込み TypeScript、入門
操作 件数 押せないタグ
最初(AND、何も選んでいない) 6件 なし
AND で React を押す 3件 JavaScript
AND で テスト も押す 1件 JavaScript、入門
OR に切り替える(React、テスト のまま) 4件 なし
OR で JavaScript も押す 5件 なし
OR で React を外す 3件 なし

AND で React を押すと、残った3件のタグに JavaScript がないので、JavaScript のボタンが押せなくなりました。テスト も押すと、残りは「Testing Library の使い方」の1件だけになり、入門 も押せなくなりました。冒頭の画像は、このときの画面です。OR に切り替えると、React か テスト を持つ4件が出ました。

コンソールのエラーと警告は、0件でした。

state の Set を add() で書き換えると、画面が変わらない

useState の set 関数のリファレンスには、新しい値が今の値と Object.is で同じなら、React は再レンダーを飛ばすと書かれています。Set の add() は同じ Set を書き換えるので、それを返しても「同じ値」とみなされます。

確かめるため、Set を add() で書き換えて返すボタンと、union() で新しい Set を返すボタンを並べました(state の型は、add() を呼べるように Set<string> にしています)。

tsx
// Mutate.tsx(Set をその場で書き換えたときの確認用)
import { useState } from 'react';

export function Mutate() {
  const [selected, setSelected] = useState<Set<string>>(() => new Set());
  return (
    <div id="mutate">
      <button
        type="button"
        onClick={() =>
          setSelected((prev) => {
            prev.add('React'); // 同じ Set をそのまま返す
            return prev;
          })
        }
      >
        add で追加
      </button>
      <button type="button" onClick={() => setSelected((prev) => prev.union(new Set(['TypeScript'])))}>
        union で追加
      </button>
      <output>{[...selected].join(', ') || '(なし)'}</output>
    </div>
  );
}
txt
最初: (なし)
add で追加 を押したあと: (なし)
add で追加 をもう一度押したあと: (なし)
union で追加 を押したあと: React, TypeScript

「add で追加」を2回押しても、表示は「(なし)」のままでした。そのあと「union で追加」を押すと、TypeScript と一緒に、前に add() で入れた React も表示されました。React は、画面に出ないまま state の Set に入っていて、union() がその Set をもとに新しい Set を作ったためです。

React の公式ドキュメントにあるオブジェクトの state の更新の説明でも、state のオブジェクトは書き換えずに、新しいものに置き換えるよう書かれています。Set でも同じで、union() や difference() のように新しい Set を返すメソッドを使えば、それに沿った書き方になります。

結果の順番は、Set の大きさで変わる

intersection() の結果をそのまま画面に並べると、並びが思わぬ順番になることがあります。MDN の intersection() の説明には、結果の順番は、2つのうち小さいほうの Set の順番になると書かれています。

Node.js で、同じ tags に対して、要素の数が違う Set を渡して試しました。

txt
tags  = Set { 'a', 'b', 'c' }
fewer = Set { 'c', 'a' }            // tags より小さい
more  = Set { 'c', 'a', 'x', 'y' }  // tags より大きい

tags.intersection(fewer) = ["c","a"]
tags.intersection(more)  = ["a","c"]

共通の要素は a と c で同じなのに、相手の大きさで並びが入れ替わりました。union() のほうは、MDN の union() の説明のとおり、元の Set の要素のあとに相手にしかない要素が続きました(['b','a'] と ['c','a'] で ["b","a","c"])。

intersection() の結果を並べて見せたいときは、配列にしてから並べ直します。TagFilter では、intersection() の結果は数を比べるだけで、並べてはいません。ボタンの並びは allTags(posts) を sort() したもので、検証では JavaScript、React、TypeScript、テスト、入門 の順になりました。

TypeScript では lib に ESNext.Collection か ES2025 を入れる

tsconfig.json の lib に ES2022 と DOM の型しか入れていないと、tsc は次のエラーを出しました。

txt
src/tagSets.ts(13,27): error TS2339: Property 'intersection' does not exist on type 'ReadonlySet<string>'.
src/tagSets.ts(19,39): error TS2550: Property 'union' does not exist on type 'Set<string>'. Do you need to change your target library? Try changing the 'lib' compiler option to 'es2025' or later.
src/tagSets.ts(25,39): error TS2339: Property 'difference' does not exist on type 'ReadonlySet<string>'.
src/tagSets.ts(25,66): error TS2339: Property 'union' does not exist on type 'ReadonlySet<string>'.
exit: 1

lib に ESNext.Collection を足すか、lib を ES2025 にすると、エラーはなくなりました。

json
{
  "compilerOptions": {
    "lib": ["ES2022", "ESNext.Collection", "DOM", "DOM.Iterable"]
  }
}

これらのメソッドの型は、TypeScript 5.5 で追加されました(TypeScript 5.5 のリリースノート)。Chrome 122 より前のように、メソッドがないブラウザも対象にするときは、MDN の intersection() のページの関連リンクで紹介されている core-js などの polyfill を使います。

​Posted by 86