概要
自身が開発しているブログアプリに検索機能を追加しました
検索コンポーネントの検索結果に応じて、ページ内のposts
データをフィルタリングするUX
を実現したかったのですが、実装方針に迷いがあり、記事にまとめました。
結論
以下の流れで機能実装しました。
-
SearchBox
(Client Component
)で文字列変更時にrouter.push
-
page.tsx
で検索文字列に応じたposts
を再取得
実装
それでは実装していきます。
今回の実装では、私のアプリで使用している一部コンポーネントの記述を抜き出して解説しています。
適宜自身の環境に置き換えてご確認ください🙇♂️
SearchBox.tsx
検索を行う SearchBox
を実装します。
入力を監視し、デバウンス処理を通じてクエリ付きのURLに遷移することで、検索結果を反映させます。
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import styles from './SearchBox.module.css';
export default function SearchBox() {
const searchParams = useSearchParams();
const [search, setSearch] = useState(searchParams.get('q') || '');
const [debouncedSearch, setDebouncedSearch] = useState(search);
const router = useRouter();
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedSearch(search);
}, 500);
return () => clearTimeout(timer);
}, [search]);
useEffect(() => {
if (debouncedSearch.trim()) {
router.push(`/?q=${debouncedSearch.trim()}`);
} else if (debouncedSearch === '') {
router.push('/');
}
}, [debouncedSearch, router]);
return (
<div className={styles.searchContainer}>
<div className={styles.searchBox}>
<input
type="text"
placeholder="記事を検索..."
className={styles.searchInput}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<div className={styles.searchIcon}>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
<path d="M11.742 10.344a6.5 6.5 0 1 0-1.397 1.398h-.001c.03.04.062.078.098.115l3.85 3.85a1 1 0 0 0 1.415-1.414l-3.85-3.85a1.007 1.007 0 0 0-.115-.1zM12 6.5a5.5 5.5 0 1 1-11 0 5.5 5.5 0 0 1 11 0z"/>
</svg>
</div>
</div>
</div>
);
}
page.tsx
page.tsx
では、URL
のクエリパラメータ(searchParams
)を元に、検索キーワードが存在するかどうかを判定しています。
- 検索キーワードがある場合は
searchPosts()
を実行 - ない場合は通常の
getPosts()
を実行
といった条件分岐で表示する投稿データを切り替えています。
import Card from "@/components/ui/Card/Card";
import styles from './page.module.css';
import Link from "next/link";
import { getPosts, searchPosts } from "@/app/(public)/posts/fetcher";
import { PostsResponse } from "../types";
type PageProps = {
searchParams: Promise<{
cursor?: string;
q?: string;
}>;
};
export default async function Home({ searchParams }: PageProps) {
const queryParams = await searchParams;
const cursor = queryParams.cursor || undefined;
const searchQuery = queryParams.q || '';
const data = searchQuery
? await searchPosts({ query: searchQuery, first: 15, after: cursor })
: await getPosts({ first: 15, after: cursor });
const posts = await data.json() as PostsResponse;
return (
<div>
{searchQuery && (
<h1 className={styles.searchTitle}>「{searchQuery}」の検索結果</h1>
)}
{posts.edges.length === 0 && searchQuery && (
<p className={styles.noResults}>検索結果が見つかりませんでした。別のキーワードをお試しください。</p>
)}
<div className={styles.cardContainer}>
{posts.edges.map(({ node: post }) => (
<Link href={`/posts/${post.id}`} key={post.id}>
<Card
img={post.thumbnailUrl}
title={post.title}
description={post.content}
variant="post"
unoptimized={true}
maxLines={3}
titleMaxLines={true}
/>
</Link>
))}
</div>
<div className={styles.paginationContainer}>
{posts.pageInfo.hasPreviousPage && (
<Link href={`/?${searchQuery ? `q=${searchQuery}&` : ''}cursor=${posts.pageInfo.startCursor}`} className={styles.paginationLink}>
前のページ
</Link>
)}
{posts.pageInfo.hasNextPage && (
<Link href={`/?${searchQuery ? `q=${searchQuery}&` : ''}cursor=${posts.pageInfo.endCursor}`} className={styles.paginationLink}>
次のページ
</Link>
)}
</div>
</div>
);
}
まとめ
今までNextではプルダウン形式の検索機能しか採用していなかった...
日々学びですね 🔍