🥘

Next js 3分クッキング(実装) 〜ViewのFiltering 切り替え表示〜

2024/07/24に公開

はじめに

View側で表示するComponentのFiltering実装についてサクッと実装する方法について師匠に教えて貰ったので備忘録も兼ねて3分クッキング風に残しておきたいと思います。

結論

こんな感じで検索できるようになります。

以下、Vercelです
毎回、取得して表示ではなくCacheがあればそれを出すようにしてます

https://starwars-bm7w416zm-ssp0727lncs-projects.vercel.app/cuisines

面倒だったのでVercelは前回記事と同じApp内に実装して参照のPageだけかえました🙇(手抜き)

前回記事は以下です(よければこちらも)
https://zenn.dev/dk_/articles/5fd18603ace7b9

材料

名称 version 備考
Nex.js 14.2.5 Reactでもよい
@tanstack/react-query 5.51.11 必須
use-debounce 10.0.1 自分で実装するならなくても良い
tailwind css 3.4.1 それっぽい見た目の為に必要なだけなので無くても良い

下準備

各Component等を準備

TanstackProvider.tsx
'use client';

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { type ReactNode } from 'react';

type TanstackProviderProps = {
  children: ReactNode;
};

const TanstackProvider = ({ children }: TanstackProviderProps) => {
  const queryClient = new QueryClient();
  return (
    <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
  );
};

export default TanstackProvider;

CuisineList.tsx
import Image from 'next/image';

import { Cuisines } from '@/type/cuisine';

type CuisineListProps = {
  cuisines: Cuisines[];
};

export const CuisineList = ({ cuisines }: CuisineListProps) => {
  return (
    <div className="flex flex-row flex-wrap gap-4">
      {cuisines.map((cuisine) => (
        <div
          key={cuisine.id}
          className="flex w-[250px] flex-col gap-4 rounded-md bg-gray-700 p-4"
        >
          <div className="flex flex-row justify-between">
            <div>
              <h2 className="text-xl font-bold">{cuisine.name}</h2>
              <p className="opacity-50">{cuisine.category}</p>
            </div>
            <p>${cuisine.price}</p>
          </div>
          <Image
            src={cuisine.image}
            alt={cuisine.name}
            width={150}
            height={150}
            className="rounded-md"
          />
        </div>
      ))}
    </div>
  );
};

Cuisines.ts
export type Cuisines = {
  id: number;
  name: string;
  category?: 'Japanese' | 'Italian' | 'French';
  price: number;
  image: string;
};
CuisinesPage
'use client';

import { useState } from 'react';

import { CuisineList } from '@/components/cuisines/CuisinesList';
import { CuisinesListFilter } from '@/components/cuisines/CuisinesListFilter';

const CuisinesPage = () => {
  return (
    <div className="flex flex-col gap-2">
      <div>
        <h1 className="text-4xl font-bold">Cuisines</h1>
      </div>
      <CuisinesListFilter />
      <div>
        <CuisineList />
      </div>
    </div>
  );
};

export default CuisinesPage;

擬似endpointを準備

/api/cuisines.ts
import { CUISINES } from './data/cuisinesData';

export type CuisineFilters = {
  category?: 'Japanese' | 'Italian' | 'French';
  maxPrice?: number;
  search?: string;
};

export type Cuisine = {
  id: number;
  name: string;
  category?: 'Japanese' | 'Italian' | 'French';
  price: number;
  image: string;
};

export const fetchCuisines = async (
  options?: CuisineFilters
): Promise<Cuisine[]> => {
  await new Promise((resolve) => setTimeout(resolve, 1000));

  let filteredCuisines = CUISINES;

  if (options?.category) {
    filteredCuisines = filteredCuisines.filter((cuisine) => {
      return cuisine.category === options.category;
    });
  }

  if (options?.maxPrice) {
    filteredCuisines = filteredCuisines.filter((cuisine) => {
      return cuisine.price <= (options.maxPrice as number);
    });
  }

  if (options?.search) {
    filteredCuisines = filteredCuisines.filter((cuisine) => {
      return cuisine.name.toLowerCase().includes(options.search!.toLowerCase());
    });
  }

  return filteredCuisines;
};

/api/data/cusinesData.ts
import { Cuisine } from '../cuisines';

