People need to return to useful settings without recreating them each time.
Updated September 10, 2026
Problem
People repeatedly rebuild the same filters, time range, and display settings before they can start work.
Solution
Let people name and save a useful configuration. Make clear which settings are included, such as filters, sort order, and visible columns.
Distinguish a relative range that moves with time from a fixed historical period. Show that choice when saving and reopening the view.
Separate personal views from shared views and defaults. Give shared views an owner, and report saved settings that no longer refer to valid data or panels.
Use when
People often return to a configuration that takes several steps to recreate.
Don’t use when
The state is trivially reconstructed, or the URL already carries everything. If a link restores the view completely, a bookmark is a saved view and the product does not need its own feature.
Trade-offs
Saved views accumulate and can preserve outdated assumptions. Shared views need maintenance. Different personal filters can make colleagues think they are discussing the same dashboard when they are seeing different results.
Checklist
What exactly is captured—filters, range, sort, columns, expansion?
Is an absolute time range saved, and should it be?
Can a view be personal, shared or default, and is the difference obvious?
Who owns a shared view, and what happens when they leave?
What does a view referencing a dimension that no longer exists do?
Is a stale view distinguishable from a legitimately empty result?
Can views be found and searched once there are hundreds?
Is there a way to see what a view actually captured before opening it?
Does the URL already do this, making the feature redundant?
Is anything pruning views nobody has opened in a year?
Compare
Grafana splits the job across saved dashboards, starred dashboards and variable state carried in the URL, so “saved view” is assembled from three mechanisms rather than existing as one object, and the absolute-versus-relative range trap sits squarely in the middle of it. Sentry makes saved searches first-class with personal and team scoping, which fits a triage product where different roles genuinely need different queues. Honeycomb saves queries and collects them into boards, so the saved unit is a question rather than a page state, which ages better because a question stays meaningful when the data changes. Datadog leans on saved views across several products with team sharing, and shows the accumulation problem clearly at scale.
Related
Filter bar supplies many of the saved choices. Template variable applies shared selections. Time-range picker defines whether the window moves or stays fixed.
Share and embed sends a view to others. Multi-page dashboard fits stable views that deserve their own pages.
Wireframe — the pattern's anatomy, not any one product's version of it
Implementation
The same five filters, every morning, as one click. The part teams get wrong is the unsaved-changes state: a view that silently drifts from what its name says stops being trustworthy within a week.
SavedViewCard.tsxThe view type has no field for an absolute range. The card lists what it captured, checks each filter still resolves, and holds the ownership tier as a control.
import { Button } from "@/components/ui/button";import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";import { cn } from "@/lib/utils";/** * What a saved view holds, shown before it is opened. * * The type is the decision about what gets captured. Filters, sort, columns, * expansion and a relative range are in it. An absolute range is not, and * there is no field to put one in: a view saved during an incident with its * absolute range reopens next month still showing that incident. The card * says "not kept" on the page so the person opening it knows what it will * not restore. */export type Scope = "personal" | "shared" | "default";export type SavedView = { name: string; scope: Scope; /** A shared or default view needs a name against it. */ owner: string; /** Dimension to value, in the operator syntax the page uses: { sev: ">=2" }. */ filters: Record<string, string>; sort: { by: string; dir: "asc" | "desc" }; columns: { shown: string[]; of: number }; expandedRows: boolean; /** Relative only. "last 24h" reopens as the last 24 hours from whenever it is opened. */ range: `last ${string}`;};const SCOPES: Record<Scope, { label: string; hint: (v: SavedView) => string }> = { personal: { label: "Personal", hint: () => "Only you see it." }, shared: { label: "Shared", hint: (v) => `Owned by ${v.owner}.` }, default: { label: "Default", hint: () => "What everyone sees first." },};type Props = { view: SavedView; /** Whether a filter's dimension value still exists in the source. A view * pointed at a service that was deleted should say so; returning nothing * is indistinguishable from a healthy quiet system. */ exists: (dimension: string, value: string) => boolean; /** What the view returns right now. */ rows: number; onOpen: (view: SavedView) => void; onScopeChange: (scope: Scope) => void;};export function SavedViewCard({ view, exists, rows, onOpen, onScopeChange }: Props) { const missing = Object.entries(view.filters).filter(([k, v]) => !exists(k, v)); const drifted = missing.length > 0; const filters = Object.entries(view.filters).map(([k, v]) => `${k}${/^[<>=!]/.test(v) ? v : `=${v}`}`).join(", "); const row = (label: string, value: string, flag = false) => ( <div className="contents"> <dt className={cn("text-muted-foreground", flag && "text-status-warn")}>{label}</dt> <dd className={cn("m-0 text-right text-card-foreground", flag && "text-status-warn")}>{value}</dd> </div> ); return ( <div className={cn("rounded-lg border bg-card p-4", drifted && "border-status-warn")}> <div className="flex items-center justify-between border-b pb-2"> <p className="text-sm text-card-foreground">{view.name}</p> <Button size="sm" variant="outline" className="h-6 px-2.5 text-xs" onClick={() => onOpen(view)}>open</Button> </div> <dl className="mt-3 grid grid-cols-[100px_1fr] gap-x-3 gap-y-2 text-xs"> {row("filters", filters, drifted)} {row("sort", `${view.sort.by}, ${view.sort.dir}`)} {row("columns", `${view.columns.shown.length} of ${view.columns.of}`)} {row("expanded rows", view.expandedRows ? "yes" : "no")} {row("range", view.range)} </dl> {drifted ? ( <p className="mt-2 text-[11px] text-status-warn"> {missing.map(([k, v]) => `${k}=${v}`).join(", ")} no longer exists · {rows} rows </p> ) : ( <dl className="mt-2 grid grid-cols-[100px_1fr] gap-x-3 border-t border-dashed pt-2 text-xs"> {row("absolute range", "not kept", true)} </dl> )} <div className="mt-3 border-t pt-3"> <p className="text-[11px] text-muted-foreground">Who owns it</p> <ToggleGroup type="single" value={view.scope} onValueChange={(s) => s && onScopeChange(s as Scope)} aria-label="Who owns it" className="mt-1.5 justify-start gap-1" > {(Object.keys(SCOPES) as Scope[]).map((s) => ( <ToggleGroupItem key={s} value={s} className="h-6 rounded-md border px-2 text-[11px] data-[state=on]:border-foreground"> {SCOPES[s].label} </ToggleGroupItem> ))} </ToggleGroup> <p className="mt-1.5 text-[11px] text-muted-foreground">{SCOPES[view.scope].hint(view)}</p> </div> </div> );}
demo.tsxHow it is called: a shared morning view that resolves, and a personal one pointed at a service that no longer exists.
import { useState } from "react";import { SavedViewCard, type SavedView, type Scope } from "./SavedViewCard";/** * Two views. Morning triage is a shared view that still resolves. The second * filters on a service that was decommissioned, and says so instead of * quietly returning an empty table. */const SERVICES = ["cart", "checkout", "search", "auth"];const exists = (dimension: string, value: string) => dimension !== "service" || SERVICES.includes(value);const MORNING: SavedView = { name: "Morning triage", scope: "shared", owner: "Priya", filters: { env: "prod", sev: ">=2" }, sort: { by: "last seen", dir: "desc" }, columns: { shown: ["title", "service", "sev", "count", "first seen", "last seen", "owner"], of: 14 }, expandedRows: true, range: "last 24h",};const LEGACY: SavedView = { name: "Legacy cart errors", scope: "personal", owner: "Priya", filters: { service: "legacy-cart" }, sort: { by: "count", dir: "desc" }, columns: { shown: ["title", "count", "last seen"], of: 14 }, expandedRows: false, range: "last 7d",};export default function Demo() { const [scope, setScope] = useState<Scope>(MORNING.scope); const open = (v: SavedView) => { location.hash = `#view/${encodeURIComponent(v.name)}`; }; return ( <div className="flex w-[360px] flex-col gap-4"> <SavedViewCard view={{ ...MORNING, scope }} exists={exists} rows={38} onOpen={open} onScopeChange={setScope} /> <SavedViewCard view={LEGACY} exists={exists} rows={0} onOpen={open} onScopeChange={() => {}} /> </div> );}
What it renders. Identical markup in both panes, with only the token values changing.