Search Bar

Text search input with icon and clear functionality.

Installation

pnpm dlx shadcn@latest add https://ui.vllnt.com/r/search-bar.json
bash

Code

"use client";

import { useEffect, useRef, useState } from "react";

import { useRouter, useSearchParams } from "next/navigation";

import { useDebounce } from "../../lib/use-debounce";
import { Button } from "../button/button";
import { Input } from "../input/input";

type SearchBarProps = {
  className?: string;
  onSearch?: (query: string) => void;
  placeholder?: string;
};

export function SearchBar({
  className,
  onSearch,
  placeholder = "Search posts...",
}: SearchBarProps) {
  const router = useRouter();
  const searchParameters = useSearchParams();
  const initialQuery = searchParameters.get("search") ?? "";
  const [query, setQuery] = useState(initialQuery);
  const debouncedQuery = useDebounce(query, 300);
  const isInitialMount = useRef(true);
  const isUserTyping = useRef(false);

  const typingTimeoutReference = useRef<NodeJS.Timeout | undefined>(undefined);
  const lastSetSearchParameterReference = useRef<string>("");
  const lastDebouncedQueryReference = useRef<string>("");

  // Sync query with URL search params (e.g., on browser back/forward)
  // Sync when user is not actively typing and URL changed externally
  useEffect(() => {
    const searchParameter = searchParameters.get("search") ?? "";

    // Skip if this is the search param we set ourselves
    if (searchParameter === lastSetSearchParameterReference.current) {
      return;
    }

    // Sync if user is not actively typing and values differ
    if (!isUserTyping.current && query !== searchParameter) {
      requestAnimationFrame(() => {
        setQuery(searchParameter);
        lastDebouncedQueryReference.current = searchParameter;
      });
    }
  }, [searchParameters, query]); // Include query to properly sync state

  // Update URL when debounced query changes
  useEffect(() => {
    // Skip initial mount to avoid unnecessary URL update
    if (isInitialMount.current) {
      isInitialMount.current = false;
      const initialTrimmed = debouncedQuery.trim();
      lastDebouncedQueryReference.current = initialTrimmed;
      lastSetSearchParameterReference.current = initialTrimmed;
      return;
    }

    const trimmedQuery = debouncedQuery.trim();

    // Skip if this is the same value we already processed
    if (trimmedQuery === lastDebouncedQueryReference.current) {
      return;
    }

    lastDebouncedQueryReference.current = trimmedQuery;

    if (onSearch) {
      onSearch(trimmedQuery);
      return;
    }

    // Check current URL to avoid unnecessary updates
    const currentUrlParameter = searchParameters.get("search") ?? "";

    // Skip if URL already matches the debounced query
    if (trimmedQuery === currentUrlParameter) {
      lastSetSearchParameterReference.current = trimmedQuery;
      return;
    }

    const parameters = new URLSearchParams(searchParameters);
    if (trimmedQuery) {
      parameters.set("search", trimmedQuery);
    } else {
      parameters.delete("search");
    }
    const newUrl = parameters.toString();
    lastSetSearchParameterReference.current = trimmedQuery;
    router.replace(`?${newUrl}`);
  }, [debouncedQuery, router, onSearch, searchParameters]);

  // Cleanup timeout on unmount
  useEffect(() => {
    return () => {
      if (typingTimeoutReference.current) {
        clearTimeout(typingTimeoutReference.current);
      }
    };
  }, []);

  const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
    isUserTyping.current = true;
    setQuery(event.target.value);

    // Clear existing timeout
    if (typingTimeoutReference.current) {
      clearTimeout(typingTimeoutReference.current);
    }

    // Reset typing flag after debounce delay + buffer
    typingTimeoutReference.current = setTimeout(() => {
      isUserTyping.current = false;
    }, 350);
  };

  const handleSubmit = (event: React.SyntheticEvent) => {
    event.preventDefault();
    isUserTyping.current = false;

    // Clear typing timeout
    if (typingTimeoutReference.current) {
      clearTimeout(typingTimeoutReference.current);
    }

    const trimmedQuery = query.trim();
    if (onSearch) {
      onSearch(trimmedQuery);
    } else {
      const parameters = new URLSearchParams(searchParameters);
      if (trimmedQuery) {
        parameters.set("search", trimmedQuery);
      } else {
        parameters.delete("search");
      }
      const newUrl = parameters.toString();
      lastSetSearchParameterReference.current = trimmedQuery;
      router.replace(`?${newUrl}`);
    }
  };

  return (
    <form className={`flex gap-2 ${className}`} onSubmit={handleSubmit}>
      <Input
        aria-label={placeholder}
        className="flex-1"
        onChange={handleInputChange}
        placeholder={placeholder}
        type="text"
        value={query}
      />
      <Button type="submit" variant="outline">
        Search
      </Button>
    </form>
  );
}
typescript