Skip to content

Commit 369fbdd

Browse files
doc: Add React Query cache conventions documentation
Create docs/react-query-cache-conventions.md covering query key naming ([entity, identifier?, scope]), the central factory pattern in src/lib/queryKeys.ts, invalidateQueries vs setQueryData decision guidance, a worked example for adding a new entity type, and stale time/gcTime defaults with override recommendations. Also cross-link the new doc from the existing state management guide. Closes #602
1 parent 3299420 commit 369fbdd

2 files changed

Lines changed: 261 additions & 0 deletions

File tree

Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
1+
# React Query Cache Conventions
2+
3+
This document describes the conventions for React Query cache keys and cache
4+
invalidation used across the client. Following these conventions keeps query
5+
keys predictable, invalidation reliable, and cache behaviour consistent.
6+
7+
---
8+
9+
## Query Key Structure
10+
11+
Every query key follows the general shape:
12+
13+
```
14+
[entity, identifier?, scope?]
15+
```
16+
17+
- **entity** — the domain object (e.g. `'creators'`, `'wallet'`)
18+
- **identifier** — a specific record id or address when targeting one item
19+
- **scope** — the view or sub-resource (e.g. `'list'`, `'detail'`, `'holders'`)
20+
21+
### The Query Key Factory
22+
23+
All keys are defined in a **single central factory** at
24+
`src/lib/queryKeys.ts`. Hooks and mutations import from it rather than
25+
constructing inline arrays.
26+
27+
```ts
28+
// src/lib/queryKeys.ts
29+
export const queryKeys = {
30+
creators: {
31+
all: ['creators'] as const,
32+
list: (params?: GetCoursesParams) =>
33+
['creators', 'list', params ?? null] as const,
34+
detail: (id: string) => ['creators', 'detail', id] as const,
35+
holders: (creatorId: string) =>
36+
['creators', creatorId, 'holders'] as const,
37+
},
38+
wallet: {
39+
holdings: (address: string) => ['wallet', address, 'holdings'] as const,
40+
activity: (address: string) => ['wallet', address, 'activity'] as const,
41+
},
42+
};
43+
```
44+
45+
Key design rules:
46+
47+
1. **`all` key** — every entity group exposes a static `all` key
48+
(`['creators']`) so a single `invalidateQueries` call can target every key
49+
in that domain.
50+
2. **Shared prefixes** — keys within a group share the leading segment so
51+
prefix-based invalidation works. Invalidating `['creators']` will mark every
52+
creator key stale.
53+
3. **`as const`** — factory functions return `as const` tuples so TypeScript
54+
infers literal types instead of `string[]`.
55+
4. **Optional params** — when a list key receives no filter, it stores `null`
56+
at the param position so the key shape is always consistent.
57+
5. **No inline keys** — production hooks must use the factory. (Existing code
58+
in `useCreatorHolderCount.ts` uses an inline key as a deliberate exception
59+
because the `queryFn` is injected for testability.)
60+
61+
### Adding a New Entity
62+
63+
To add a new entity type — for example `courses` — extend the factory with the
64+
same patterns:
65+
66+
```ts
67+
import type { GetCoursesParams } from '@/services/course.service';
68+
69+
export const queryKeys = {
70+
creators: { /**/ },
71+
wallet: { /**/ },
72+
courses: {
73+
all: ['courses'] as const,
74+
list: (params?: GetCoursesParams) =>
75+
['courses', 'list', params ?? null] as const,
76+
detail: (id: string) => ['courses', 'detail', id] as const,
77+
enrollments: (courseId: string) =>
78+
['courses', courseId, 'enrollments'] as const,
79+
},
80+
};
81+
```
82+
83+
Then use it in hooks:
84+
85+
```ts
86+
import { useQuery } from '@tanstack/react-query';
87+
import { queryKeys } from '@/lib/queryKeys';
88+
import { courseService } from '@/services/course.service';
89+
90+
export function useCourseDetail(id: string) {
91+
return useQuery({
92+
queryKey: queryKeys.courses.detail(id),
93+
queryFn: () => courseService.getById(id),
94+
enabled: !!id,
95+
});
96+
}
97+
```
98+
99+
The corresponding unit tests in `src/lib/__tests__/queryKeys.test.ts` verify key
100+
shapes and shared prefixes:
101+
102+
```ts
103+
it('courses.detail shares the courses prefix with courses.all', () => {
104+
expect(queryKeys.courses.detail('x')[0]).toBe(
105+
queryKeys.courses.all[0],
106+
);
107+
});
108+
109+
it('courses.detail embeds the id at index 2', () => {
110+
expect(queryKeys.courses.detail('course-123')[2]).toBe('course-123');
111+
});
112+
```
113+
114+
---
115+
116+
## Cache Invalidation Patterns
117+
118+
### `invalidateQueries` (preferred after writes)
119+
120+
After a mutation that changes server data, **invalidate** stale queries and let
121+
React Query refetch in the background:
122+
123+
```ts
124+
import { useMutation, useQueryClient } from '@tanstack/react-query';
125+
import { queryKeys } from '@/lib/queryKeys';
126+
127+
export function useEnrollInCourse() {
128+
const queryClient = useQueryClient();
129+
130+
return useMutation({
131+
mutationFn: (courseId: string) => courseService.enroll(courseId),
132+
onSuccess: (_, courseId) => {
133+
queryClient.invalidateQueries({
134+
queryKey: queryKeys.courses.enrollments(courseId),
135+
});
136+
queryClient.invalidateQueries({
137+
queryKey: queryKeys.courses.detail(courseId),
138+
});
139+
},
140+
});
141+
}
142+
```
143+
144+
Use `invalidateQueries` when:
145+
146+
- The server is the source of truth for the mutated data.
147+
- The mutation response does not contain the full updated entity.
148+
- Multiple queries might be affected and you want them all to refetch.
149+
150+
### `setQueryData` (optimistic or server-returned data)
151+
152+
Use `setQueryData` when the mutation response contains the **exact** updated
153+
data and you want to avoid an extra network roundtrip:
154+
155+
```ts
156+
export function useUpdateCourseTitle() {
157+
const queryClient = useQueryClient();
158+
159+
return useMutation({
160+
mutationFn: ({
161+
courseId,
162+
title,
163+
}: { courseId: string; title: string }) =>
164+
courseService.updateTitle(courseId, title),
165+
onSuccess: (updatedCourse, { courseId }) => {
166+
queryClient.setQueryData(
167+
queryKeys.courses.detail(courseId),
168+
updatedCourse,
169+
);
170+
},
171+
});
172+
}
173+
```
174+
175+
Use `setQueryData` when:
176+
177+
- The server returns the complete updated entity in the mutation response.
178+
- You are implementing **optimistic updates** and need to roll back on error.
179+
- The updated data is needed immediately without waiting for a refetch.
180+
181+
### Decision Table
182+
183+
| Situation | Approach |
184+
|---|---|
185+
| Mutation changes server state, response is minimal | `invalidateQueries` |
186+
| Mutation response includes full updated object | `setQueryData` |
187+
| Optimistic update with rollback | `setQueryData` + `onError` rollback |
188+
| Multiple entities affected by one mutation | `invalidateQueries` on shared prefix |
189+
| User clicks "Refresh" button | `refetch()` on the specific query |
190+
191+
See [docs/state-management.md](./state-management.md) for the general rule on
192+
when data belongs in React Query vs local state.
193+
194+
---
195+
196+
## Stale Time and Cache Time
197+
198+
### Defaults
199+
200+
The client does not set global overrides, so React Query v5 defaults apply:
201+
202+
| Option | Default | Meaning |
203+
|---|---|---|
204+
| `staleTime` | `0` | Data is stale immediately. Queries refetch on mount, window focus, and reconnect. |
205+
| `gcTime` | `5 * 60 * 1000` (5 minutes) | Unused/inactive data stays in the cache for 5 minutes before garbage collection. |
206+
207+
### When to Override
208+
209+
Override `staleTime` for data that changes infrequently. This reduces
210+
unnecessary network requests:
211+
212+
```ts
213+
// Price data that updates every 30 seconds
214+
useQuery({
215+
queryKey: queryKeys.creators.holders(creatorId),
216+
queryFn: () => fetchHolderCount(creatorId),
217+
staleTime: 30_000,
218+
});
219+
```
220+
221+
| Scenario | Recommended `staleTime` | Rationale |
222+
|---|---|---|
223+
| Real-time or live data (prices, balances) | `0` (default) | Always show the latest value. |
224+
| Semi-static data (profile details, course metadata) | `30_000``60_000` (30–60 s) | Balances freshness against unnecessary refetches. |
225+
| Rarely-changing data (creator list, static config) | `5 * 60_000` (5 min) or longer | Reduce bandwidth for data that barely changes. |
226+
| Data that never changes during a session | `Infinity` | Fetch once; never refetch until the page reloads. |
227+
228+
Override `gcTime` only when you want to keep data in the cache longer (or
229+
shorter) than the 5 minute default — for example, to preserve form draft data
230+
across navigation:
231+
232+
```ts
233+
useQuery({
234+
queryKey: queryKeys.courses.detail(courseId),
235+
queryFn: () => courseService.getById(courseId),
236+
gcTime: 10 * 60_000, // keep in cache for 10 minutes after unmount
237+
});
238+
```
239+
240+
### Important
241+
242+
- `gcTime` must always be **greater than** `staleTime` (if both are set).
243+
- React Query v5 renamed `cacheTime` to `gcTime`. Use `gcTime` everywhere.
244+
- The `MutationCache` in `src/providers/web3Utils.ts` logs structured error
245+
data on mutation failures. There is no need to add per-hook error logging.
246+
247+
---
248+
249+
## Cross-references
250+
251+
- [State Management Overview](./state-management.md) — when to use React Query
252+
vs local state
253+
- [Error Handling in Hooks](./error-handling-in-hooks.md)`useMutation`
254+
patterns with toasts and invalidation
255+
- [API Layer Conventions](./api-layer.md) — service layer and `ApiError` class
256+
- [Contribution Guide](../CONTRIBUTING.md) — verification commands, naming
257+
conventions, and PR workflow
258+
- [Adding a Page Route](./adding-page-routes.md) — how to register a new route
259+
that consumes these hooks

docs/state-management.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ queryClient.invalidateQueries({ queryKey: queryKeys.creators.list() });
2020

2121
This marks cached data stale and lets React Query refetch in the background the next time the query is observed. Use this after a buy, sell, or profile update so all subscribers see fresh data automatically.
2222

23+
See [React Query Cache Conventions](./react-query-cache-conventions.md) for the query key naming convention, the `invalidateQueries` vs `setQueryData` decision guide, and stale time defaults.
24+
2325
## Refetch manually
2426

2527
Refetch manually only when you need to force an immediate reload independent of staleness — for example, a user-triggered "Refresh" button:

0 commit comments

Comments
 (0)