検索と絞り込みを作る:使いやすい一覧画面にする

はじめに
この節では、スポット一覧画面をもっと使いやすくします。
今までの一覧画面は、スポットをただ並べるだけでした。
でも、投稿が増えると、見たいスポットを探すのが大変になります。
そこで今回は、次の3つを作ります。
キーワード検索
カテゴリ絞り込み
条件に合った投稿件数の表示
この3つがあるだけで、一覧画面はかなりアプリらしくなります。
この節のゴール
この節が終わると、次のことができるようになります。
- キーワードでスポットを検索できる
- カテゴリでスポットを絞り込める
- 条件に合った件数を表示できる
useStateを使って検索条件を管理できるfilterを使って表示するデータだけを選べる 今日のポイントはこれです。
元のデータから、条件に合うものだけを表示する
1. 今回作る画面
今回は、スポット一覧ページを作ります。
URLはこれです。
/spots
作るファイルはここです。
app/spots/page.tsx
このページに、検索と絞り込みを追加します。
2. 検索と絞り込みの考え方
まず、一覧には元のデータがあります。
すべてのスポット
そこに条件をつけます。
キーワードに合うものだけ
カテゴリに合うものだけ
すると、表示されるデータが少なくなります。
すべてのスポット
↓
検索条件でしぼる
↓
条件に合うスポットだけ表示
この処理を、プログラムでは filter で行います。
3. 今回使うデータ
まずはデータベースではなく、ページ内にサンプルデータを書きます。
難しい保存処理はまだ使いません。
検索と絞り込みの仕組みだけに集中します。
4. 完成コード
app/spots/page.tsx を作成、または置き換えてください。
app/spots/page.tsx
'use client';
import Link from 'next/link';
import { useMemo, useState } from 'react';
type Spot = {
id: string;
name: string;
description: string;
category: string;
location: string;
imageUrl: string;
};
const categories = [
'すべて',
'図書館',
'カフェ',
'学食',
'作業スペース',
'休憩スポット',
] as const;
type Category = (typeof categories)[number];
const spots: Spot[] = [
{
id: '1',
name: '静かな図書館',
description: 'とても静かで、勉強や読書に集中できます。',
category: '図書館',
location: '名古屋駅 徒歩5分',
imageUrl:
'https://images.unsplash.com/photo-1521587760476-6c12a4b040da?auto=format&fit=crop&w=1200&q=80',
},
{
id: '2',
name: 'おしゃれカフェ',
description: '落ち着いた雰囲気で、友人との会話や作業に向いています。',
category: 'カフェ',
location: '栄駅 徒歩3分',
imageUrl:
'https://images.unsplash.com/photo-1501339847302-ac426a4a7cbb?auto=format&fit=crop&w=1200&q=80',
},
{
id: '3',
name: '学食の穴場席',
description: '昼休み以外は比較的空いていて、短い休憩に使いやすいです。',
category: '学食',
location: '大学キャンパス内',
imageUrl:
'https://images.unsplash.com/photo-1528605248644-14dd04022da1?auto=format&fit=crop&w=1200&q=80',
},
{
id: '4',
name: '集中できる作業スペース',
description: 'コンセントがあり、課題や制作作業に使いやすい場所です。',
category: '作業スペース',
location: '伏見駅 徒歩6分',
imageUrl:
'https://images.unsplash.com/photo-1497366754035-f200968a6e72?auto=format&fit=crop&w=1200&q=80',
},
];
/**
* 役割: スポット一覧画面を表示し、検索とカテゴリ絞り込みを行う
* 入力: なし
* 出力: 検索・絞り込み付きのスポット一覧画面
*/
export default function SpotsPage(): JSX.Element {
const [keyword, setKeyword] = useState<string>('');
const [selectedCategory, setSelectedCategory] =
useState<Category>('すべて');
const filteredSpots = useMemo(() => {
return spots.filter((spot) => {
const normalizedKeyword = keyword.trim().toLowerCase();
const matchesKeyword =
normalizedKeyword.length === 0 ||
spot.name.toLowerCase().includes(normalizedKeyword) ||
spot.description.toLowerCase().includes(normalizedKeyword) ||
spot.location.toLowerCase().includes(normalizedKeyword);
const matchesCategory =
selectedCategory === 'すべて' || spot.category === selectedCategory;
return matchesKeyword && matchesCategory;
});
}, [keyword, selectedCategory]);
return (
<main
style={{
maxWidth: '960px',
margin: '0 auto',
padding: '32px',
}}
>
<h1>スポット一覧</h1>
<p>キーワード検索とカテゴリ絞り込みで、見たいスポットを探しましょう。</p>
<section
style={{
display: 'grid',
gap: '16px',
marginTop: '24px',
padding: '16px',
border: '1px solid #ddd',
borderRadius: '12px',
background: '#fff',
}}
>
<label style={{ display: 'grid', gap: '8px' }}>
<span>キーワード検索</span>
<input
type="text"
value={keyword}
onChange={(event) => {
setKeyword(event.target.value);
}}
placeholder="例)図書館、カフェ、名古屋"
style={{
padding: '12px',
border: '1px solid #ccc',
borderRadius: '8px',
}}
/>
</label>
<label style={{ display: 'grid', gap: '8px' }}>
<span>カテゴリで絞り込み</span>
<select
value={selectedCategory}
onChange={(event) => {
setSelectedCategory(event.target.value as Category);
}}
style={{
padding: '12px',
border: '1px solid #ccc',
borderRadius: '8px',
}}
>
{categories.map((category) => {
return (
<option key={category} value={category}>
{category}
</option>
);
})}
</select>
</label>
<p
style={{
margin: 0,
padding: '12px',
borderRadius: '8px',
background: '#e6f6f2',
color: '#1e7b65',
fontWeight: 700,
}}
>
{filteredSpots.length}件のスポットが見つかりました
</p>
</section>
<section
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))',
gap: '16px',
marginTop: '24px',
}}
>
{filteredSpots.map((spot) => {
return (
<article
key={spot.id}
style={{
border: '1px solid #ddd',
borderRadius: '12px',
overflow: 'hidden',
background: '#fff',
}}
>
<img
src={spot.imageUrl}
alt={spot.name}
style={{
width: '100%',
height: '150px',
objectFit: 'cover',
display: 'block',
}}
/>
<div style={{ padding: '16px' }}>
<p
style={{
display: 'inline-block',
margin: 0,
padding: '4px 10px',
borderRadius: '999px',
background: '#e6f6f2',
color: '#1e7b65',
fontSize: '12px',
fontWeight: 700,
}}
>
{spot.category}
</p>
<h2>{spot.name}</h2>
<p>{spot.description}</p>
<p style={{ color: '#666' }}>場所: {spot.location}</p>
<Link href={`/spots/${spot.id}`}>詳細を見る</Link>
</div>
</article>
);
})}
</section>
{filteredSpots.length === 0 ? (
<p
style={{
marginTop: '24px',
padding: '16px',
border: '1px solid #ddd',
borderRadius: '12px',
background: '#f5f8fa',
}}
>
条件に合うスポットがありません。キーワードやカテゴリを変えてみましょう。
</p>
) : null}
</main>
);
}
5. 画面を確認する
ファイルを保存したら、開発サーバーを起動します。
npm run dev
ブラウザで次を開きます。
http://localhost:3000/spots
表示されたら、次を試してください。
検索欄に「図書館」と入力する
カテゴリを「カフェ」に変える
検索欄を空に戻す
カテゴリを「すべて」に戻す
件数と表示されるカードが変われば成功です。
6. キーワード検索の仕組み
キーワード検索は、この部分で行っています。
const matchesKeyword =
normalizedKeyword.length === 0 ||
spot.name.toLowerCase().includes(normalizedKeyword) ||
spot.description.toLowerCase().includes(normalizedKeyword) ||
spot.location.toLowerCase().includes(normalizedKeyword);
これは、次の意味です。
検索欄が空
→ 全部表示する
スポット名にキーワードが含まれる
→ 表示する
説明文にキーワードが含まれる
→ 表示する
場所にキーワードが含まれる
→ 表示する
includes** とは**
includes は、「その文字が含まれているか」を調べるものです。
'静かな図書館'.includes('図書館')
これは true になります。
'静かな図書館'.includes('カフェ')
これは false になります。
7. カテゴリ絞り込みの仕組み
カテゴリ絞り込みは、この部分です。
const matchesCategory =
selectedCategory === 'すべて' || spot.category === selectedCategory;
意味はこうです。
カテゴリが「すべて」
→ 全部表示する
スポットのカテゴリと選んだカテゴリが同じ
→ 表示する
たとえば、カテゴリで「カフェ」を選ぶと、カフェだけ表示されます。
8. 件数表示の仕組み
件数は、絞り込んだ後の配列の数を表示しています。
{filteredSpots.length}件のスポットが見つかりました
filteredSpots は、条件に合ったスポットだけが入った配列です。
そのため、length を使うと、条件に合った件数がわかります。
9. なぜ useMemo を使うのか
今回のコードでは、絞り込み結果を作るために useMemo を使っています。
const filteredSpots = useMemo(() => {
return spots.filter((spot) => {
...
});
}, [keyword, selectedCategory]);
初心者のうちは、こう考えれば大丈夫です。
keyword や selectedCategory が変わったときだけ、絞り込みをやり直す。
今回のような小さいアプリでは必須ではありません。
ただ、一覧データが増えたときにも使いやすい書き方なので、ここで慣れておくと便利です。
10. 一覧画面をより便利にする工夫
検索と絞り込みができるだけでも便利ですが、さらに使いやすくするなら、次のような工夫があります。
検索欄に例を表示する
件数を表示する
条件に合わないときのメッセージを出す
カテゴリを選択式にする
カードを見やすく並べる
今回のコードでは、すでに次の工夫を入れています。
プレースホルダー
カテゴリ選択
件数表示
0件のときのメッセージ
詳細ページへのリンク
これだけでも、かなり使いやすくなります。
11. よくあるつまずき
1. 検索しても変わらない
input の value と onChange を確認してください。
value={keyword}
onChange={(event) => {
setKeyword(event.target.value);
}}
2. カテゴリを変えても絞り込まれない
select の value と onChange を確認してください。
value={selectedCategory}
onChange={(event) => {
setSelectedCategory(event.target.value as Category);
}}
3. 大文字・小文字で検索できない
検索するときは、小文字にそろえると安全です。
spot.name.toLowerCase().includes(normalizedKeyword)
日本語だけならあまり気にしなくても大丈夫ですが、英語が入ると役立ちます。
4. 0件のときに何も出ない
0件のときは、メッセージを出すと親切です。
{filteredSpots.length === 0 ? (
<p>条件に合うスポットがありません。</p>
) : null}
12. 今日覚えること
今回覚えることは、これだけです。
検索欄の値を state で持つ
カテゴリの値を state で持つ
filter で条件に合うデータだけ選ぶ
length で件数を表示する
0件のときはメッセージを出す
特に大事なのは、この流れです。
検索条件を入力する
↓
state が変わる
↓
filter で一覧がしぼられる
↓
画面の表示が変わる
13. ここまでのまとめ
この節では、一覧画面に検索と絞り込みを追加しました。
できるようになったことは、次の4つです。
- キーワード検索
- カテゴリ絞り込み
- 条件に合った件数表示
- 0件のときのメッセージ表示
検索と絞り込みがあると、一覧画面は一気に使いやすくなります。
投稿が増えても、目的のスポットを探しやすくなるからです。
練習問題
問題1
キーワード検索の入力値を保存している state は何ですか。
問題2
条件に合ったデータだけを取り出すときに使った配列のメソッドは何ですか。
問題3
件数を表示するときに使ったプロパティは何ですか。
問題4
カテゴリが「すべて」のときは、どのように表示されますか。
回答
問題1の回答
keyword
問題2の回答
filter
問題3の回答
length
問題4の回答
すべてのスポットが表示されます。
次に進む前のチェック
次へ進む前に、次の4つを確認してください。
- 検索欄に文字を入れると一覧が変わる
- カテゴリを選ぶと一覧が変わる
- 件数が表示される
- 0件のときにメッセージが出る ここまでできればOKです。
次は、副作用処理や画面更新の考え方を、もう少し整理して学んでいきます。
