You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
description: Analyze and fix URL/query-param state anti-patterns — manual useSearchParams reads, hand-built query mutations, view-state trapped in useState, and objects in the URL
3
+
argument-hint: [scope] [fix=true|false]
4
+
---
5
+
6
+
# You Might Not Need URL State
7
+
8
+
Arguments:
9
+
- scope: what to analyze (default: your current changes). Examples: "diff to main", "PR #123", "app/workspace/[workspaceId]/tables/", "whole codebase"
10
+
- fix: whether to apply fixes (default: true). Set to false to only propose changes.
11
+
12
+
User arguments: $ARGUMENTS
13
+
14
+
## Context
15
+
16
+
Shareable client view-state (active tab/panel, filters, search query, sort, pagination, selected-entity id, an open "view" modal/drawer that is a destination) lives in the URL via [`nuqs`](https://nuqs.dev) — driven by a co-located `search-params.ts`, never read via `useSearchParams().get(...)` and never mutated by hand-built query strings. Remote data stays in React Query; high-frequency / large / ephemeral / socket-synced state stays in Zustand; purely local UI stays in `useState`.
17
+
18
+
`.claude/rules/sim-url-state.md` is the source of truth — read it first.
19
+
20
+
## References
21
+
22
+
Read these before analyzing:
23
+
1.`.claude/rules/sim-url-state.md` — the decision framework, conventions, debounced-input pattern, sort convention, selected-entity deep-link pattern, and the workflow-editor carve-out
4.https://nuqs.dev/docs/server-side — `createSearchParamsCache` for server reads
27
+
28
+
## Anti-patterns to detect
29
+
30
+
1.**Manual param reads for state**: `useSearchParams().get(...)` or `new URLSearchParams(window.location.search)` used to *read* view-state. Replace with `useQueryState`/`useQueryStates` bound to a `search-params.ts`. (Read-once auth/invite/redirect tokens — `token`, `callbackUrl`, `redirect`, `error`, `invite_flow`, `code` — are NOT view-state; leave them on `useSearchParams`.)
31
+
2.**Hand-built query mutation**: constructing a query string + `router.replace`/`router.push` to change a param on the current path. Use a nuqs setter. (A `router.push` that changes the route *path* is fine; an outbound `new URLSearchParams` building an `href`/`window.open`/download/API URL is fine.)
32
+
3.**`window.history.replaceState`/`pushState`** to mutate a param.
33
+
4.**URL state duplicated into a store/useState + synced with an effect** (or a `popstate` listener). The URL is the single source of truth; derive from it, don't mirror it.
34
+
5.**Objects in the URL**: serializing a `TableDefinition`/`SkillDefinition`/etc. Store the id and derive the object from the loaded list (`items.find(i => i.id === id)`).
35
+
6.**High-frequency / large state in the URL**: cursor, pan/zoom, un-debounced keystrokes, big JSON blobs. Debounce text search (local `useState` mirror + reconcile effect); keep canvas/presence/resize state in Zustand.
36
+
7.**Shareable view-state trapped in `useState`**: a tab/filter/sort/pagination/selected-entity that should be a link but lives in local state. Migrate it to the URL.
37
+
8.**Missing Suspense boundary**: a component newly calling `useQueryState`/`useQueryStates` whose page entry has no `<Suspense>` wrapper (Next.js requires it for `useSearchParams`). Add one with a real-chrome fallback.
38
+
9.**`import { z }` for param validation in client code**: use nuqs parsers instead.
39
+
40
+
## Steps
41
+
42
+
1. Read `.claude/rules/sim-url-state.md` and the nuqs docs above to understand the guidelines
43
+
2. Analyze the specified scope for the anti-patterns listed above
44
+
3. For each finding, decide the correct home using the decision table — do not force URL state onto ephemeral/high-frequency/socket-synced state
45
+
4. If fix=true, apply the fixes (co-locate a `search-params.ts`, wire `useQueryState(s)`, add the Suspense boundary, delete the replaced state + sync effects). If fix=false, propose the fixes without applying.
Copy file name to clipboardExpand all lines: .claude/rules/sim-url-state.md
+47Lines changed: 47 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -20,6 +20,15 @@ Pick exactly one home for each piece of state:
20
20
21
21
Put state in the URL **only** when it is *all* of: shareable, deep-linkable, bookmarkable, survives reload + back/forward — **and** is discrete, low-frequency, and small. If it fails any of those, it does not go in the URL.
22
22
23
+
### When to use what (decision table)
24
+
25
+
| Home | Trigger | Example |
26
+
| --- | --- | --- |
27
+
|**URL (nuqs)**| Client view-state worth a link: tab, filter, search, sort, pagination, selected-entity id, an open "view" modal/drawer that is a destination |`?tab=licenses`, `?category=Communication`, `?page=3`, `?skillId=abc`|
28
+
|**React Query**| Server/remote data fetched from an endpoint |`useMcpServers(workspaceId)`, `useSkills(workspaceId)`|
29
+
|**Zustand**| Cross-component client state that must NOT be in the URL: high-frequency, large, ephemeral, socket-synced | canvas pan/zoom, live cursor, drag state, resize widths, unsaved buffers |
30
+
|**`useState`**| Purely local single-component UI; also the snappy mirror of a debounced URL search | a hover flag, a transient dialog target, the live text of a debounced search box |
31
+
23
32
## Anti-patterns (forbidden)
24
33
25
34
- Direct `useSearchParams().get(...)` or `new URLSearchParams(window.location.search)` to **read** state.
@@ -119,6 +128,38 @@ If a client param must be re-read server-side after a change, set `shallow: fals
119
128
120
129
Keep local `useState` for snappy typing; push to the URL debounced, and reconcile from the URL with a ref-guarded effect so external URL changes (back/forward, deep link) flow back into the input without clobbering in-flight keystrokes. This is the established logs pattern — follow it rather than writing every keystroke to the URL.
121
130
131
+
## Sort convention (`sort` + `dir`)
132
+
133
+
Sortable lists use **two scalar params**, never a serialized `{column,direction}` object:
Both carry the shared filter options (`{ history: 'replace', clearOnDefault: true }`). The defaults must match the list's existing default sort exactly. If a UI exposes "no active sort" as `null`, derive that in the component (`sort === DEFAULT && dir === DEFAULT ? null : { column, direction }`) — the URL still holds the resolved values. "Clear sort" writes the defaults back (which `clearOnDefault` strips from the URL); never write `null`/garbage columns.
146
+
147
+
## Selected-entity deep-link (store the id, derive the object)
148
+
149
+
To deep-link a row/modal/drawer to one entity, store **only its id** and look the object up in the already-loaded list — never serialize the object into the URL:
Open the panel/modal when the id resolves to a loaded entity; closing it calls `setSkillId(null)`. Because this reads `useSearchParams` it needs a **Suspense** boundary on the page (see below). A separate "create new" flow has no id and stays in local `useState`.
162
+
122
163
## Read-then-strip deep links
123
164
124
165
For an ephemeral deep-link that pre-opens a modal/drawer and should not linger in the URL (e.g. integrations `?connect=oauth`, knowledge `?addConnector=`), read the param, act on it once behind a `useRef` guard, then clear it: `setParam(null, { history: 'replace', scroll: false })`. See `apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx`.
@@ -138,3 +179,9 @@ Borderline candidates that *look* shareable but currently stay in Zustand becaus
138
179
-**`focusedBlockId`** ("look at this block") — the only genuinely shareable candidate, but it is entangled with the persisted editor store and panel-open orchestration. Adding it is a *new feature*, not a migration; ship it deliberately (with runtime verification against a live socket), not as part of a sweep.
139
180
140
181
Rule of thumb for the editor: if state is socket-coupled, high-frequency, viewport-related, or a persisted resize/preference, it stays in Zustand. When in doubt, leave it and flag it — do not force fragile URL state into the canvas.
0 commit comments