Membuat Komponen Search di Next.js App Router

Membuat Komponen Search di Next.js App Router

Zakiego

Zakiego

@zakiego

Abstrak

Membuat komponen Search agaknya gampang-gampang susah, namun semenjak munculnya App Router, menjadi lumayan susah.

Melalui artikel ini, kita akan belajar bagaimana membuat komponen <Search/> yang pada setiap perubahannya, akan men-trigger perubahan di search params, seperti /search?query=something.

Kita akan menggunakan bantuan next-query-params untuk membuatnya terhubung dengan search params, dengan mudah. Kemudian menggunakan use-debounce untuk menambahkan debounce. Dan terakhir, kita akan membuatnya lebih sungguhan dengan melakukan fetch ke dummy API, dengan tambahan pengaman dari zod.

Kode bisa dilihat pada repository zakiego/seach-params-playground

Dan kamu bisa mencobanya langsung pada seach-params-playground.vercel.app

Pendahuluan

Ada banyak cara untuk membuat komponen search. Cara paling masyhur adalah menggunakan onChange dari komponen <input/>, lalu menyimpannya dengan useState.

Sayangnya, jika menggunakan Next.js App Router, kita tidak bisa menggunakan useState begitu saja. Karena server component tidak memperbolehkan itu.

Sebab itu, melalui artikel ini kita akan belajar membuat komponen <Input/> dan menghubungkannya langsung dengan searchParams.

Apa itu searchParams?

Contoh paling sederhana, saat kita mengetik di kolom pencarian, lalu URL berubah dari /search menjadi search?query=sepeda. query itulah yang dimaksud searchParams.

Value dari searchParams bisa didapat dari setiap file page.tsx. Bentuknya bisa berupa string, array of string, atau undefined.

// https://nextjs.org/docs/app/api-reference/file-conventions/page

export default function Page({
  params,
  searchParams,
}: {
  params: { slug: string }
  searchParams: { [key: string]: string | string[] | undefined }
}) {
  return <h1>My Page</h1>
}

Komponen Input Sederhana

Pertama, kita akan membuat halaman /search terlebih dahulu. Untuk itu, buat file /src/app/search/page.tsx.

// src/app/search/page.tsx

interface Props {
  searchParams: {
    query?: string;
  };
}

export default function Page(props: Props) {
  const { searchParams } = props;
  const { query } = searchParams;

  return (
    <div>
      <div>Query: {query}</div>
      <input className="ring-1 ring-gray-300 rounded-md" />
    </div>
  );
}

Dari kode di atas, kita mengambil searchParams dari props, kemudian mengambil query dari searchParams.

Sebenarnya, searchParams bisa kita namai apa pun, misalnya ?q= atau ?keyword, suka-suka kita.

Seandainya menggunakan client component, kita bisa langsung mengambil value dari <input/> menggunakan onChange. Contohnya seperti berikut:

<input
  onChange={(e) => {
    console.log(e.target.value);
  }}
/>

Namun yang terjadi jika menerapkannya pada server component (default dari page.tsx) akan menghasilkan error.

Solusi

Untuk mengakalinya, kita akan memindahkan komponen <input> ke dalam client component. Buat file /src/app/search/client.tsx. Kita akan mengumpulkan semua client component di sini.

Tambahkan "use client" di baris paling atas kode, untuk menjadikan semua komponen di file ini adalah client component.

"use client";

export const SearchInput = () => {
  return (
    <input
      className="ring-1 ring-gray-300 rounded-md min-w-full p-2"
      onChange={(e) => {
        console.log(e.target.value);
      }}
    />
  );
};

Jika sudah, saatnya kita mengimport komponen <SearchInput/> ke dalam page.tsx tadi.

// src/app/search/page.tsx

import { SearchInput } from "@/app/search/client";

interface Props {
  searchParams: {
    query?: string;
  };
}

export default function Page(props: Props) {
  const { searchParams } = props;
  const { query } = searchParams;

  return (
    <div>
      <SearchInput />
      <div>Query: {query}</div>
    </div>
  );
}