export const CUISINES: Cuisine[] = [
  {
    id: 1,
    name: 'Food 1',
    category: 'Japanese',
    price: 10,
    image: 'https://via.placeholder.com/150',
  },
  {
    id: 2,
    name: 'Food 2',
    category: 'Italian',
    price: 20,
    image: 'https://via.placeholder.com/150',
  },
  {
    id: 3,
    name: 'Food 3',
    category: 'French',
    price: 30,
    image: 'https://via.placeholder.com/150',
  },
  {
    id: 4,
    name: 'Food 4',
    category: 'Japanese',
    price: 40,
    image: 'https://via.placeholder.com/150',
  },
  {
    id: 5,
    name: 'Food 5',
    category: 'Italian',
    price: 50,
    image: 'https://via.placeholder.com/150',
  },
  {
    id: 6,
    name: 'Food 6',
    category: 'French',
    price: 60,
    image: 'https://via.placeholder.com/150',
  },
  {
    id: 7,
    name: 'Food 7',
    category: 'Japanese',
    price: 70,
    image: 'https://via.placeholder.com/150',
  },
  {
    id: 8,
    name: 'Food 8',
    category: 'Italian',
    price: 80,
    image: 'https://via.placeholder.com/150',
  },
  {
    id: 9,
    name: 'Food 9',
    category: 'French',
    price: 90,
    image: 'https://via.placeholder.com/150',
  },
  {
    id: 10,
    name: 'Food 10',
    category: 'Japanese',
    price: 100,
    image: 'https://via.placeholder.com/150',
  },
];

調理開始

手順 その1

Pageに検索で使用する値をuseStateで設定します。

const [search, setSearch] = useState<CuisineFilters['search']>();
const [category, setCategory] = useState<CuisineFilters['category']>();
const [maxPrice, setMaxPrice] = useState<CuisineFilters['maxPrice']>();

手順 その2

Page内でTanstack queryを使い該当する条件でdataを取得できるようにします。

const { data, isFetching } = useQuery({
 queryKey: ['cuisines', { category, maxPrice, search }],
 queryFn: () => fetchCuisines({ category, maxPrice, search }),
});

手順 その3

CuisinesListFilterのpropsにcallbackイベントをセットしてそれが子Componentで呼ばれたら値親側の値が変わるように設定

<CuisinesListFilter
  onChange={(filters) => {
  setCategory(filters.category);
  setMaxPrice(filters.maxPrice);
  setSearch(filters.search);
 }}
/>

手順 その4

子Component側で切り替えた場合の状態をuseEffectで検知できるようにする。
※文字検索についてはdebounce処理を入れてタイミングを設定する

import { useEffect, useState } from 'react';
import { useDebounce } from 'use-debounce';

import { type CuisineFilters } from '@/app/api/cuisines';

type CuisinesListFilterProps = {
  onChange: (filters: CuisineFilters) => void;
};

export const CuisinesListFilter = ({ onChange }: CuisinesListFilterProps) => {
  const [search, setSearch] = useState<CuisineFilters['search']>();
  const [category, setCategory] = useState<CuisineFilters['category'] | 'all'>(
    'all'
  );
  const [maxPrice, setMaxPrice] = useState<CuisineFilters['maxPrice'] | 'all'>(
    'all'
  );
  const [debouncedSearch] = useDebounce(search, 500);

  useEffect(() => {
    const filters: CuisineFilters = {
      category: category === 'all' ? undefined : category,
      maxPrice: maxPrice === 'all' ? undefined : maxPrice,
      search: debouncedSearch,
    };

    onChange(filters);
  }, [category, debouncedSearch, maxPrice, onChange]);

  return (
    <div className="flex flex-row gap-2">
      <input
        type="text"
        value={search}
        onChange={(e) => setSearch(e.target.value)}
        placeholder="Search Cuisines"
      />
      <select
        value={category}
        onChange={(e) =>
          setCategory(e.target.value as CuisineFilters['category'] | 'all')
        }
      >
        <option value="all">all</option>
        <option value="Japanese">Japanese</option>
        <option value="Italian">Italian</option>
        <option value="French">French</option>
      </select>
      <select
        value={maxPrice}
        onChange={(e) =>
          setMaxPrice(
            e.target.value !== 'all' ? parseInt(e.target.value) : 'all'
          )
        }
      >
        <option value="all">all</option>
        <option value="10">$10</option>
        <option value="50">$50</option>
        <option value="100">$100</option>
      </select>
    </div>
  );
};

上記の手順を行うことで親側で子の変更処理に基づいてTanstack queryの処理を発火させることができ、簡単に検索処理の実装ができるようになります。

まとめ

3分クッキングのように「下準備いれたら3分ちゃうやんけ!」という思いはあるものの
簡単にFilteringを利用したViewの切り替えができるようになるので検索時などの処理をサクッと実装する際の土台として使っていこうと思います。

Repositoryは以下です。(参考になれば)

https://github.com/TaharaKazuki/starwars

余談

料理の単語を調べたら〇〇料理(日本語で言う和食・フレンチ)はちょっとお硬い表現だけどcuisineを使うとでてきたのでこの単語を使ってます

https://www.ntv.co.jp/3min/

今日(執筆時)のレシピはなんだろ?っと思ってみてみたらアクアパッツァでしたw
本家も「3分じゃ無理やろ。。。」的なレシピばっかりだったので一安心ですw

Discussion