2 Ways to Build Live Search in Remix (Without Page Reloads)
Explore two ways to build search in Remix. The classic form-based method and the more dynamic, programmable approach

📖 Practical Remix Implementation Guides
Comprehensive guides covering Remix patterns, optimization techniques, and developer shortcuts. Plus code snippets and prompts to speed up your workflow.
Dynamic search (also called live search or type-ahead) is a staple UX pattern. In Remix, there are two canonical ways to fetch new data as the user types, without a full page reload.
Both patterns are first-class Remix techniques. Use the one that matches your UX goals.
1. Classic <Form method="GET"> — Submit on Enter
How it works
- The user types in an input field.
- When they press Enter (or click a submit button), the browser performs a GET request.
- Remix runs the loader for the route.
- The page navigates to the updated URL (for example,
?search=apple). - The loader returns filtered data, and the component re-renders.
Example
// routes/search.tsx
export const loader = async ({ request }: LoaderFunctionArgs) => {
const url = new URL(request.url);
const query = url.searchParams.get("search") ?? "";
const results = query ? await fetchProducts(query) : [];
return json({ results, query });
};
export default function SearchPage() {
const { results, query } = useLoaderData<typeof loader>();
const [search, setSearch] = useState(query);
return (
<Form method="GET">
<TextField
label="Product"
name="search"
value={search}
onChange={setSearch}
autoComplete="off"
/>
{/* Optional submit button */}
{/* <button type="submit">Search</button> */}
<ResultsList items={results} />
</Form>
);
}
Pros and Cons
| Pros | Cons | |
|---|---|---|
| UX | Simple behavior. The URL is always up to date and shareable. | Requires pressing Enter to submit. |
| Dev | No extra hooks or state handling needed. | Causes full navigation, so scroll position resets and parent loaders run again. |
When to use it
- You want a basic search that submits on Enter.
- You want the URL to reflect the current query for sharing or bookmarking.
- You prefer the simplest possible code.
2. useFetcher — Instant Live Search
How it works
- The user types.
- In the input’s
onChange, you callfetcher.submit({ search }, { method: "GET" }). - Remix runs the same loader.
- The page does not navigate; the loader result goes into
fetcher.data. - You render the results from
fetcher.datafor a fluid, real-time experience.
Example (with debounce and optional URL sync)
// routes/search.tsx
import debounce from "lodash.debounce";
export default function SearchPage() {
const loaderData = useLoaderData<typeof loader>();
const fetcher = useFetcher<typeof loader>();
const [search, setSearch] = useState(loaderData.query);
const results = fetcher.data?.results ?? loaderData.results;
const debouncedSubmit = useMemo(
() =>
debounce((value: string) => {
fetcher.submit({ search: value }, { method: "GET" });
}, 300),
[fetcher]
);
useEffect(() => {
const params = new URLSearchParams(window.location.search);
if (search) params.set("search", search);
else params.delete("search");
window.history.replaceState(null, "", `?${params.toString()}`);
}, [search]);
const handleSearch = (value: string) => {
setSearch(value);
debouncedSubmit(value);
};
return (
<>
<TextField
label="Product"
value={search}
onChange={handleSearch}
autoComplete="off"
/>
<ResultsList items={results} />
</>
);
}
Pros and Cons
| Pros | Cons | |
|---|---|---|
| UX | Live results with no page reload. Scroll and other state stay intact. | Needs extra code for debounce and optional URL sync. |
| Dev | Reuses the same loader logic. No redundant queries. | You must handle fetcher.data typing. |
When to use it
- You want type-ahead search with instant feedback.
- Avoiding full navigation is important (for example, you have large parent loaders).
- You want fine-grained control over network calls and history.
Decision Matrix
| Scenario | Recommended Pattern |
|---|---|
| Search on Enter or Submit Button | <Form method="GET"> |
| Live search while typing | useFetcher |
| Shareable URL | Either works; <Form> handles it automatically, useFetcher needs manual history sync. |
| Expensive parent loaders you want to avoid | useFetcher |
| Simplest implementation | <Form> |
Key Takeaways
- For traditional search where the user confirms the input,
<Form method="GET">is straightforward, keeps your URL in sync, and is SEO-friendly. - For dynamic, instant search,
useFetcherallows live updates without a full reload, but you handle debouncing and history updates. - Both approaches reuse your loader logic — no need to duplicate queries or validation.
Use the right tool for the UX you want. Keep the loader as your single source of truth. And if you go with useFetcher, debounce your requests to avoid overwhelming your server.
Thanks, Matija
10
Comments
Leave a Comment