|
| 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 |
0 commit comments