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.