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

お気に入り機能を追加する:気になったスポットを保存して、自分だけの一覧を作る

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

はじめに

この節では、スポット共有アプリに お気に入り機能 を追加します。

お気に入り機能とは、気になったスポットを保存して、あとで見返せるようにする機能です。

たとえば、一覧画面や詳細画面でハートボタンを押すと、そのスポットがお気に入りに入ります。

気になるスポットを見つける
↓
♡ ボタンを押す
↓
お気に入りに保存される
↓
お気に入り一覧で見られる

これができると、アプリがかなり「自分用」に使えるものになります。


この節のゴール

この節が終わると、次のことができるようになります。

  • 気になったスポットをお気に入りに保存できる
  • お気に入りを解除できる
  • 保存済みのお気に入り一覧を表示できる
  • 状態管理を使って、画面にすぐ反映できる
  • お気に入り状態に応じて、ボタンの表示を変えられる 今日の合言葉はこれです。

IDを保存して、画面の見た目を切り替える。


1. お気に入り機能の考え方

お気に入り機能では、スポット全部を保存する必要はありません。

保存するのは、お気に入りにしたスポットのIDだけ で十分です。

たとえば、スポットがこうあるとします。

const spots = [
  { id: 'spot-1', name: '静かな図書館' },
  { id: 'spot-2', name: 'おしゃれカフェ' },
  { id: 'spot-3', name: '学食の穴場席' },
];

この中で、spot-1spot-3 をお気に入りにした場合、保存するのはこれだけです。

['spot-1', 'spot-3']

つまり、お気に入りのIDリスト を持つだけです。


2. なぜIDだけ保存するのか

IDだけ保存する理由は、シンプルです。

データが軽い
管理しやすい
同じスポット情報を二重に持たなくてよい

スポットの詳しい情報は、すでに spots の中にあります。

お気に入りでは、「どれを保存したか」だけわかれば十分です。


3. 保存場所を決める

今回は、前の節と同じく localStorage を使います。

保存するキーは、次のようにします。

const FAVORITES_STORAGE_KEY = 'spot-share-favorite-ids';

ここに、お気に入りIDの配列を保存します。

['spot-1', 'spot-3']

localStorage には文字列しか保存できないので、保存するときは JSON.stringify、読み込むときは JSON.parse を使います。

localStorage.setItem(FAVORITES_STORAGE_KEY, JSON.stringify(favoriteIds));

const savedValue = localStorage.getItem(FAVORITES_STORAGE_KEY);
const favoriteIds = savedValue ? JSON.parse(savedValue) : [];

4. 保存処理を追加する

まず、lib/spot-storage.ts にお気に入り用の関数を追加します。

すでに getAllSpotscreateSpot があるファイルに、以下を追記してください。

lib/spot-storage.ts** に追記**

const FAVORITES_STORAGE_KEY = 'spot-share-favorite-ids';

/**
 * 役割: お気に入りスポットID一覧を取得する
 * 入力: なし
 * 出力: お気に入りに登録されているスポットID配列
 */
export function getFavoriteSpotIds(): string[] {
  if (!isBrowser()) {
    return [];
  }

  const storedValue = window.localStorage.getItem(FAVORITES_STORAGE_KEY);

  if (storedValue === null) {
    return [];
  }

  try {
    const parsedValue = JSON.parse(storedValue) as string[];

    if (!Array.isArray(parsedValue)) {
      return [];
    }

    return parsedValue;
  } catch {
    return [];
  }
}

/**
 * 役割: お気に入りスポットID一覧を保存する
 * 入力: favoriteSpotIds - 保存したいお気に入りID配列
 * 出力: なし
 */
function saveFavoriteSpotIds(favoriteSpotIds: string[]): void {
  if (!isBrowser()) {
    return;
  }

  window.localStorage.setItem(
    FAVORITES_STORAGE_KEY,
    JSON.stringify(favoriteSpotIds),
  );
}

/**
 * 役割: 指定したスポットIDのお気に入り状態を切り替える
 * 入力: spotId - お気に入りに追加または解除するスポットID
 * 出力: 切り替え後にお気に入りなら true、解除されたなら false
 */
