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

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