|
| 1 | +--- |
| 2 | +name: studiolambda-query |
| 3 | +description: > |
| 4 | + Skill for using @studiolambda/query, a lightweight isomorphic SWR-style async data |
| 5 | + management library. Use when writing, editing, reviewing, or testing code that uses |
| 6 | + createQuery, useQuery, QueryProvider, cache mutations, hydration, event subscriptions, |
| 7 | + or any @studiolambda/query API. Covers the core framework-agnostic library and |
| 8 | + React 19+ bindings (hooks and components). |
| 9 | +license: MIT |
| 10 | +compatibility: Requires Node.js 18+. React bindings require React 19.1+. |
| 11 | +metadata: |
| 12 | + version: "1.4.0" |
| 13 | + author: studiolambda |
| 14 | +--- |
| 15 | + |
| 16 | +# @studiolambda/query |
| 17 | + |
| 18 | +Lightweight (~1.7KB), isomorphic, framework-agnostic SWR-style async data management library with React 19+ bindings. |
| 19 | + |
| 20 | +**Install:** `npm i @studiolambda/query` |
| 21 | + |
| 22 | +**Import paths:** |
| 23 | +- Core: `@studiolambda/query` |
| 24 | +- React: `@studiolambda/query/react` |
| 25 | + |
| 26 | +## Core API |
| 27 | + |
| 28 | +### Creating an instance |
| 29 | + |
| 30 | +```typescript |
| 31 | +import { createQuery } from '@studiolambda/query' |
| 32 | + |
| 33 | +const query = createQuery({ |
| 34 | + fetcher: async (key, { signal }) => { |
| 35 | + const res = await fetch(key, { signal }) |
| 36 | + if (!res.ok) throw new Error(res.statusText) |
| 37 | + return res.json() |
| 38 | + }, |
| 39 | + expiration: () => 5000, // cache for 5 seconds |
| 40 | + stale: true, // return stale data while revalidating (default) |
| 41 | + removeOnError: false, // keep cached item on fetch error (default) |
| 42 | + fresh: false, // respect cache (default) |
| 43 | +}) |
| 44 | +``` |
| 45 | + |
| 46 | +### Configuration options |
| 47 | + |
| 48 | +| Option | Type | Default | Description | |
| 49 | +|--------|------|---------|-------------| |
| 50 | +| `expiration` | `(item: T) => number` | `() => 2000` | Cache duration in ms | |
| 51 | +| `fetcher` | `(key: string, { signal }) => Promise<T>` | `fetch`-based JSON | Data fetcher function | |
| 52 | +| `stale` | `boolean` | `true` | Return stale data while revalidating | |
| 53 | +| `removeOnError` | `boolean` | `false` | Remove cached item on fetch error | |
| 54 | +| `fresh` | `boolean` | `false` | Always bypass cache | |
| 55 | + |
| 56 | +Instance-only options: `itemsCache`, `resolversCache`, `events` (EventTarget), `broadcast` (BroadcastChannel). |
| 57 | + |
| 58 | +### Query |
| 59 | + |
| 60 | +```typescript |
| 61 | +const data = await query.query<User>('/api/user/1') |
| 62 | + |
| 63 | +// Per-query option overrides |
| 64 | +const data = await query.query<User>('/api/user/1', { |
| 65 | + fetcher: customFetcher, |
| 66 | + stale: false, |
| 67 | + fresh: true, |
| 68 | +}) |
| 69 | +``` |
| 70 | + |
| 71 | +### Mutations |
| 72 | + |
| 73 | +```typescript |
| 74 | +// Direct value |
| 75 | +await query.mutate('/api/user', updatedUser) |
| 76 | + |
| 77 | +// Function based on previous value |
| 78 | +await query.mutate<Post[]>('/api/posts', (previous) => [...(previous ?? []), newPost]) |
| 79 | + |
| 80 | +// Async mutation |
| 81 | +await query.mutate('/api/posts', async (previous) => { |
| 82 | + const post = await createNewPost() |
| 83 | + return [...(previous ?? []), post] |
| 84 | +}) |
| 85 | + |
| 86 | +// With custom expiration |
| 87 | +await query.mutate('/api/user', updatedUser, { expiration: () => 10000 }) |
| 88 | +``` |
| 89 | + |
| 90 | +### Forget (invalidate cache) |
| 91 | + |
| 92 | +```typescript |
| 93 | +await query.forget('/api/user') // Single key |
| 94 | +await query.forget(['/api/user', '/api/posts']) // Multiple keys |
| 95 | +await query.forget(/^\/api\/users(.*)/) // Regex pattern |
| 96 | +await query.forget() // All keys |
| 97 | +``` |
| 98 | + |
| 99 | +### Hydrate (pre-populate cache) |
| 100 | + |
| 101 | +```typescript |
| 102 | +query.hydrate('/api/user', serverData, { expiration: () => 10000 }) |
| 103 | +query.hydrate(['/api/post/1', '/api/post/2'], defaultPost) |
| 104 | +``` |
| 105 | + |
| 106 | +### Abort |
| 107 | + |
| 108 | +```typescript |
| 109 | +query.abort('/api/user') // Abort single key |
| 110 | +query.abort(['/api/user', '/api/posts']) // Abort multiple |
| 111 | +query.abort() // Abort all pending |
| 112 | +query.abort('/api/user', 'cancelled') // With custom reason |
| 113 | +``` |
| 114 | + |
| 115 | +### Inspect cache |
| 116 | + |
| 117 | +```typescript |
| 118 | +const value = await query.snapshot<User>('/api/user') // Current cached value or undefined |
| 119 | +const itemKeys = query.keys('items') // Cached item keys |
| 120 | +const resolverKeys = query.keys('resolvers') // Pending resolver keys |
| 121 | +const date = query.expiration('/api/user') // Expiration Date or undefined |
| 122 | +``` |
| 123 | + |
| 124 | +### Reconfigure |
| 125 | + |
| 126 | +```typescript |
| 127 | +query.configure({ expiration: () => 10000, stale: false }) |
| 128 | +``` |
| 129 | + |
| 130 | +### Events |
| 131 | + |
| 132 | +Events: `refetching`, `resolved`, `mutating`, `mutated`, `aborted`, `forgotten`, `hydrated`, `error`. |
| 133 | + |
| 134 | +```typescript |
| 135 | +// Subscribe (returns unsubscriber) |
| 136 | +const unsub = query.subscribe('/api/user', 'resolved', (event) => { |
| 137 | + console.log('resolved:', event.detail) |
| 138 | +}) |
| 139 | +unsub() |
| 140 | + |
| 141 | +// One-time listener |
| 142 | +const event = await query.once('/api/user', 'resolved') |
| 143 | + |
| 144 | +// Await next fetch resolution |
| 145 | +const result = await query.next<string>('/api/user') |
| 146 | +const [a, b] = await query.next<[User, Config]>(['/api/user', '/api/config']) |
| 147 | + |
| 148 | +// Stream resolutions (async generator) |
| 149 | +for await (const value of query.stream<User>('/api/user')) { |
| 150 | + console.log(value) |
| 151 | +} |
| 152 | + |
| 153 | +// Stream arbitrary events (async generator) |
| 154 | +for await (const event of query.sequence<User>('/api/user', 'resolved')) { |
| 155 | + console.log(event.detail) |
| 156 | +} |
| 157 | +``` |
| 158 | + |
| 159 | +### Cross-tab sync |
| 160 | + |
| 161 | +```typescript |
| 162 | +// Must configure broadcast manually in vanilla usage |
| 163 | +query.configure({ broadcast: new BroadcastChannel('query') }) |
| 164 | +const unsub = query.subscribeBroadcast() |
| 165 | +// ... later |
| 166 | +unsub() |
| 167 | +``` |
| 168 | + |
| 169 | +## React Bindings |
| 170 | + |
| 171 | +Designed for React 19+ with first-class Suspense and Transitions support. |
| 172 | + |
| 173 | +### Setup |
| 174 | + |
| 175 | +```tsx |
| 176 | +import { QueryProvider } from '@studiolambda/query/react' |
| 177 | +import { createQuery } from '@studiolambda/query' |
| 178 | + |
| 179 | +const query = createQuery({ fetcher: myFetcher }) |
| 180 | + |
| 181 | +function App() { |
| 182 | + return ( |
| 183 | + <QueryProvider query={query} clearOnForget> |
| 184 | + <Suspense fallback={<Loading />}> |
| 185 | + <MyComponents /> |
| 186 | + </Suspense> |
| 187 | + </QueryProvider> |
| 188 | + ) |
| 189 | +} |
| 190 | +``` |
| 191 | + |
| 192 | +`QueryProvider` props: |
| 193 | +- `query?` - Query instance (creates one if omitted) |
| 194 | +- `clearOnForget?` - Auto-refetch after `forget()` (default `false`) |
| 195 | +- `ignoreTransitionContext?` - Use local transitions instead of shared (default `false`) |
| 196 | + |
| 197 | +`QueryProvider` automatically handles BroadcastChannel setup and cleanup. |
| 198 | + |
| 199 | +### useQuery |
| 200 | + |
| 201 | +The primary hook. Components using it **must** be inside `<Suspense>`. |
| 202 | + |
| 203 | +```tsx |
| 204 | +import { useQuery } from '@studiolambda/query/react' |
| 205 | + |
| 206 | +function UserProfile() { |
| 207 | + const { data, isPending, isRefetching, refetch, mutate, forget } = useQuery<User>('/api/user/1') |
| 208 | + |
| 209 | + return ( |
| 210 | + <div style={{ opacity: isPending ? 0.5 : 1 }}> |
| 211 | + <h1>{data.name}</h1> |
| 212 | + <button onClick={() => refetch()}>Refresh</button> |
| 213 | + <button onClick={() => mutate({ ...data, name: 'New Name' })}>Update</button> |
| 214 | + <button onClick={() => forget()}>Clear</button> |
| 215 | + </div> |
| 216 | + ) |
| 217 | +} |
| 218 | +``` |
| 219 | + |
| 220 | +**Returns:** |
| 221 | + |
| 222 | +| Property | Type | Description | |
| 223 | +|----------|------|-------------| |
| 224 | +| `data` | `T` | Resolved data (always available, Suspense handles loading) | |
| 225 | +| `isPending` | `boolean` | Transition pending for mutations/refetches | |
| 226 | +| `expiresAt` | `Date` | When cached data expires | |
| 227 | +| `isExpired` | `boolean` | Whether data is stale | |
| 228 | +| `isRefetching` | `boolean` | Background refetch in progress | |
| 229 | +| `isMutating` | `boolean` | Mutation in progress (async mutations only) | |
| 230 | +| `refetch` | `(options?) => Promise<T>` | Trigger fresh fetch | |
| 231 | +| `mutate` | `(value, options?) => Promise<T>` | Optimistic mutation | |
| 232 | +| `forget` | `() => Promise<void>` | Clear cached data | |
| 233 | + |
| 234 | +**Options (second argument):** All core `Options` fields plus `query?`, `clearOnForget?`, `ignoreTransitionContext?`. |
| 235 | + |
| 236 | +### useQueryActions |
| 237 | + |
| 238 | +Actions without data subscription. Use when you need to mutate/refetch from a sibling component. |
| 239 | + |
| 240 | +```tsx |
| 241 | +const { refetch, mutate, forget } = useQueryActions<User>('/api/user/1') |
| 242 | +``` |
| 243 | + |
| 244 | +### useQueryStatus |
| 245 | + |
| 246 | +Status without data subscription. |
| 247 | + |
| 248 | +```tsx |
| 249 | +const { expiresAt, isExpired, isRefetching, isMutating } = useQueryStatus('/api/user/1') |
| 250 | +``` |
| 251 | + |
| 252 | +### useQueryBasic |
| 253 | + |
| 254 | +Minimal hook returning only `data` and `isPending`. |
| 255 | + |
| 256 | +```tsx |
| 257 | +const { data, isPending } = useQueryBasic<User>('/api/user/1') |
| 258 | +``` |
| 259 | + |
| 260 | +### useQueryInstance |
| 261 | + |
| 262 | +Get the raw `Query` instance from context. Throws if none found. |
| 263 | + |
| 264 | +```tsx |
| 265 | +const queryInstance = useQueryInstance() |
| 266 | +``` |
| 267 | + |
| 268 | +### useQueryPrefetch |
| 269 | + |
| 270 | +Prefetch keys on mount. |
| 271 | + |
| 272 | +```tsx |
| 273 | +import { useMemo } from 'react' |
| 274 | + |
| 275 | +const keys = useMemo(() => ['/api/user/1', '/api/config'], []) |
| 276 | +useQueryPrefetch(keys) |
| 277 | +``` |
| 278 | + |
| 279 | +### QueryTransition |
| 280 | + |
| 281 | +Share a single transition across multiple `useQuery` calls: |
| 282 | + |
| 283 | +```tsx |
| 284 | +import { QueryTransition } from '@studiolambda/query/react' |
| 285 | +import { useTransition } from 'react' |
| 286 | + |
| 287 | +function App() { |
| 288 | + const [isPending, startTransition] = useTransition() |
| 289 | + return ( |
| 290 | + <QueryTransition isPending={isPending} startTransition={startTransition}> |
| 291 | + <UserList /> |
| 292 | + <UserDetails /> |
| 293 | + </QueryTransition> |
| 294 | + ) |
| 295 | +} |
| 296 | +``` |
| 297 | + |
| 298 | +### QueryPrefetch / QueryPrefetchTags |
| 299 | + |
| 300 | +```tsx |
| 301 | +import { useMemo } from 'react' |
| 302 | + |
| 303 | +const keys = useMemo(() => ['/api/user', '/api/config'], []) |
| 304 | + |
| 305 | +<QueryPrefetch keys={keys}> |
| 306 | + <Content /> |
| 307 | +</QueryPrefetch> |
| 308 | + |
| 309 | +// Also renders <link rel="preload" as="fetch"> tags |
| 310 | +<QueryPrefetchTags keys={keys}> |
| 311 | + <Content /> |
| 312 | +</QueryPrefetchTags> |
| 313 | +``` |
| 314 | + |
| 315 | +### Testing React components |
| 316 | + |
| 317 | +```tsx |
| 318 | +import { createQuery } from '@studiolambda/query' |
| 319 | +import { useQuery } from '@studiolambda/query/react' |
| 320 | +import { act, Suspense } from 'react' |
| 321 | +import { createRoot } from 'react-dom/client' |
| 322 | + |
| 323 | +it('renders user data', async ({ expect }) => { |
| 324 | + const query = createQuery({ fetcher: () => Promise.resolve({ name: 'Ada' }) }) |
| 325 | + const promise = query.next<User>('/api/user') |
| 326 | + |
| 327 | + function Component() { |
| 328 | + const { data } = useQuery<User>('/api/user', { query }) |
| 329 | + return <span>{data.name}</span> |
| 330 | + } |
| 331 | + |
| 332 | + const el = document.createElement('div') |
| 333 | + |
| 334 | + await act(async () => { |
| 335 | + createRoot(el).render( |
| 336 | + <Suspense fallback="loading"><Component /></Suspense> |
| 337 | + ) |
| 338 | + }) |
| 339 | + |
| 340 | + await act(async () => { await promise }) |
| 341 | + expect(el.innerText).toBe('Ada') |
| 342 | +}) |
| 343 | +``` |
| 344 | + |
| 345 | +Pattern: create query with mock fetcher, pass it via `{ query }` option to bypass context, use `query.next(key)` to await resolution. |
| 346 | + |
| 347 | +## Gotchas |
| 348 | + |
| 349 | +- **Expiration is a function, not a number.** Always `expiration: () => 5000`, not `expiration: 5000`. |
| 350 | +- **`useQuery` suspends.** Components must be inside `<Suspense>` or React throws. |
| 351 | +- **`data` from `useQuery` is always resolved.** It's never undefined or null from a loading state. Suspense handles loading. |
| 352 | +- **`hydrate` without expiration creates immediately-stale data.** The first `query()` returns the hydrated value, the second triggers a refetch. |
| 353 | +- **Mutation with `expiration: () => 0` makes the value immediately stale.** Provide a non-zero expiration in `mutate` options if you want it to persist. |
| 354 | +- **`forget` does not cancel pending fetches.** Use `abort` to cancel in-flight requests. |
| 355 | +- **`stale: false` blocks until refetch completes.** Default `stale: true` returns old data while revalidating in the background. |
| 356 | +- **`subscribe('refetching')` on a key with a pending resolver fires immediately.** This is intentional for late subscribers. |
| 357 | +- **BroadcastChannel is not auto-created in vanilla usage.** `QueryProvider` handles it in React. In core, configure it manually. |
| 358 | +- **Pass stable `keys` arrays to `useQueryPrefetch` / `QueryPrefetch`.** Use `useMemo` or a module-level constant to avoid infinite re-renders. |
| 359 | +- **`useQueryInstance` throws if no query is in context or options.** Ensure `QueryProvider` is an ancestor or pass `{ query }` in options. |
0 commit comments