Document the list-filtering pattern

Codify the lessons from the subprocessors filter fix as reusable guidance:
a pure URL-state filter hook (never a per-instance mirror + write-back
effect), a single-owner debounced search hook, and refetching inside a
transition to scope the loading state to the results instead of the
whole-page Suspense fallback.

Add a list-filtering Cursor rule and expand the state-management and relay
guides with the corresponding sections.

Signed-off-by: Émile Ré <emile@probo.com>
This commit is contained in:
Émile Ré
2026-07-08 09:38:10 -04:00
parent b4b6599907
commit 8ed92e13b3
3 changed files with 236 additions and 0 deletions

View File

@@ -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:
<div aria-busy={isPending} className={`… transition-opacity ${isPending ? "opacity-60" : ""}`}>
```
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).

View File

@@ -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:
<div aria-busy={isPending} className={`… transition-opacity ${isPending ? "opacity-60" : ""}`}>
{/* list */}
</div>
```
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:

View File

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