Skip to main content

Query from the URL

A filtered list is worth sharing. Put the query in ?q= and the link you paste into Slack opens the same list. Query is controlled only, so the URL state is yours to wire, and mantle never imports a router. This recipe is one small hook.

Two rules shape it:

  • The typed text is the source of truth while you type. Bind the field straight to the URL and every keystroke is a navigation, so the URL write waits 250 ms after the last edit and replaces the history entry instead of pushing one. A clear writes at once, so an empty field never shows a filtered list.
  • A URL change from outside (the back button, a link to ?q=is:shared) replaces the typed text. The hook compares the URL value with the one it saw last render and resets the text during render, not in an effect.

Type a filter below and watch the address bar.

ProjectDescription
billing-apiInvoices and payments
marketing-sitePublic website
design-tokensOld color scales
staging-apiStaging API
webhook-testsLocal webhook tests
import { Button } from "@ngrok/mantle/button";import { DataTable, createColumnHelper, tableFeatures, useTable } from "@ngrok/mantle/data-table";import { Empty } from "@ngrok/mantle/empty";import { filterItemsByQuery } from "@ngrok/mantle/query-filter";import { Query } from "@ngrok/mantle/query";import { MagnifyingGlassIcon } from "@phosphor-icons/react/MagnifyingGlass";import { useDeferredValue } from "react";import { type Project, projectFilterGrammar, projects } from "~/features/query-demos";import { useQueryParamInput } from "~/features/query-url-state"; const features = tableFeatures({});const columnHelper = createColumnHelper<typeof features, Project>();const columns = columnHelper.columns([	columnHelper.accessor("name", {		id: "name",		header: () => <DataTable.Header>Project</DataTable.Header>,		cell: (props) => (			<DataTable.Cell className="text-strong" translate="no">				{props.getValue()}			</DataTable.Cell>		),	}),	columnHelper.accessor("description", {		id: "description",		header: () => <DataTable.Header>Description</DataTable.Header>,		cell: (props) => <DataTable.Cell>{props.getValue()}</DataTable.Cell>,	}),]); export function QueryFromUrlDemo() {	const { query, setQuery } = useQueryParamInput();	const deferredQuery = useDeferredValue(query);	const visibleProjects = filterItemsByQuery({		grammar: projectFilterGrammar,		items: projects,		query: deferredQuery,	});	const table = useTable({ features, data: visibleProjects, columns });	const rows = table.getRowModel().rows; 	return (		<div className="w-full space-y-3">			<Query.Root				grammar={projectFilterGrammar}				items={projects}				value={query}				onValueChange={setQuery}			>				<Query.Input					aria-label="Filter projects"					placeholder="Filter projects, or type a filter like is:shared"				/>				<Query.Content>					<Query.List>{(suggestion) => <Query.Item suggestion={suggestion} />}</Query.List>					<Query.Empty />					<Query.Footer>						<Query.Legend />						<Query.KeyboardHints />					</Query.Footer>				</Query.Content>			</Query.Root>			<DataTable.Root table={table}>				<DataTable.Head />				<DataTable.Body>					{rows.length > 0 ? (						rows.map((row) => <DataTable.Row key={row.id} row={row} />)					) : (						<DataTable.EmptyRow>							<Empty.Root>								<Empty.Icon svg={<MagnifyingGlassIcon />} />								<Empty.Title>No projects match this filter</Empty.Title>								<Empty.Actions>									<Button										type="button"										appearance="outlined"										intent="neutral"										onClick={() => setQuery("")}									>										Clear filter									</Button>								</Empty.Actions>							</Empty.Root>						</DataTable.EmptyRow>					)}				</DataTable.Body>			</DataTable.Root>		</div>	);}

The hook with React Router

Copy this file. It uses useSearchParams from react-router and useDebouncedCallback from @ngrok/mantle/hooks.