Dan hasilnya adalah berikut, kita berhasil menggunakan onChange.

Menghubungkan ke Search Params

Sebenarnya, kita bisa menggunakan fitur searchParams bawaan dari Next.js, kodenya akan seperti berikut:

// https://nextjs.org/learn/dashboard-app/adding-search-and-pagination#2-update-the-url-with-the-search-params

'use client';
 
import { useSearchParams, usePathname, useRouter } from 'next/navigation';
 
export default function Search() {
  const searchParams = useSearchParams();
  const pathname = usePathname();
  const { replace } = useRouter();
 
  function handleSearch(term: string) {
    const params = new URLSearchParams(searchParams);
    if (term) {
      params.set('query', term);
    } else {
      params.delete('query');
    }
    replace(`${pathname}?${params.toString()}`);
  }
}

Namun, saya bukan orang yang rajin untuk menulis useSearchParams, usePathname, dan useRouter berulang-ulang. 🤷🏻‍♂️

Untuk itulah, kita akan menggunakan next-query-params

Penggunaannya sangat mirip dengan useState, kita hanya perlu membuat const [name, setName] = useQueryParam(), berikut contohnya:

// https://www.npmjs.com/package/next-query-params#usage

import {useQueryParam, StringParam, withDefault} from 'use-query-params';

export default function IndexPage() {
  const [name, setName] = useQueryParam('name', withDefault(StringParam, ''));

  function onNameInputChange(event) {
    setName(event.target.value);
  }

  return (
    <p>My name is <input value={name} onChange={onNameInputChange} /></p>
  );
}

Mari kita mulai.

Pertama, install terlebih dahulu.

pnpm add next-query-params use-query-params

Kemudian kita membuat file /src/app/providers.tsx.

Secara default, App Router menggunakan server component. Sedangkan, ada beberapa komponen yang harus menggunakan client component.

File providers.tsx ini lazim digunakan oleh beberapa library seperti Chakra UI dan Next UI. Tujuannya agar file layout.tsx utama tetap menggunakan server component, dan cukup providers.tsx yang menggunakan client component.

// src/app/providers.tsx

"use client";

import { Suspense } from "react";

import NextAdapterApp from "next-query-params/app";
import { QueryParamProvider } from "use-query-params";

export function Providers({ children }: { children: React.ReactNode }) {
  return (
    <>
      <Suspense>
        <QueryParamProvider adapter={NextAdapterApp}>
          {children}
        </QueryParamProvider>
      </Suspense>
    </>
  );
}

Setelah itu, saatnya kita meng-import <Providers/> ke dalam /src/app/layout.tsx.

// src/app/layout.tsx

import { Providers } from "@/app/providers";

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  );
}

Terakhir, update komponen <SearchInput/> yang telah kita buat sebelumnya pada file /src/app/search/client.tsx

Tambahkan useQueryParam. Karena kita menggunakan nama "query" maka, menjadi useQueryParam("query").

StringParam akan membantu mengubah value dari query otomatis menjadi string. Selain string, ada juga helper untuk number (NumberParam) dan array (ArrayParam).

"use client";

import { StringParam, useQueryParam } from "use-query-params";

export const SearchInput = () => {
  const [query, setQuery] = useQueryParam("query", StringParam);

  return (
    <input
      className="ring-1 ring-gray-300 rounded-md min-w-full p-2"
      onChange={(e) => {
        setQuery(e.target.value);
      }}
    />
  );
};

Selesai.

Setiap kali kita mengetikkan value di komponen input, maka URL akan berubah menjadi /search?query=halo. Dan jika kita hapus, maka akan terhapus pula.

Kamu dapat mencobanya di sini seach-params-playground.vercel.app/search?query=laptop.

Poles Dengan Debounce

Setiap kali kita mengetikkan sesuatu di komponen input, maka search params akan berubah, seketika. Sayangnya, hal ini memiliki dampak kurang bagus.