export function toggleFavoriteSpot(spotId: string): boolean {
  const currentFavoriteSpotIds = getFavoriteSpotIds();

  const isAlreadyFavorite = currentFavoriteSpotIds.includes(spotId);

  if (isAlreadyFavorite) {
    const nextFavoriteSpotIds = currentFavoriteSpotIds.filter(
      (currentSpotId) => {
        return currentSpotId !== spotId;
      },
    );

    saveFavoriteSpotIds(nextFavoriteSpotIds);

    return false;
  }

  saveFavoriteSpotIds([...currentFavoriteSpotIds, spotId]);

  return true;
}

5. お気に入り状態を画面で持つ

次に、一覧ページでお気に入り状態を使います。

お気に入りIDの配列を state で持ちます。

const [favoriteSpotIds, setFavoriteSpotIds] = useState<string[]>(() =>
  getFavoriteSpotIds(),
);

この favoriteSpotIds には、今お気に入りになっているスポットIDが入ります。

['spot-1', 'spot-3']

6. 一覧ページにお気に入りボタンを追加する

app/spots/page.tsx を、お気に入りボタン付きにします。

app/spots/page.tsx

'use client';

import Link from 'next/link';
import { useState } from 'react';
import {
  getAllSpots,
  getFavoriteSpotIds,
  toggleFavoriteSpot,
} from '@/lib/spot-storage';
import type { Spot } from '@/lib/spot-types';

/**
 * 役割: 保存済みスポットの一覧を表示し、お気に入り追加・解除を行う
 * 入力: なし
 * 出力: お気に入りボタン付きのスポット一覧画面
 */
export default function SpotsPage(): JSX.Element {
  const [spots] = useState<Spot[]>(() => getAllSpots());
  const [favoriteSpotIds, setFavoriteSpotIds] = useState<string[]>(() =>
    getFavoriteSpotIds(),
  );

  /**
   * 役割: 指定したスポットのお気に入り状態を切り替え、画面に反映する
   * 入力: spotId - 対象スポットID
   * 出力: なし
   */
  const handleToggleFavorite = (spotId: string): void => {
    const isFavoriteNow = toggleFavoriteSpot(spotId);

    setFavoriteSpotIds((currentFavoriteSpotIds) => {
      if (isFavoriteNow) {
        return [...currentFavoriteSpotIds, spotId];
      }

      return currentFavoriteSpotIds.filter((currentSpotId) => {
        return currentSpotId !== spotId;
      });
    });
  };

  return (
    <main style={{ maxWidth: '800px', margin: '0 auto', padding: '32px' }}>
      <h1>スポット一覧</h1>
      <p>気になったスポットは、お気に入りに保存できます。</p>

      <div style={{ display: 'flex', gap: '16px', marginTop: '24px' }}>
        <Link href="/spots/new">+ 新しいスポットを投稿する</Link>
        <Link href="/favorites">お気に入り一覧を見る</Link>
      </div>

      <section
        style={{
          display: 'grid',
          gap: '16px',
          marginTop: '24px',
        }}
      >
        {spots.map((spot) => {
          const isFavorite = favoriteSpotIds.includes(spot.id);

          return (
            <article
              key={spot.id}
              style={{
                display: 'grid',
                gridTemplateColumns: '120px 1fr',
                gap: '16px',
                border: '1px solid #ddd',
                borderRadius: '12px',
                padding: '16px',
              }}
            >
              {spot.imageUrl.length > 0 ? (
                <img
                  src={spot.imageUrl}
                  alt={spot.name}
                  style={{
                    width: '120px',
                    height: '90px',
                    objectFit: 'cover',
                    borderRadius: '8px',
                  }}
                />
              ) : (
                <div
                  style={{
                    width: '120px',
                    height: '90px',
                    display: 'grid',
                    placeItems: 'center',
                    borderRadius: '8px',
                    background: '#f5f8fa',
                    color: '#666',
                  }}
                >
                  画像なし
                </div>
              )}

              <div>
                <h2>{spot.name}</h2>
                <p>
                  {spot.category}・{spot.location}
                </p>
                <p>{spot.description}</p>

                <div style={{ display: 'flex', gap: '12px' }}>
                  <Link href={`/spots/${spot.id}`}>詳細を見る</Link>

                  <button
                    type="button"
                    onClick={() => {
                      handleToggleFavorite(spot.id);
                    }}
                    style={{
                      border: '1px solid #ddd',
                      borderRadius: '999px',
                      padding: '6px 12px',
                      background: isFavorite ? '#fdf3f3' : '#fff',
                      color: isFavorite ? '#d13e5c' : '#08131a',
                      cursor: 'pointer',
                    }}
                  >
                    {isFavorite ? '♥ お気に入り済み' : '♡ お気に入り'}
                  </button>
                </div>
              </div>
            </article>
          );
        })}
      </section>
    </main>
  );
}

