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

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.data
for 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,
useFetcher
allows 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
Enjoyed this article?
Subscribe to my newsletter for more insights and tutorials.