ComponentsSaved views & URL state
Saved views & URL state
Filters live in the URL as readable text with a schema version, so links can be shared, bookmarked and still work after you rename a field. Saved views give filters a name, show when you've changed one, and apply in a single undo step.
On this page
Install component source from the public registry, or use the local workspace package. Follow the setup guide first. The npm package is not published yet.
View registry files- Internationalized Date
(@internationalized/date)· via shared Dethink componentsDate values, calendar arithmetic, and time-zone-aware date handling.
- React Aria
(react-aria)· via shared Dethink componentsAccessibility hooks and overlay utilities used by the implementation.
- React Aria Components
(react-aria-components)· via shared Dethink componentsAccessible interaction primitives, including keyboard behavior, focus handling, and ARIA semantics.
Shared foundation: React, clsx for conditional classes, tailwind-merge for resolving utility-class conflicts. Styles use Tailwind CSS and Dethink CSS variables.
Based on this component’s source and registry composition. Libraries used only in showcase examples are excluded.
Import the component into your page or component file.
import {
SavedViewsMenu,
useFilterUrlState,
useSavedViews,
} from "@dethink/components";This example writes to this page's URL. Edit the filter, then reload or press back.
URL state and saved views
Apply a view, change a chip to see it marked edited, then save changes or save it as a new view. The URL updates with each change, and undo still works.
URL: (no filter)
Show sourceexamples/saved-views/url-views.tsx
"use client";
import { useState } from "react";
import {
FilterBar,
QueryInput,
SavedViewsMenu,
createFilter,
createFilterCondition,
defineFilterFields,
encodeFilterParam,
formatFilterSearch,
useFilterUrlState,
useSavedViews,
type SavedView,
} from "@dethink/components";
const fields = defineFilterFields([
{ key: "title", label: "Title", type: "text" },
{
key: "status",
label: "Status",
type: "option",
options: [
{ value: "open", label: "Open" },
{ value: "blocked", label: "Blocked" },
{ value: "done", label: "Done" },
],
},
{
key: "labels",
label: "Labels",
type: "multiOption",
options: [
{ value: "bug", label: "Bug" },
{ value: "api", label: "API" },
{ value: "billing", label: "Billing" },
],
},
{ key: "estimate", label: "Estimate", type: "number" },
{ key: "created", label: "Created", type: "date" },
]);
const initialViews: SavedView[] = [
{
id: "my-open",
name: "Open this month",
version: 1,
scope: "personal",
filter: createFilter({
children: [
createFilterCondition({
field: "status",
operator: "isAnyOf",
value: ["open"],
}),
createFilterCondition({
field: "created",
operator: "inPeriod",
value: { kind: "relative", amount: 0, unit: "month" },
}),
],
}),
},
{
id: "team-bugs",
name: "Big bugs",
version: 1,
scope: "team",
filter: createFilter({
children: [
createFilterCondition({
field: "labels",
operator: "includesAny",
value: ["bug"],
}),
createFilterCondition({
field: "estimate",
operator: "gte",
value: 5,
}),
],
}),
},
];
export function SavedViewsUrlExample() {
// The filter lives in this page's URL (?q=…&v=1). Try editing it, then
// reload or use back and forward.
const state = useFilterUrlState({ fields });
// In an app, persist views in your API; here they live in memory.
const [views, setViews] = useState(initialViews);
const savedViews = useSavedViews({
state,
fields,
views,
onCreate: (view) => setViews((current) => [...current, view]),
onUpdate: (view) =>
setViews((current) =>
current.map((item) => (item.id === view.id ? view : item)),
),
onDelete: (view) =>
setViews((current) => current.filter((item) => item.id !== view.id)),
});
const search = formatFilterSearch(encodeFilterParam(state.filter, fields));
return (
<div className="grid gap-3">
<div className="flex flex-wrap items-center gap-2">
<SavedViewsMenu savedViews={savedViews} scopes={["personal", "team"]} />
<QueryInput
fields={fields}
state={state}
className="min-w-0 flex-1 basis-72"
/>
</div>
<FilterBar fields={fields} state={state} />
<p className="text-muted-foreground text-xs">
URL:{" "}
<code className="text-foreground font-mono break-all">
{search || "(no filter)"}
</code>
</p>
</div>
);
}useFilterUrlState talks to the URL through a small store, so it needs no router dependency.
The default store uses the History API with replaceState, so filter edits don’t flood the back button. Pass createHistoryFilterStore({ mode: "push" }) to make each change a history entry, or createMemoryFilterStore() for tests and embedded views. With Next.js, pass the page’s searchParams as initialSearch so the server renders the same filter. The docs include store recipes for the Next.js router and nuqs.
| Prop | What it does | Default |
|---|---|---|
fieldsFilterField[] | The field schema used to read and write the URL. | Not set |
storeFilterUrlStore | { read, write, subscribe } over a search string. createHistoryFilterStore({ mode }) and createMemoryFilterStore(search) ship; routers plug in with a few lines. | page URL |
initialSearchstring | The search string the server rendered with. Without it, the URL filter applies right after hydration. | "" |
defaultValueFilter | Filter when the URL has none. It is left out of the URL; an empty filter that differs from it is written as q=. | empty |
version / fieldsAt / migratenumber / (v) => FilterField[] / (filter, v) => Filter | Schema version written as v, the schema old links were written with, and the upgrade from it. renameFilterField helps with renames. | 1 |
writeTimeoutnumber | How long after its last write the hook still recognizes its own writes coming back from a slow router. | 2000 |
param / versionParamstring | Search param names. | "q" / "v" |
returnsFilterState & { urlError? } | Everything useFilterState returns, plus why the URL could not be read. Undo works; outside URL changes are not undo steps. | Not set |
| Prop | What it does | Default |
|---|---|---|
state / fields / viewsFilterState / FilterField[] / SavedView[] | The filter state, schema and your persisted views ({ id, name, filter, version, scope?, meta? }). | Not set |
onCreate / onUpdate / onDelete(view) => void | Promise | Persistence. Changes show right away and roll back if the returned promise rejects. | Not set |
activeViewId / defaultActiveViewId / onActiveViewChangestring | null | Controlled or uncontrolled active view. | null |
version / migratenumber / (filter, v) => Filter | Stamped on saves; views saved with older versions are migrated before they apply. | 1 |
returnsSavedViewsState | views, activeView, isDirty, diff, and apply (one undo step), save, saveAs(name, { scope }), rename, remove, deselect. | Not set |
| Prop | What it does | Default |
|---|---|---|
savedViewsSavedViewsState | State from useSavedViews. | Not set |
scopes("personal" | "team")[] | Scopes offered when saving a new view. With more than one, the form asks who can see it. | ["personal"] |
labelsPartial<SavedViewsMenuLabels> | Every visible and accessible string. | English |
size"sm" | "md" | Matches FilterBar. | "md" |
The trigger names the active view and adds “edited” when the filter has changed, so the state isn’t shown by the dot alone. The menu is a dialog with views grouped under Personal and Team headings; each row has an apply button (marked current for the active view) and named rename and delete buttons.
Rename and save-as forms take focus, submit with Enter and cancel with Escape, returning focus to the row. Deleting asks for confirmation. Applying or saving closes the menu and returns focus to the trigger.