Remote Search with URL State
Problem
A list page should be bookmarkable and shareable. Search query, active filters, sort order, and current page should all be preserved in the URL so that navigating back or pasting the URL restores the exact previous state.
Solution
Use encodeQuery() to serialize source state into URL-safe params after each interaction, and decodeQuery() + applyRemoteQuery() to restore state from params on page load.
ts
import { applyRemoteQuery, createRemoteSource, decodeQuery, encodeQuery } from '@vielzeug/sourcerer';
type Item = { id: number; name: string };
type Filter = { status?: 'open' | 'closed' };
type Sort = { by: 'name' | 'id'; dir: 'asc' | 'desc' };
const source = createRemoteSource<Item, Filter, Sort>({
fetch: async ({ filter, limit, page, search, sort }, signal) => {
const res = await fetch(`/api/items`, {
method: 'POST',
signal,
body: JSON.stringify({ filter, limit, page, search, sort }),
headers: { 'Content-Type': 'application/json' },
});
return res.json(); // { items: Item[], total: number }
},
limit: 20,
// autoFetch: true (default) — initial data loads immediately
});
// On load: restore state from current URL
// decodeQuery accepts URLSearchParams directly
await applyRemoteQuery(source, decodeQuery<Filter, Sort>(new URLSearchParams(location.search), { defaultLimit: 20 }));
await source.ready();
// User interaction: apply a search
await source.search('error', { immediate: true });
// After each interaction: push updated state back to the URL
const nextParams = new URLSearchParams(encodeQuery(source.toQuery()));
history.replaceState(null, '', `?${nextParams.toString()}`);Roundtrip guarantee
encodeQuery() and decodeQuery() are inverses — any query round-trips without loss:
ts
const original = source.toQuery();
const params = encodeQuery(original);
const restored = decodeQuery(params, { defaultLimit: 20 });
// restored deeply equals originalHandling untrusted URLs
decodeQuery() is fault-tolerant by default: malformed filter/sort JSON params are silently dropped. To throw on invalid input instead:
ts
const query = decodeQuery(urlParams, { strict: true });Pitfalls
decodeQueryreturns aPartial<RemoteSourceQuery>. Pass it directly toapplyRemoteQuery()— no manual field mapping needed.- Calling
history.pushState(instead ofreplaceState) on every interaction floods the browser history. Always usereplaceStatewhen syncing list state to the URL. - Subscribe to source changes and compare the serialized params before writing to the URL to avoid redundant history pushes when only
isLoadingchanged.