7. 何が起きているか

この部分で、今お気に入りかどうかを調べています。

const isFavorite = favoriteSpotIds.includes(spot.id);

favoriteSpotIds の中に、今のスポットIDが入っていれば、お気に入り済みです。

入っている
→ ♥ お気に入り済み

入っていない
→ ♡ お気に入り

UIはここで切り替えています。

{isFavorite ? '♥ お気に入り済み' : '♡ お気に入り'}

8. 詳細ページにもお気に入りボタンを追加する

一覧ページだけでなく、詳細ページからもお気に入りにできるようにします。

app/spots/[id]/page.tsx

'use client';

import Link from 'next/link';
import { notFound, useRouter } from 'next/navigation';
import { use, useMemo, useState } from 'react';
import {
  deleteSpot,
  getFavoriteSpotIds,
  getSpotById,
  toggleFavoriteSpot,
} from '@/lib/spot-storage';
import type { Spot } from '@/lib/spot-types';

type SpotDetailPageProps = {
  params: Promise<{
    id: string;
  }>;
};

/**
 * 役割: スポット詳細ページを表示し、削除とお気に入り切り替えを行う
 * 入力: params - URLに含まれるスポットID
 * 出力: スポット詳細画面
 */
export default function SpotDetailPage({
  params,
}: SpotDetailPageProps): JSX.Element {
  const router = useRouter();
  const { id } = use(params);

  const [spot] = useState<Spot | null>(() => getSpotById(id));
  const [isFavorite, setIsFavorite] = useState<boolean>(() =>
    getFavoriteSpotIds().includes(id),
  );

  const formattedDate = useMemo(() => {
    if (spot === null) {
      return '';
    }

    return new Date(spot.updatedAt).toLocaleString('ja-JP');
  }, [spot]);

  /**
   * 役割: 表示中スポットのお気に入り状態を切り替える
   * 入力: なし
   * 出力: なし
   */
  const handleToggleFavorite = (): void => {
    const isFavoriteNow = toggleFavoriteSpot(id);
    setIsFavorite(isFavoriteNow);
  };

  /**
   * 役割: 表示中のスポットを削除して一覧へ戻る
   * 入力: なし
   * 出力: なし
   */
  const handleDelete = (): void => {
    const shouldDelete = window.confirm('このスポットを削除しますか?');

    if (!shouldDelete) {
      return;
    }

    deleteSpot(id);
    router.push('/spots');
  };

  if (spot === null) {
    notFound();
  }

  return (
    <main style={{ maxWidth: '720px', margin: '0 auto', padding: '32px' }}>
      <Link href="/spots">← 一覧へ戻る</Link>

      <article style={{ marginTop: '24px' }}>
        {spot.imageUrl.length > 0 ? (
          <img
            src={spot.imageUrl}
            alt={spot.name}
            style={{
              width: '100%',
              height: '260px',
              objectFit: 'cover',
              borderRadius: '12px',
            }}
          />
        ) : null}

        <p
          style={{
            display: 'inline-block',
            marginTop: '16px',
            padding: '4px 10px',
            borderRadius: '999px',
            background: '#e6f6f2',
            color: '#1e7b65',
            fontWeight: 700,
          }}
        >
          {spot.category}
        </p>

        <h1>{spot.name}</h1>
        <p>場所: {spot.location}</p>
        <p>更新日: {formattedDate}</p>
        <p style={{ lineHeight: 1.8 }}>{spot.description}</p>

        <div
          style={{
            display: 'flex',
            flexWrap: 'wrap',
            gap: '12px',
            marginTop: '24px',
          }}
        >
          <button
            type="button"
            onClick={handleToggleFavorite}
            style={{
              border: '1px solid #ddd',
              borderRadius: '999px',
              padding: '8px 14px',
              background: isFavorite ? '#fdf3f3' : '#fff',
              color: isFavorite ? '#d13e5c' : '#08131a',
              cursor: 'pointer',
              fontWeight: 700,
            }}
          >
            {isFavorite ? '♥ お気に入り解除' : '♡ お気に入りに追加'}
          </button>

          <Link href={`/spots/${spot.id}/edit`}>編集する</Link>

          <button
            type="button"
            onClick={handleDelete}
            style={{
              border: 'none',
              background: '#fdf3f3',
              color: '#b22323',
              cursor: 'pointer',
              fontWeight: 700,
            }}
          >
            削除する
          </button>
        </div>
      </article>
    </main>
  );
}