import { useDebouncedCallback } from "@ngrok/mantle/hooks";import { startTransition, useState } from "react";import { useSearchParams } from "react-router"; /** * When the typed text reaches the URL: * - `change`: 250 ms after the last edit. * - `submit`: only when the person presses Enter. Use it when one filter pass *   over the list is too slow to run while the person types. */export type QueryCommitMode = "change" | "submit"; /** * Owns the typed text of a `Query` and keeps it in the `?q=` URL param, * so a filtered list is shareable by its URL. * * The typed text is the source of truth while you type. In `change` mode, the * URL write waits 250 ms after the last edit. In `submit` mode, only `submit` * writes. In both modes, a clear writes at once. Each write replaces the * history entry. A URL change from outside, such as the back button, replaces * the typed text. * * @example * ```tsx * const { query, setQuery } = useQueryParamInput(); * <Query.Root grammar={grammar} items={items} value={query} onValueChange={setQuery}>…</Query.Root> * ``` */export function useQueryParamInput({ commitOn = "change" }: { commitOn?: QueryCommitMode } = {}) {	const [searchParams, setSearchParams] = useSearchParams();	const urlQuery = searchParams.get("q") ?? "";	const [query, setQuery] = useState(urlQuery);	const [previousUrlQuery, setPreviousUrlQuery] = useState(urlQuery);	// The `q` this hook last wrote. When the URL comes back with it, the typed text stays.	const [writtenQuery, setWrittenQuery] = useState(urlQuery);	if (urlQuery !== previousUrlQuery) {		setPreviousUrlQuery(urlQuery);		if (urlQuery !== writtenQuery) {			setQuery(urlQuery);			setWrittenQuery(urlQuery);		}	} 	function writeUrlQuery(nextQuery: string) {		if (nextQuery === urlQuery) {			return;		}		setWrittenQuery(nextQuery);		// The transition keeps the keystroke urgent while the list re-filters.		startTransition(() => {			setSearchParams(				(current) => {					const next = new URLSearchParams(current);					// A new filter starts a paginated list over at its first page.					next.delete("page");					if (nextQuery === "") {						next.delete("q");					} else {						next.set("q", nextQuery);					}					return next;				},				{ replace: true, preventScrollReset: true },			);		});	} 	// The write reads `query` when it runs. A URL change from outside during the	// wait replaces `query`, so the write never puts the old text back.	const writeTypedQuery = useDebouncedCallback(() => writeUrlQuery(query), { waitMs: 250 }); 	return {		/** The text in the field. */		query,		/** The query in the URL. In `submit` mode, filter the list by this. */		appliedQuery: urlQuery,		setQuery: (nextQuery: string) => {			setQuery(nextQuery);			if (nextQuery === "") {				writeUrlQuery("");			} else if (commitOn === "change") {				writeTypedQuery();			}		},		/** Writes the typed text to the URL now. Call it from the form's submit handler. */		submit: () => writeUrlQuery(query),	};}

Filter with useDeferredValue(query), as the demo does, so a long list re-renders behind the keystroke instead of in front of it. If the list loads through TanStack Query, filter inside select and keep the query key unfiltered, so one cache entry serves every query.

Apply on Enter

Filtering on every keystroke is right for most lists. When one filter pass over the list is too slow to run while a person types, as with thousands of rows or a list that streams in, apply the query on Enter instead. Pass commitOn: "submit", and filter by appliedQuery, the query in the URL:

  • Wrap the field in a <form>. Enter with no active suggestion submits it, and the submit handler calls submit.
  • Set enterHint on Query.KeyboardHints, so the hints say that Enter applies the filter.
  • Leave out items. Each match count is one more filter pass, so the suggestions show none.
  • A clear still applies at once, so the clear button and the empty field agree.

Pick the mode from the list size, such as commitOn: rows.length > 2000 ? "submit" : "change". The hook reads the mode on each render, so the switch keeps the person's focus and caret.

  • billing-api
  • marketing-site
  • design-tokens
  • staging-api
  • webhook-tests
import { filterItemsByQuery } from "@ngrok/mantle/query-filter";import { Query } from "@ngrok/mantle/query";import { projectFilterGrammar, projects } from "~/features/query-demos";import { useQueryParamInput } from "~/features/query-url-state"; export function QueryFromUrlSubmitDemo() {	const { query, setQuery, appliedQuery, submit } = useQueryParamInput({ commitOn: "submit" });	// Filter by the applied query: one pass runs per Enter, not per keystroke.	const visibleProjects = filterItemsByQuery({		grammar: projectFilterGrammar,		items: projects,		query: appliedQuery,	}); 	return (		<div className="w-full space-y-3">			<form				onSubmit={(event) => {					event.preventDefault();					submit();				}}			>				{/* No `items`: each match count is one more filter pass, so the suggestions show none. */}				<Query.Root grammar={projectFilterGrammar} value={query} onValueChange={setQuery}>					<Query.Input						aria-label="Filter projects"						placeholder="Filter projects, then press Enter"					/>					<Query.Content>						<Query.List>{(suggestion) => <Query.Item suggestion={suggestion} />}</Query.List>						<Query.Empty />						<Query.Footer>							<Query.Legend />							<Query.KeyboardHints enterHint="select, or apply the filter" />						</Query.Footer>					</Query.Content>				</Query.Root>			</form>			<ul className="text-body space-y-1 text-sm">				{visibleProjects.map((project) => (					<li key={project.id} translate="no">						{project.name}					</li>				))}			</ul>		</div>	);}

The hook with nuqs

With nuqs 2.5 or later, useQueryStates owns the URL write, and its limitUrlUpdates: debounce(250) option takes the place of the debounce. One call writes q and clears page. This docs site doesn't use nuqs, so nothing on this page runs this version.

import { debounce, parseAsInteger, parseAsString, useQueryStates } from "nuqs";import { startTransition, useState } from "react"; const params = {	q: parseAsString.withDefault(""),	page: parseAsInteger,}; export function useQueryParamInput({	commitOn = "change",}: { commitOn?: "change" | "submit" } = {}) {	const [{ q: urlQuery }, setParams] = useQueryStates(params, { clearOnDefault: true });	const [query, setQuery] = useState(urlQuery);	const [previousUrlQuery, setPreviousUrlQuery] = useState(urlQuery);	if (urlQuery !== previousUrlQuery) {		setPreviousUrlQuery(urlQuery);		setQuery(urlQuery);	} 	function writeUrlQuery(nextQuery: string, { wait }: { wait: boolean }) {		// A new filter starts a paginated list over at its first page.		void setParams(			{ q: nextQuery, page: null },			{ startTransition, limitUrlUpdates: wait ? debounce(250) : undefined },		);	} 	return {		query,		appliedQuery: urlQuery,		setQuery: (nextQuery: string) => {			setQuery(nextQuery);			if (nextQuery === "") {				writeUrlQuery("", { wait: false });			} else if (commitOn === "change") {				writeUrlQuery(nextQuery, { wait: true });			}		},		submit: () => writeUrlQuery(query, { wait: false }),	};}