Skip to content
Dethink Components

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 components

    Date values, calendar arithmetic, and time-zone-aware date handling.

  • React Aria (react-aria) · via shared Dethink components

    Accessibility hooks and overlay utilities used by the implementation.

  • React Aria Components (react-aria-components) · via shared Dethink components

    Accessible 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.

Usage
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
examples/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.

useFilterUrlState
PropWhat it doesDefault
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
initialSearchstringThe search string the server rendered with. Without it, the URL filter applies right after hydration.""
defaultValueFilterFilter 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) => FilterSchema version written as v, the schema old links were written with, and the upgrade from it. renameFilterField helps with renames.1
writeTimeoutnumberHow long after its last write the hook still recognizes its own writes coming back from a slow router.2000
param / versionParamstringSearch 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
useSavedViews
PropWhat it doesDefault
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 | PromisePersistence. Changes show right away and roll back if the returned promise rejects.Not set
activeViewId / defaultActiveViewId / onActiveViewChangestring | nullControlled or uncontrolled active view.null
version / migratenumber / (filter, v) => FilterStamped on saves; views saved with older versions are migrated before they apply.1
returnsSavedViewsStateviews, activeView, isDirty, diff, and apply (one undo step), save, saveAs(name, { scope }), rename, remove, deselect.Not set
SavedViewsMenu
PropWhat it doesDefault
savedViewsSavedViewsStateState 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.