diff --git a/.cursor/rules/list-filtering.mdc b/.cursor/rules/list-filtering.mdc
new file mode 100644
index 000000000..e5b4f8668
--- /dev/null
+++ b/.cursor/rules/list-filtering.mdc
@@ -0,0 +1,117 @@
+---
+description: Structure list filtering — a pure URL-state hook, a single-owner debounced search input, and refetch inside a transition (no whole-page Suspense fallback)
+globs: "**/*.{ts,tsx}"
+alwaysApply: false
+---
+
+# List filtering (URL state, debounced search, transition refetch)
+
+Filterable lists (a toolbar of selects + a search field driving a server-side
+query) have three recurring traps. Follow all three rules.
+
+## 1. The URL-filter hook is pure — no local state, no effects
+
+Filter values (category, status, sort, search term) live in the URL
+(`useSearchParams`). The hook that exposes them must be **pure**: read the
+params, return values + setters, and nothing else. No `useState`, no
+`useEffect`. A pure hook can be called by any number of components (page,
+loader, toolbar, empty state) that all read the same source of truth.
+
+The moment a URL-filter hook holds its own `useState` mirror **and** an effect
+that writes that mirror back to the URL, every component that calls the hook
+runs its own copy of that effect. Only one of them updates the mirror (the
+input), so the others keep a **stale** mirror and fight the real writer —
+producing an infinite loop that flips the list between filtered and unfiltered.
+
+```ts
+// BAD — hook owns a mirror + write-back effect; N callers = N fighting writers
+export function useListFilters() {
+ const [params, setParams] = useSearchParams();
+ const query = params.get("q") ?? "";
+ const [input, setInput] = useState(query); // per-instance state
+ useEffect(() => { // per-instance effect
+ if (input !== query) setParams(/* write q=input */, { replace: true });
+ }, [input, query, setParams]);
+ return { query, input, setInput /* … */ };
+}
+```
+
+```ts
+// GOOD — pure URL state; safe to call from many components
+export function useListFilters() {
+ const [params, setParams] = useSearchParams();
+ const setParam = useCallback((k: string, v: string) => {
+ setParams((prev) => {
+ const next = new URLSearchParams(prev);
+ if (v) next.set(k, v); else next.delete(k);
+ return next;
+ }, { replace: true });
+ }, [setParams]);
+ return {
+ query: params.get("q") ?? "",
+ category: params.get("category") ?? "",
+ setQuery: (v: string) => setParam("q", v),
+ setCategory: (v: string) => setParam("category", v),
+ clear: () => setParams({}, { replace: true }),
+ };
+}
+```
+
+## 2. The debounced search input is a single-owner hook
+
+The one piece of local state a filter needs is the free-text search box (typed
+immediately, committed to the URL after a debounce). Put it in a **separate**
+hook mounted in **exactly one** component (the toolbar) — it is the single
+writer of the `q` param. Guard the URL→input sync with a ref so the hook's own
+debounced writes are not echoed back onto the input (which would drop in-flight
+keystrokes); only *external* changes (a Clear button, back/forward) sync.
+
+```ts
+// GOOD — single-owner debounced input; ref distinguishes own writes from external
+export function useListSearch(): [string, (v: string) => void] {
+ const { query, setQuery } = useListFilters();
+ const [input, setInput] = useState(query);
+ const lastCommitted = useRef(query);
+
+ useEffect(() => { // debounce: input → URL
+ if (input === query) return;
+ const h = setTimeout(() => { lastCommitted.current = input; setQuery(input); }, 300);
+ return () => clearTimeout(h);
+ }, [input, query, setQuery]);
+
+ useEffect(() => { // sync URL → input for EXTERNAL changes only
+ if (query !== lastCommitted.current) { lastCommitted.current = query; setInput(query); }
+ }, [query]);
+
+ return [input, setInput];
+}
+```
+
+## 3. Refetch inside a transition — scope the loading state
+
+When a filter changes, refetch inside `startTransition` (React `useTransition`).
+Relay's `refetch` (and `loadQuery`/`usePaginationFragment`) suspends, and the
+nearest boundary is usually the **route-level `Suspense`** whose fallback is the
+whole-page skeleton — so without a transition the toolbar and results blank out
+on every keystroke. A transition keeps the current UI mounted and exposes
+`isPending`; dim only the results container (or show a scoped inline spinner),
+never the whole tree.
+
+```tsx
+// BAD — refetch suspends to the route skeleton; toolbar + results flash on every change
+useEffect(() => { refetch(vars); }, [refetch, vars]);
+```
+
+```tsx
+// GOOD — transition keeps the toolbar/results mounted; only the results dim
+const [isPending, startTransition] = useTransition();
+useEffect(() => {
+ startTransition(() => refetch(vars, { fetchPolicy: "store-or-network" }));
+}, [refetch, vars]);
+
+// results container only:
+
+```
+
+See [`contrib/claude/state-management.md`](../../contrib/claude/state-management.md#filtering-a-list)
+and [`contrib/claude/relay.md`](../../contrib/claude/relay.md#refetch-inside-a-transition).
diff --git a/contrib/claude/relay.md b/contrib/claude/relay.md
index c11e0ada9..56c3a8919 100644
--- a/contrib/claude/relay.md
+++ b/contrib/claude/relay.md
@@ -297,6 +297,44 @@ const [data, refetch] = useRefetchableFragment(thirdPartyContactsFragment, third
const connectionId = data.contacts.__id;
```
+## Refetch inside a transition
+
+`refetch` (and `loadQuery` / `usePaginationFragment`) **suspends** while the new
+data loads. The nearest `Suspense` boundary is usually the **route-level** one,
+whose fallback is the whole-page skeleton — so a bare `refetch` (e.g. re-running
+a list query when a filter changes) blanks the entire page, toolbar included, on
+every change.
+
+Wrap the refetch in `startTransition` (React `useTransition`). A transition keeps
+the current UI mounted instead of falling back to the boundary, and exposes
+`isPending` so you can scope the loading affordance to just the results — a dim,
+an inline spinner, or a small placeholder — never the whole tree. This is the
+default for filter/sort refetches; prefer a scoped transition over a page-level
+Suspense fallback.
+
+```tsx
+// Bad — refetch suspends to the route skeleton; the toolbar and results flash
+useEffect(() => { refetch(variables); }, [refetch, variables]);
+```
+
+```tsx
+// Good — transition keeps the toolbar + current results mounted; only results dim
+const [isPending, startTransition] = useTransition();
+useEffect(() => {
+ startTransition(() => {
+ refetch(variables, { fetchPolicy: "store-or-network" });
+ });
+}, [refetch, variables]);
+
+// scope the loading state to the results container:
+
+ {/* list */}
+
+```
+
+See [`state-management.md`](state-management.md#filtering-a-list) for the full
+list-filtering pattern (pure URL-state hook + single-owner debounced search).
+
## Pagination
Use `usePaginationFragment` for cursor-based Relay pagination:
diff --git a/contrib/claude/state-management.md b/contrib/claude/state-management.md
index febf5b282..8c46e9c75 100644
--- a/contrib/claude/state-management.md
+++ b/contrib/claude/state-management.md
@@ -65,6 +65,87 @@ useFilterStore(); // global singleton for a local concern
`zustand` is available (it's a `@probo/ui` dependency) — use it deliberately for app-wide ephemeral state, not as a shortcut around prop-passing or the URL.
+## Filtering a list
+
+A filterable list (a toolbar of selects + a search box driving a server-side
+query) combines several of the rules above. Three points matter, in order:
+
+1. **The filter values live in the URL** (rule 2): category, status, sort, and
+ the search term go in `useSearchParams`. Expose them through a hook that is
+ **pure** — it reads the params and returns values + setters, with **no
+ `useState` and no `useEffect`**. A pure hook is safe to call from every
+ component that needs the filters (page, loader, toolbar, empty state); they
+ all read one source of truth.
+
+ The failure mode: a URL-filter hook that keeps its own `useState` mirror
+ **and** an effect writing that mirror back to the URL. Every caller runs its
+ own copy of that effect, but only one (the input) updates the mirror — the
+ rest keep a **stale** mirror and fight the real writer, flipping the list
+ between filtered and unfiltered in an infinite loop.
+
+2. **The debounced search input is the one piece of local state** — and it lives
+ in a **separate, single-owner hook** mounted in exactly one component (the
+ toolbar), the single writer of the search param. It holds the immediate input
+ value and commits it to the URL after a debounce. Guard the URL→input sync
+ with a `ref` tracking your own writes, so a debounced commit isn't echoed
+ back onto the input (which would drop in-flight keystrokes); only *external*
+ changes (a Clear button, back/forward) sync.
+
+3. **Refetch on change inside a transition** so the loading state is scoped to
+ the results, not the whole page — see
+ [`relay.md`](relay.md#refetch-inside-a-transition).
+
+```ts
+// Bad — URL-filter hook with a per-instance mirror + write-back effect.
+// Called by the page, loader, toolbar and empty state → four fighting writers.
+export function useThirdPartyFilters() {
+ const [params, setParams] = useSearchParams();
+ const query = params.get("q") ?? "";
+ const [input, setInput] = useState(query);
+ useEffect(() => {
+ if (input !== query) setParams(/* write q=input */, { replace: true });
+ }, [input, query, setParams]);
+ return { query, input, setInput /* … */ };
+}
+```
+
+```ts
+// Good — pure URL-state hook (no state/effects); safe to call anywhere
+export function useThirdPartyFilters() {
+ const [params, setParams] = useSearchParams();
+ const setParam = useCallback((k: string, v: string) => {
+ setParams((prev) => {
+ const next = new URLSearchParams(prev);
+ if (v) next.set(k, v); else next.delete(k);
+ return next;
+ }, { replace: true });
+ }, [setParams]);
+ return {
+ query: params.get("q") ?? "",
+ category: params.get("category") ?? "",
+ setQuery: (v: string) => setParam("q", v),
+ setCategory: (v: string) => setParam("category", v),
+ clear: () => setParams({}, { replace: true }),
+ };
+}
+
+// Good — debounced search input in its own single-owner hook (used by the toolbar)
+export function useThirdPartySearch(): [string, (v: string) => void] {
+ const { query, setQuery } = useThirdPartyFilters();
+ const [input, setInput] = useState(query);
+ const lastCommitted = useRef(query);
+ useEffect(() => { // debounce input → URL
+ if (input === query) return;
+ const h = setTimeout(() => { lastCommitted.current = input; setQuery(input); }, 300);
+ return () => clearTimeout(h);
+ }, [input, query, setQuery]);
+ useEffect(() => { // sync URL → input for external changes only
+ if (query !== lastCommitted.current) { lastCommitted.current = query; setInput(query); }
+ }, [query]);
+ return [input, setInput];
+}
+```
+
## Anti-patterns to avoid
- **Prop-drilling data** that a child could read from Relay (`useFragment`) or the router (`useParams`) itself.