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:
117
.cursor/rules/list-filtering.mdc
Normal file
117
.cursor/rules/list-filtering.mdc
Normal 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).
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user