Torihaji's Growth Diary

Little by little, no hurry.

supabaseの部分一致検索 + インクリメンタルサーチを行いたい

現状

supabaseにおいて部分一致検索( + インクリメンタルサーチ)を行おうとしている。

結論は ilikeと%query%みたいにして使えば部分一致検索はできて

インクリメンタルサーチはqueryの値をdebounce使って設定すればできる。

インクリメンタルサーチはよくある検索バーとかで

入力に応じて検索結果欄に結果が出てくるようなuxを提供してくれるもの。

環境は

Next.js 15.3.1 
Approuter 
React 19 
supabase
swr
shadcn
"supabase": "^2.22.6",
"@supabase/ssr": "^0.6.1",
"@supabase/supabase-js": "^2.49.4",

構成としては

client componentのcmdkから出るコマンドパレットにおける検索バーから文字を入力すると

その入力をトリガーにswrがroute handlerを叩き、そのroute handlerの中で supabaseに対してクエリを叩き

そのレスポンスを受け取ってviewにレンダリングするという感じ。

よくあるcmd + k押したらモーダルが出るという定番のやつ。

"use client";

import { Home, Plus, User } from "lucide-react";
import { useRouter } from "next/navigation";
import { useCallback, useState } from "react";
import { useHotkeys } from "react-hotkeys-hook";

import {
  CommandDialog,
  CommandEmpty,
  CommandGroup,
  CommandInput,
  CommandItem,
  CommandList,
  CommandSeparator,
  CommandShortcut,
} from "@/components/ui/command";
import { useSearchItems } from "@/features/items/hooks/swr";
import { Item } from "@/lib/items/types";

export function CommandPallette() {
  const router = useRouter();
  const [open, setOpen] = useState(false);
  const [query, setQuery] = useState("");
  const { data, isLoading } = useSearchItems({
    open,
    query,
    config: {
      keepPreviousData: true,
    },
  });

  useHotkeys(["meta+h", "ctrl+h"], () => {
    router.push("/home");
  });

  useHotkeys(["meta+k", "ctrl+k"], () => {
    setOpen(true);
  });

  useHotkeys(["meta+ctrl+n"], () => {
    router.push("/items/new");
  });

  const handleSelect = useCallback(
    (path: string) => {
      setOpen(false);
      router.push(path);
    },
    [router]
  );

  return (
    <>
      <CommandDialog open={open} onOpenChange={setOpen} shouldFilter={false}>
        <CommandInput
          placeholder="Type a command or search..."
          value={query}
          onValueChange={setQuery}
        />
        <CommandList>
          {query === "" ? (
            <>
              <CommandGroup heading="Suggestions">
                <CommandItem onSelect={() => handleSelect("/home")}>
                  <Home />
                  <span>Home</span>
                  <CommandShortcut>⌘H</CommandShortcut>
                </CommandItem>
                <CommandItem onSelect={() => handleSelect("/items/new")}>
                  <Plus />
                  <span>New Item</span>
                  <CommandShortcut>⌘+ctrl+N</CommandShortcut>
                </CommandItem>
                <CommandItem onSelect={() => handleSelect("/profile")}>
                  <User />
                  <span>Profile</span>
                  <CommandShortcut>⌘+ctrl+P</CommandShortcut>
                </CommandItem>
              </CommandGroup>

              <CommandSeparator />
              <CommandGroup heading="Items">
                {data?.map((item: Item) => (
                  <CommandItem
                    key={item.id}
                    onSelect={() => handleSelect(`/items/${item.id}`)}
                  >
                    <span>{item.title}</span>
                  </CommandItem>
                ))}
              </CommandGroup>
            </>
          ) : (
            <CommandGroup heading="Search">
              {isLoading && <CommandItem disabled>Loading…</CommandItem>}

              {data && data.length === 0 ? (
                <CommandEmpty>No results found.</CommandEmpty>
              ) : (
                <>
                  {data?.map((item: Item) => (
                    <CommandItem
                      key={item.id}
                      onSelect={() => handleSelect(`/items/${item.id}`)}
                    >
                      <span className="truncate">{item.title}</span>
                    </CommandItem>
                  ))}
                </>
              )}
            </CommandGroup>
          )}
        </CommandList>
      </CommandDialog>
    </>
  );
}
import useSWR, { SWRConfiguration } from "swr";

