Next.jsによるWebアプリケーション開発概論

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

7おすすめスポット共有アプリを作りながら学ぶ 画面遷移設計・状態管理・ユーザー入力処理
Next.jsTailwind CSSアプリ開発開発Web開発

はじめに

この節では、スポット一覧画面をもっと使いやすくします。

今までの一覧画面は、スポットをただ並べるだけでした。

でも、投稿が増えると、見たいスポットを探すのが大変になります。

そこで今回は、次の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]);

初心者のうちは、こう考えれば大丈夫です。

keywordselectedCategory が変わったときだけ、絞り込みをやり直す。

今回のような小さいアプリでは必須ではありません。

ただ、一覧データが増えたときにも使いやすい書き方なので、ここで慣れておくと便利です。


10. 一覧画面をより便利にする工夫

検索と絞り込みができるだけでも便利ですが、さらに使いやすくするなら、次のような工夫があります。

検索欄に例を表示する
件数を表示する
条件に合わないときのメッセージを出す
カテゴリを選択式にする
カードを見やすく並べる

今回のコードでは、すでに次の工夫を入れています。

プレースホルダー
カテゴリ選択
件数表示
0件のときのメッセージ
詳細ページへのリンク

これだけでも、かなり使いやすくなります。


11. よくあるつまずき

1. 検索しても変わらない

inputvalueonChange を確認してください。

value={keyword}
onChange={(event) => {
  setKeyword(event.target.value);
}}

2. カテゴリを変えても絞り込まれない

selectvalueonChange を確認してください。

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です。

次は、副作用処理や画面更新の考え方を、もう少し整理して学んでいきます。

教材トップへ戻る