Playbooks
The Sticky View State Playbook
By Flora May dela Cruz
How to make a multi-page app feel like one continuous surface — filters, tabs, scroll position, and selection survive navigation without a global store or a backend.
Purpose
A lot of enterprise apps get this wrong in the same boring way: the user filters a list, clicks into a detail, comes back — and everything has reset. Filters cleared, tab back to default, scroll position at the top, selection gone. It reads as broken. The fix usually feels like it requires a state library, a server, or both. It doesn’t. For most product surfaces — and especially for prototypes that need to test feeling — a small set of conventions around browser-native storage is enough.
This playbook is the rule set, the table you fill in once per page, and the three specific decisions that separate sticky-state-that-helps from sticky-state-that-haunts.
When to use it
- A multi-page app where users frequently navigate list → detail → back
- A prototype that needs to feel like a real product during user testing
- A surface with filters, search, tabs, view-mode toggles, or selection that should outlive a navigation
- Apps where URL-as-state is too heavy (filter combinations are messy, you don’t want every selection in the address bar) but local memory is the right scope
Don’t use this for cross-device sync, cross-session memory, or anything that should be shareable via copy-link. Those need URL state or a backend. Sticky view state is within this tab, while this session is open — that’s the contract.
Core framework
Three decisions, in this order. Skip any of them and the persistence either feels missing or feels haunted.
1. What survives — and what is allowed to die
Sort every piece of state on the page into one of three buckets.
Persisted (survives navigation, dies on hard refresh or tab close):
- Active tab on a multi-tab page
- View mode (list vs. grid, dense vs. comfortable)
- Search query
- Active filters / facet selections
- Selection (with a caveat — see decision 3)
- Per-entity sub-tab on detail pages (keyed by entity ID)
In-memory only (survives navigation via Back/Forward, dies on any fresh visit):
- Scroll position of the main content area
- Cursor position inside an editor
Never persisted (always starts fresh on mount):
- Dialog open/closed state
- Popover position or anchor target
- Loading flags
- Animation progress, gauge widths measured from the DOM
- Drag-in-progress state
- Hover state
The first bucket goes to sessionStorage. The second goes to an in-memory Map keyed by the browser’s per-history-entry key. The third stays in plain local component state. The reason this matters: persist a dialog’s open state and the dialog flashes open every time the user returns to the page. Persist a loading flag and the page comes back stuck in a spinner. Persist scroll position to sessionStorage instead of the in-memory map and a fresh nav-bar click leaves the user scrolled halfway down a page they think they just opened.
2. The keying convention
Every persisted key needs a prefix and, for per-entity state, an ID suffix.
<page>-<state> // list-page state
<page>-<state>-<entityId> // detail-page state, one entry per entity
Prefixes do two things: they group related state in DevTools (Application → Session Storage becomes scannable), and they prevent collisions when two pages happen to have a “tab” or “search” field. The entity suffix is the rule that lets a user open three different detail pages in different tabs without their states stomping each other.
3. When selection clears
This is the decision most teams get wrong. Selection feels like persistent state — and it is — but it’s persistent state with a task lifecycle. Sort the triggers:
| Trigger | Clear selection? | Why |
|---|---|---|
| User navigates away and comes back | ❌ No | Their work is in progress |
| User cancels a bulk-action dialog | ❌ No | They may retry |
| User closes a dialog via the ✕ | ❌ No | Same as cancel |
| The action’s success callback fires | ✅ Yes | The task is done |
| Dialog auto-dismisses after success | ❌ No | Too late; clearing should have happened at onSuccess |
| User explicitly clicks “Clear selection” | ✅ Yes | They asked |
The rule under all of this: clear selection on action success, not on dialog close. Leaving items selected after a completed action implies there is still pending work — and if the user reopens the same dialog, the already-completed items pre-select again. Both are confusing.
Reusable template
Drop this table into the design doc for each page that has sticky view state. The exercise of filling it in usually surfaces the bugs before code is written.
# Sticky view state — <page name>
## State inventory
| State | Key | Type | Default | Cleared when |
|---|---|---|---|---|
| Active tab | `<page>-tab` | string | "overview" | Never |
| View mode | `<page>-view` | string | "list" | Never |
| Search | `<page>-search` | string | "" | Never |
| Filter selections | `<page>-filters` | object | {} | Never |
| Selected items | `<page>-selected` | string[]| [] | Action success |
| Scroll position | (in-memory only) | number | 0 | Fresh visit |
| Dialog open? | (component state) | boolean | false | Always |
## Per-entity state (one row per state, keyed by entity ID)
| State | Key pattern | Type | Default |
|---|---|---|---|
| Detail tab | `<page>-tab-{id}` | string | "overview" |
| Show-all toggle | `<page>-showall-{id}` | boolean | false |
## Reset behavior
- Hard refresh: all sessionStorage cleared (browser behavior).
- "Reset prototype" button: `sessionStorage.clear()` + reload.
- Filter "Clear" button: each filter reset to default, written back to storage.
- Action success: clear the affected selection only.
## Explicitly not persisted
- Dialog open/closed
- Popover anchor
- Loading flags
- Hover state
- Drag state
The “Explicitly not persisted” section is the one most teams forget. Make it part of the template — naming what isn’t persisted is how you prevent someone from quietly persisting it six months later.
AI-assisted workflow
Two prompts. Both stress-test the state inventory before code touches the page.
Prompt: detect would-be haunted state
Here is a state inventory for a page in a multi-page app. For each
entry marked "persisted," tell me whether persisting it across
navigation would create a confusing or stuck experience. Specifically
flag:
- Anything dialog- or popover-related
- Anything driven by current DOM measurement (sizes, positions)
- Anything that represents an "in-progress" interaction (drag, hover,
loading)
- Anything that would re-trigger an action or animation on return
Output: a list of entries that should be moved to "never persisted,"
with one sentence of reasoning each.
<paste the inventory>
Prompt: derive the keying scheme
Below is a list of pages and the state they need to persist. Generate
a keying scheme that:
- Uses a consistent prefix per page
- Uses an entity-ID suffix for detail-page state
- Avoids collisions across pages
- Is human-readable in DevTools
Output: a table of (page, state, key pattern).
<paste the list>
These prompts are most useful as a checkpoint, not a generator. The point is to make the inventory sit in writing before any hook is touched.
Collaboration considerations
- For PMs: the state inventory belongs in the PRD or design spec, not in implementation. It’s a product decision what survives a navigation — engineers should not be inferring this.
- For engineers: the cost of getting this wrong is asymmetric. Persisting too much costs a few hours of cleanup. Persisting too little costs the entire feeling-like-a-real-app illusion the team is trying to build.
- For research / user testing: sticky state is one of the highest-leverage things to validate in a prototype. Run two test sessions with persistence on, two with it off. The difference is usually obvious to participants without them being able to articulate why.
- For QA: the test matrix is small and specific: hard refresh resets, Back/Forward preserves, navigation away and back preserves, action success clears the right selection, dialog cancel does not.
Common failure patterns
- Persisting dialog open state. The dialog flashes open on every return. Almost always a copy-paste from a “persist everything” pattern.
- Persisting scroll position in sessionStorage instead of an in-memory map. A fresh nav click lands the user mid-page on a page they think they just opened.
- Forgetting the entity-ID suffix on detail pages. Open detail A, switch tab to “history,” navigate to detail B — and B’s history tab is also active, because the key collided.
- Clearing selection on dialog close instead of on action success. Selection clears even when the user cancels, losing their work.
- Not clearing selection on action success. Reopening the action pre-selects already-completed items.
- Storing non-serializable values without a bridge.
SetandMapneed to be serialized to arrays/entries and reconstructed; otherwise stored values come back as{}and silently default. - No prefix. DevTools fills with
tab,search,view— collisions across pages are inevitable. - Sessions that live too long. Persisting to
localStorageinstead ofsessionStoragemeans a state from three weeks ago greets the user on Monday. Use the shortest scope that meets the need.
Generalized example
A fictional fleet-management dashboard. Three pages: Vehicle List, Vehicle Detail, Route Planner. Each has filters, tabs, and selection.
# Sticky view state — Vehicle List
| State | Key | Type | Default | Cleared when |
|--------------------|----------------------|--------|-------------|-------------------|
| Active tab | fleet-tab | string | "active" | Never |
| View mode | fleet-view | string | "list" | Never |
| Search | fleet-search | string | "" | Never |
| Status filters | fleet-filters | object | {} | Never |
| Selected vehicles | fleet-selected | string[]| [] | Recall action OK |
| Scroll position | (in-memory) | number | 0 | Fresh visit |
| Recall dialog open | (component) | boolean| false | Always |
# Sticky view state — Vehicle Detail
| State | Key pattern | Default |
|--------------------|--------------------------|--------------|
| Active tab | vehicle-tab-{vehicleId} | "overview" |
| Maintenance toggle | vehicle-maint-{vehicleId}| false |
The user filters down to 12 vans flagged for inspection, multi-selects 4, clicks “Schedule recall,” confirms the dialog. The recall succeeds → those 4 deselect at onSuccess. Filters stay. Tab stays. They click a vehicle to see detail, come back — same filters, same selection state (now zero), same scroll position. Switch to the Route Planner, switch back — same. Hard refresh → clean slate. That sequence is the whole product feeling.
Public-safe review (verified before publish)
- No employer or client product names, codenames, or org names
- No customer names, segment sizes, or identifiable details
- No internal metrics, thresholds, OKRs, or telemetry numbers
- No roadmap, ship dates, or future plans
- No architecture, service names, API shapes, or schema fields from real systems
- No screenshots showing real chrome, real data, or recognizable surfaces
- No internal-only workflows, tools, or terminology
- Every example is fictional or abstracted; numbers are illustrative
- A peer outside any employer could read this and learn nothing proprietary
Take this playbook with you
Drop your email to copy the markdown or download the file. One email unlocks every playbook in the Toybox.
No spam. Occasional notes on new playbooks. Unsubscribe in one click.