9. お気に入り一覧ページを作る

次に、お気に入りにしたスポットだけを表示するページを作ります。

作るファイルはここです。

app/favorites/page.tsx

app/favorites/page.tsx

'use client';

import Link from 'next/link';
import { useState } from 'react';
import {
  getAllSpots,
  getFavoriteSpotIds,
  toggleFavoriteSpot,
} from '@/lib/spot-storage';
import type { Spot } from '@/lib/spot-types';

/**
 * 役割: お気に入りに登録されたスポットだけを一覧表示する
 * 入力: なし
 * 出力: お気に入り一覧画面
 */
export default function FavoritesPage(): JSX.Element {
  const [favoriteSpotIds, setFavoriteSpotIds] = useState<string[]>(() =>
    getFavoriteSpotIds(),
  );

  const [allSpots] = useState<Spot[]>(() => getAllSpots());

  const favoriteSpots = allSpots.filter((spot) => {
    return favoriteSpotIds.includes(spot.id);
  });

  /**
   * 役割: お気に入り一覧から対象スポットを解除する
   * 入力: spotId - 対象スポットID
   * 出力: なし
   */
  const handleRemoveFavorite = (spotId: string): void => {
    toggleFavoriteSpot(spotId);

    setFavoriteSpotIds((currentFavoriteSpotIds) => {
      return currentFavoriteSpotIds.filter((currentSpotId) => {
        return currentSpotId !== spotId;
      });
    });
  };

  return (
    <main style={{ maxWidth: '800px', margin: '0 auto', padding: '32px' }}>
      <Link href="/spots">← スポット一覧へ戻る</Link>

      <h1>お気に入り一覧</h1>
      <p>お気に入りに追加したスポットだけを表示します。</p>

      {favoriteSpots.length === 0 ? (
        <section
          style={{
            marginTop: '24px',
            padding: '24px',
            border: '1px solid #ddd',
            borderRadius: '12px',
            background: '#f5f8fa',
          }}
        >
          <p>まだお気に入りがありません。</p>
          <Link href="/spots">スポット一覧から探してみる</Link>
        </section>
      ) : (
        <section
          style={{
            display: 'grid',
            gap: '16px',
            marginTop: '24px',
          }}
        >
          {favoriteSpots.map((spot) => {
            return (
              <article
                key={spot.id}
                style={{
                  display: 'grid',
                  gridTemplateColumns: '120px 1fr',
                  gap: '16px',
                  border: '1px solid #ddd',
                  borderRadius: '12px',
                  padding: '16px',
                }}
              >
                {spot.imageUrl.length > 0 ? (
                  <img
                    src={spot.imageUrl}
                    alt={spot.name}
                    style={{
                      width: '120px',
                      height: '90px',
                      objectFit: 'cover',
                      borderRadius: '8px',
                    }}
                  />
                ) : (
                  <div
                    style={{
                      width: '120px',
                      height: '90px',
                      display: 'grid',
                      placeItems: 'center',
                      borderRadius: '8px',
                      background: '#f5f8fa',
                      color: '#666',
                    }}
                  >
                    画像なし
                  </div>
                )}

                <div>
                  <h2>{spot.name}</h2>
                  <p>
                    {spot.category}・{spot.location}
                  </p>
                  <p>{spot.description}</p>

                  <div style={{ display: 'flex', gap: '12px' }}>
                    <Link href={`/spots/${spot.id}`}>詳細を見る</Link>

                    <button
                      type="button"
                      onClick={() => {
                        handleRemoveFavorite(spot.id);
                      }}
                      style={{
                        border: '1px solid rgba(209, 62, 92, 0.24)',
                        borderRadius: '999px',
                        padding: '6px 12px',
                        background: '#fdf3f3',
                        color: '#d13e5c',
                        cursor: 'pointer',
                      }}
                    >
                      ♥ 解除する
                    </button>
                  </div>
                </div>
              </article>
            );
          })}
        </section>
      )}
    </main>
  );
}