import { Item } from "@/lib/items/types";

const itemsFetcher = (url: string): Promise<Item[]> =>
  fetch(url).then((res) => res.json());

export const useSearchItems = ({
  open,
  query,
  config,
}: {
  open: boolean;
  query: string;
  config?: SWRConfiguration;
}) => {
  const { data, isLoading, error } = useSWR<Item[]>(
    open ? `/api/items/search?q=${encodeURIComponent(query)}&limit=5` : null,
    itemsFetcher,
    config
  );

  return {
    data,
    isLoading,
    error,
  };
};

import { type NextRequest, NextResponse } from "next/server";

import { createClient } from "@/lib/supabase/server";

export async function GET(request: NextRequest) {
  const searchParams = request.nextUrl.searchParams;
  const q = searchParams.get("q") ?? "";
  const limit = searchParams.get("limit") ?? 5;

  const supabase = await createClient();

  const { data, error } = await supabase
    .from("items")
    .select("*")
    .order("updated_at", { ascending: false })
    .ilike("content", `%${q}%`)
    .limit(Number(limit));

  if (error) {
    return NextResponse.json({ error: error.message }, { status: 500 });
  }

  return NextResponse.json(data);
}

最初 queryのilikeをtextSearchにしていたのだが、完全一致しかならなかったので断念。

使い方が間違っていたのかもしれないが。

とりあえずこれで部分一致検索はなった。

あとは今のままだと入力に伴ってapiが叩かれるのでdebounce使おうと思う。

というかそもそも今の環境に合うようなdebounceってどのくらいの手法があるのだろうか。

調べた感じ

このくらいらしい。

そもそもdebounceとはなんだかな。

読んだ限り、スパム防止の目的らしい。

あとは高頻度で起きるイベントによって発火する処理に対してシステムが過剰反応しないようにする仕組みのこと。

んー色々あるが、ここは勉強のために先ほどの自作した人をパクらせてもらおう。

色々やってできた。

先ほどのswrの上にuseDebounceフックを読んでその戻り値debounceValueみたいなものを受け取り

それをswrのqueryに渡すだけ。

export function CommandPallette() {
  const router = useRouter();
  const [open, setOpen] = useState(false);
  const [query, setQuery] = useState("");
  const debouncedQuery = useDebounce(query, 1000);
  const { data } = useSearchItems({
    open,
    query: debouncedQuery as string,
  });

ということで、debounceは解決。

次に。cmdkの仕様なのかもしれないが検索結果においてtitleを表示させるようにしているが

その中のtitleが同一だと全て同じものとしてfocusが当たってしまう。

これはcmdkの仕様らしく、CommandItemにvalueがセットされていないと内包してるtextContentで比較するらしい。

ということで各itemにvalueを設定すればいい。

                  {data?.map((item: Item) => (
                    <CommandItem
                      key={item.id}
                      onSelect={() => handleSelect(`/items/${item.id}`)}
                      value={item.id}
                    >
                      <span className="truncate">{item.title}</span>
                    </CommandItem>
                  ))}

こうすることでそれぞれ別のものとして認識された。

動画が載せられないのでアレだが、これでうまくいく。

終わりに

動かしたところまだまだ詰めれる余地がありそう。

初期値設定で流し込むのはqueryではなく、普通にselect したものだけでよさそう。

ただこれでcmd k の知見とかcmdショートカット、インクリメンタルサーチの手法とか

色々しれた。

現状に少し焦っている。

不用意に焦りたくはないが、力つけてかないと。

こんなことで詰まってる場合ではない。