Membuat Komponen Search di Next.js App Router
Zakiego
@zakiego
Daftar Isi
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.