10. 動かして確認する

開発サーバーを起動します。

npm run dev

ブラウザで開きます。

http://localhost:3000/spots

次の順番で確認してください。

1. スポット一覧を開く
2. どれか1つの「♡ お気に入り」を押す
3. ボタンが「♥ お気に入り済み」に変わる
4. 「お気に入り一覧を見る」を押す
5. 追加したスポットだけが表示される
6. 「解除する」を押す
7. お気に入り一覧から消える

これができれば成功です。


11. 状態管理を使って画面に反映する

お気に入り機能で大切なのは、保存だけではありません。

保存したあと、画面の見た目もすぐ変えること が大事です。

この部分で、画面上の state を更新しています。

setFavoriteSpotIds((currentFavoriteSpotIds) => {
  if (isFavoriteNow) {
    return [...currentFavoriteSpotIds, spotId];
  }

  return currentFavoriteSpotIds.filter((currentSpotId) => {
    return currentSpotId !== spotId;
  });
});

これによって、クリックした直後にボタンの文字が変わります。

♡ お気に入り
↓
♥ お気に入り済み

12. ユーザー操作に応じてUIを変える

UIを変えているのは、この部分です。

{isFavorite ? '♥ お気に入り済み' : '♡ お気に入り'}

isFavoritetrue なら、お気に入り済み。false なら、まだお気に入りではありません。

さらに、色も変えています。

background: isFavorite ? '#fdf3f3' : '#fff',
color: isFavorite ? '#d13e5c' : '#08131a',

これで、ユーザーが「今どの状態なのか」を見てわかります。


13. よくあるつまずき

1. ボタンを押しても見た目が変わらない

localStorage だけ更新して、state を更新していない可能性があります。

保存だけでは、画面は自動では変わりません。

画面に反映するには setFavoriteSpotIds が必要です。


2. お気に入り一覧に出ない

保存しているIDと、スポットのIDが合っているか確認してください。

favoriteSpotIds.includes(spot.id)

この条件が true になったスポットだけが、お気に入り一覧に出ます。


3. お気に入り解除しても残る

解除時には、filter を使ってIDを取り除きます。

return currentFavoriteSpotIds.filter((currentSpotId) => {
  return currentSpotId !== spotId;
});

4. ページを更新すると消える

localStorage に保存できているか確認してください。

window.localStorage.setItem(
  FAVORITES_STORAGE_KEY,
  JSON.stringify(favoriteSpotIds),
);

14. 今日覚えること

今回覚えることは、次の4つです。

お気に入りはIDの配列で管理する
localStorage に保存するとリロードしても残る
state を更新すると画面がすぐ変わる
条件によってボタンの文字や色を変える

特に大事なのは、この流れです。

ボタンを押す
↓
IDを保存する
↓
stateを更新する
↓
UIが変わる
↓
お気に入り一覧に表示される

ここまでのまとめ

この節では、お気に入り機能を追加しました。

できるようになったことは、次の4つです。

  • 気になったスポットを保存する
  • お気に入りを解除する
  • 保存済み一覧を表示する
  • 操作に応じてボタンの表示を変える

これで、アプリはただの投稿一覧ではなく、自分に合わせて使えるアプリ になりました。


練習問題

問題1

お気に入り機能では、スポット全体ではなく何を保存しましたか。

問題2

お気に入りIDをブラウザに保存するために使ったものは何ですか。

問題3

お気に入り済みかどうかを調べるために使った配列メソッドは何ですか。

問題4

お気に入り解除のとき、IDを取り除くために使った配列メソッドは何ですか。


回答

問題1の回答

スポットのID

問題2の回答

localStorage

問題3の回答

includes

問題4の回答

filter

次に進む前のチェック

次へ進む前に、次の5つを確認してください。

  • 一覧画面でお気に入りに追加できる
  • ボタンの表示が変わる
  • 詳細画面でもお気に入りに追加できる
  • お気に入り一覧に表示される
  • 解除すると一覧から消える ここまでできればOKです。

次は、ローカル状態からデータベース連携へ進むための考え方を整理します。

教材トップへ戻る