Bayangkan sebuah komponen search yang terhubung dengan API. Setiap user mengetikkan satu huruf, maka satu kali fetch ke endpoint dilakukan. Lalu apa jadinya, jika user mengetikkan 10 huruf? Akan ada 10 fetch ke endpoint. Ini bukan hal yang baik. Untuk itu, kita perlu menunggu user selesai mengetikkan pencariannya, barulah kita melakukan fetch.

Alur kerja "menuggu" ini biasa kita sebut debounce.

Untuk menambahkannya ke komponen input, mari kita menggunakan use-debounce (cara instan).

pnpm add use-debounce

Mari update komponen <SearchInput/> menggunakan useDebouncedCallback.

"use client";

import { StringParam, useQueryParam } from "use-query-params";
import { useDebouncedCallback } from "use-debounce";

export const SearchInput = () => {
  const [query, setQuery] = useQueryParam("query", StringParam);

  const handleChange = useDebouncedCallback((value: string) => {
    setQuery(value);
  }, 500); // lama waktu menunggu

  return (
    <input
      className="ring-1 ring-gray-300 rounded-md min-w-full p-2"
      onChange={(e) => {
        handleChange(e.target.value);
      }}
    />
  );
};

Selesai.

Kita tidak langsung menjalankan setQuery setiap terjadi change, namun kita membungkusnya ke dalam useDebouncedCallback dan menunggunya selama 500 ms (0,5 detik), barulah search params akan terupdate.

Fetch Data Sebenarnya

Tak lengkap rasanya, jika kita tidak membuat implementasi sungguhan dari komponen ini.

Sebelum itu, tambahkan dulu zod.

pnpm add zod

Lalu, pada file /src/app/search/page.tsx ubah menjadi berikut, tambahkan function getData. Kita akan menggunakan data dummy.

// /src/app/search/page.tsx

import { z } from “zod”

const getData = async (query: string | undefined) => {
  const schema = z.object({
    products: z.array(
      z.object({
        id: z.number(),
        title: z.string(),
        description: z.string(),
        price: z.number(),
        discountPercentage: z.number(),
        rating: z.number(),
        stock: z.number(),
        brand: z.string(),
        category: z.string(),
        thumbnail: z.string(),
        images: z.array(z.string()),
      }),
    ),
    total: z.number(),
    skip: z.number(),
    limit: z.number(),
  });

  const response = await fetch(
    `https://dummyjson.com/products/search?q=${query}&limit=10`,
  );

  const data = await response.json();

  return schema. Parse(data);
};

export default async function Page(props: Props) {
  const { searchParams } = props;
  const { query } = searchParams;

  const data = await getData(query);

  return (
    <div>
      <div className="mb-2">Query: {query}</div>

      <SearchInput />

      <div className="grid grid-cols-2 gap-4">
        {data.products.map((product) => (
          <div
            key={product.id}
            className="rounded-md border border-gray-300 p-4 my-4"
          >
            <h2 className="text-xl">{product.title}</h2>
            <p className="text-gray-500">Category: {product.category}</p>
            <img
              className="rounded-md w-48 h-48 object-cover"
              src={product.thumbnail}
              alt={product.title}
            />
          </div>
        ))}
      </div>
    </div>
  );
}

Selesai.

Kamu bisa mencobanya di link berikut seach-params-playground.vercel.app/search.

Penutup

Semenjak munculnya App Router, banyak paradigma lama yang harus dirombak, salah satunya adanya pemisahan client component dan server component.

Awalnya terasa rumit memang. Namun semakin ke sini, saya justru semakin menikmatinya.

Dari awalnya pembenci App Router, menjadi pengguna setiap App Router.

Lanjutan tentang search params, mungkin kamu tertarik membaca tentang Search Params dengan nuqs di Next.js


Selesai ditulis di Pelaihari, 8 April 2024 pada 00.29 AM.

Selesai direview pada 00.47 AM.