iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
🥘

Next.js 3-Minute Recipe: Implementing View Filtering and Toggle Displays

に公開

Introduction

I was taught a way to quickly implement filtering for components displayed on the View side by my mentor, so I'd like to leave it here as a "3-minute cooking" style memo for future reference.

Conclusion

You will be able to search like this.

Below is the Vercel link.
Instead of fetching and displaying every time, it is configured to show the data from the cache if it exists.

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

I got a bit lazy, so for Vercel, I implemented it within the same App as the previous article and just changed the reference page 🙇 (cutting corners).

Previous article is here (check it out if you like):
https://zenn.dev/dk_/articles/5fd18603ace7b9

Ingredients

Name version Remarks
Next.js 14.2.5 React is also fine
@tanstack/react-query 5.51.11 Required
use-debounce 10.0.1 Not necessary if you implement it yourself
tailwind css 3.4.1 Only needed for styling, so it's optional

Preparation

Prepare components, etc.

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;

Prepare a mock 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',
  },
];

Start Cooking

Step 1

Set the values used for searching in the Page using useState.

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

Step 2

Use TanStack Query within the Page to enable data fetching based on the corresponding conditions.

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

Step 3

Set a callback event in the CuisinesListFilter props so that when it is called in the child component, the values on the parent side are updated.

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

Step 4

Enable the child component to detect state changes using useEffect.
* For text search, implement debounce processing to set the timing.

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>
  );
};

By following the steps above, you can trigger TanStack Query processing in the parent based on change events from the child, making it easy to implement search functionality.

Summary

Just like with "3-minute cooking," you might think, "If you include the preparation, it's not 3 minutes at all!" However, since it allows for easy view switching using filtering, I plan to use this as a foundation for quickly implementing search functionality and similar processes.

The repository is here (hope it helps):

https://github.com/TaharaKazuki/starwars

Side Note

When I looked up words for food, I found that terms for specific styles of cooking (like Japanese or French cuisine) are a bit formal, but the word cuisine came up, so I'm using that.

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

I wondered what today's recipe (at the time of writing) was and checked; it was Acqua Pazza lol.
Even the original show's recipes were mostly like "There's no way you can do this in 3 minutes...", so I feel relieved lol.

Discussion