Skip to content

Commit 2bf64c1

Browse files
fylornclaude
andcommitted
Document the query layer in the web README
The "Data fetching" section described hand-rolled loads and the two reasons a suppression of set-state-in-effect could stand. Both are gone, so the section now describes the query layer — keys, writes, signals, deriving local state — and keeps the rule against new suppressions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent c6041b3 commit 2bf64c1

1 file changed

Lines changed: 33 additions & 22 deletions

File tree

‎web/README.md‎

Lines changed: 33 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ The management console for ThinkWatch, built with React 19, TypeScript, and Vite
66

77
- **React 19** with TypeScript
88
- **TanStack Router** for file-based routing
9+
- **TanStack Query** for server state — see [Data fetching](#data-fetching)
910
- **shadcn/ui** (Radix UI + Tailwind CSS 4) for components
1011
- **react-i18next** for internationalization (English + Chinese)
1112
- **Vitest** + React Testing Library for testing
@@ -23,28 +24,36 @@ pnpm exec tsc --noEmit # Type check
2324

2425
## Data fetching
2526

26-
Loads are hand-rolled: a `useCallback` that fetches and sets state, plus a
27-
`useEffect` that calls it. There is no data-fetching layer in this app.
28-
29-
That shape trips `react-hooks/set-state-in-effect`, and the sites that it
30-
flags carry a one-line suppression saying which of two things is going on:
31-
32-
- **The rule is wrong.** `useEffect(() => { load(); }, [load])` where `load`
33-
is async and its first statement is the `await`. Every setState inside
34-
runs in the continuation — never synchronously with the effect, never a
35-
cascading render. The rule's cross-function analysis does not model
36-
`await`.
37-
- **The rule is right and the fix is architectural.** The loader's first
38-
statement flips a spinner. Hoisting that flag out to render-time silences
39-
the rule, but it splits "start a load" across two places and leaves every
40-
other caller of the loader responsible for remembering half of it.
41-
42-
**Both go away with a data-fetching layer** (TanStack Query or equivalent),
43-
which owns the loading flag and the cache and removes the effect entirely.
44-
That is a deliberate piece of work, not something to fold into a lint pass.
45-
Until then, do not add new suppressions of this rule without one of the two
46-
reasons above — every other finding it reports is a real one, and the rest
47-
of the codebase is clean of them.
27+
Server state goes through [TanStack Query](https://tanstack.com/query). A
28+
component asks for what it renders with `useQuery`, and the query layer owns
29+
the rest: the loading flag, the cache, cancelling a request nobody is waiting
30+
for, and keeping a late response from landing under filters that have since
31+
changed. No load runs in an effect.
32+
33+
- **Keys follow the endpoint.** `GET /api/admin/teams/{id}/members` is keyed
34+
`['admin', 'teams', id, 'members']`, with query-string parameters in a
35+
trailing object. A key names everything its `queryFn` reads —
36+
`@tanstack/query/exhaustive-deps` enforces that — and invalidating a
37+
prefix such as `['admin', 'teams']` reaches every query under it. Pass
38+
`exact: true` when a prefix would reach further than the write did.
39+
- **Writes invalidate.** After a mutation, `await
40+
queryClient.invalidateQueries({ queryKey })` for what the screen shows.
41+
Awaiting keeps orderings like "close the dialog once the list is fresh".
42+
Screens that aren't open need nothing: every successful write through
43+
`api()` drops the cached queries no screen is using (`onSuccessfulWrite`,
44+
wired up in `main.tsx`), so none of them reopens onto pre-write data.
45+
- **Pass the signal.** `queryFn: ({ signal }) => api(path, { signal })` lets
46+
the layer cancel a request once no component needs its answer.
47+
- **Derive, don't copy.** Read `query.data` where it is rendered. When a
48+
screen needs local state that follows what was loaded — a form draft, a
49+
selection — adjust it during render, the way `useResetOnChange` does.
50+
51+
`src/lib/query-client.ts` builds the client and says why its defaults differ
52+
from the library's. Tests render through `renderWithQueryClient` from
53+
`src/test/render.tsx`, which gives every call an empty cache.
54+
55+
Do not add new suppressions of `react-hooks/set-state-in-effect` — every
56+
finding it reports is a real one, and the codebase is clean of them.
4857

4958
## Project Structure
5059

@@ -58,6 +67,7 @@ src/
5867
│ └── use-mobile.ts # Responsive breakpoint detection
5968
├── lib/
6069
│ ├── api.ts # HTTP client with HMAC signing & auto token refresh
70+
│ ├── query-client.ts # TanStack Query client & its defaults
6171
│ └── utils.ts # Utility functions
6272
├── i18n/
6373
│ ├── en.json # English translations
@@ -88,6 +98,7 @@ src/
8898
│ ├── settings.tsx # Dynamic system settings (7 tabs)
8999
│ └── log-forwarders.tsx # Log forwarding configuration
90100
├── test/
101+
│ ├── render.tsx # render() inside a fresh query cache
91102
│ └── setup.ts # Test setup (jest-dom + i18n)
92103
└── router.tsx # Route definitions & setup redirect logic
93104
```

0 commit comments

Comments
 (0)