Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improvements to the search experience #219

Open
leerob opened this issue Aug 15, 2024 · 0 comments
Open

Improvements to the search experience #219

leerob opened this issue Aug 15, 2024 · 0 comments

Comments

@leerob
Copy link

leerob commented Aug 15, 2024

👋 Saw your X post and wanted to share some suggestions for improving search.

Current State

Based on this commit, it looks like you currently:

  1. Have various places that are passing functions of props into components related to search
  2. Kick off a search by setting and then updating local react state
  3. This calls tRPC to fetch new data based on the search value
  4. A callback function eventually calls router.push to update the URL

There's an opportunity to simplify and consolidate this logic, while improving the UX. Right now, you're mostly using client components and tRPC. While there's nothing wrong with this, you could centralize searching into one component, which triggers a server component to fetch new data when the URL state changes.

Desired State

I believe the UX you want is:

  • Users can instantly type into an input and start searching
  • Searching starts immediately – you do not need to wait until enter is pressed / form is submitted
  • When a search is pending, show some visual feedback to the user
  • Allow for multiple searches / permutations to happen, without showing visual jank from entering/exiting search state
  • Indicate to the user searching is happening with some animation on the containing element / inline spinner
  • Don't block the UI or drop frames when trying into the input quickly

Proposed Solution

Here's one option. It's using a new feature of Next.js (on canary) but it's not required (you could use a normal <form>).

  1. The <Search> component gets the initial search value from searchParams from a Server Component above
  2. This value is stored into local state, as well as a deferred value
  3. When there are changes in the input, the local state is updated and the form is submitted
  4. Submitting the form updates the URL state with the ?search= query parameter (replaced, not pushed onto the stack)
  5. This fires a React transition to the new URL, which means useFormStatus is pending
  6. This now shows a loading spinner inline for the search input, but also adds an attribute to the DOM element
  7. In other parts of our codebase, we can use CSS to look for that element and conditionally add an animation
  8. This also includes a useEffect to focus the input on mount (optional)
'use client';

import { useEffect, useRef, useState, useDeferredValue } from 'react';
import { useFormStatus } from 'react-dom';
import Form from 'next/form';
import { SearchIcon } from 'lucide-react';
import { Input } from '@/components/ui/input';

export function Search({ query: initialQuery }: { query: string }) {
  let [query, setQuery] = useState(initialQuery);
  let deferredQuery = useDeferredValue(query);
  let inputRef = useRef<HTMLInputElement>(null);
  let formRef = useRef<HTMLFormElement>(null);
  let isStale = query !== deferredQuery;

  useEffect(() => {
    if (inputRef.current && document.activeElement !== inputRef.current) {
      inputRef.current.focus();
      inputRef.current.setSelectionRange(
        inputRef.current.value.length,
        inputRef.current.value.length
      );
    }
  }, []);

  function handleInputChange(e: React.ChangeEvent<HTMLInputElement>) {
    setQuery(e.target.value);
    formRef.current?.requestSubmit();
  }

  return (
    <Form
      ref={formRef}
      action="/"
      replace
      className="relative flex flex-1 flex-shrink-0 w-full"
    >
      <label htmlFor="search" className="sr-only">
        Search
      </label>
      <SearchIcon className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground pointer-events-none" />
      <Input
        ref={inputRef}
        onChange={handleInputChange}
        type="text"
        name="search"
        id="search"
        placeholder="Search..."
        value={query}
        className="w-full rounded-none border-0 px-10 py-6 m-1 focus-visible:ring-0 text-base md:text-sm"
      />
      <LoadingIcon isStale={isStale} />
    </Form>
  );
}

function LoadingIcon({ isStale }: { isStale: boolean }) {
  let { pending } = useFormStatus();
  let loading = pending || isStale;

  return loading ? (
    <div
      data-pending={loading ? '' : undefined}
      className="absolute right-3 top-1/2 -translate-y-1/2"
    >
      <div
        className="h-5 w-5 animate-spin rounded-full border-2 border-primary border-t-transparent"
        role="status"
      >
        <span className="sr-only">Loading...</span>
      </div>
    </div>
  ) : null;
}

This likely isn't perfect yet but it's closer in the direction you're looking for. Happy to help out. I'm trying to build a similar example of searching here: https://next-books-search.vercel.app

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant