Reactで、Setの集合メソッドを使って複数のタグで絞り込む方法
記事のタグと、画面で選んだタグをどちらも 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、スマートフォンのブラウザ、本番用ビルド、記事が数千件あるときの速さは試していません。

intersection() と union() が返すもの
intersection() は、2つの Set の両方にある要素を、union() は、どちらかにある要素を、それぞれ新しい Set にして返します。元の Set は変わりません。
Node.js で試した結果です。
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 から切り離して関数にしました。
// 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() を持っている必要があり、配列はこれに当たりません。配列を渡すと、例外になりました。
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 に入れます。
// 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> にしています)。
// 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>
);
}
最初: (なし)
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 を渡して試しました。
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 は次のエラーを出しました。
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 にすると、エラーはなくなりました。
{
"compilerOptions": {
"lib": ["ES2022", "ESNext.Collection", "DOM", "DOM.Iterable"]
}
}
これらのメソッドの型は、TypeScript 5.5 で追加されました(TypeScript 5.5 のリリースノート)。Chrome 122 より前のように、メソッドがないブラウザも対象にするときは、MDN の intersection() のページの関連リンクで紹介されている core-js などの polyfill を使います。





