void-snippets-monorepo 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2261 -0
- package/package.json +18 -0
- package/packages/client/package.json +47 -0
- package/packages/client/src/configure.ts +34 -0
- package/packages/client/src/index.ts +4 -0
- package/packages/client/src/services/base-api.service.ts +26 -0
- package/packages/client/src/services/resource-api.service.ts +117 -0
- package/packages/client/src/utils/handle-api-error.ts +20 -0
- package/packages/client/tsconfig.json +13 -0
- package/packages/client/tsup.config.ts +10 -0
- package/packages/core/package.json +41 -0
- package/packages/core/src/id.ts +19 -0
- package/packages/core/src/index.ts +4 -0
- package/packages/core/src/string-to-id.ts +22 -0
- package/packages/core/src/types/index.ts +86 -0
- package/packages/core/src/utils/catch-error.ts +20 -0
- package/packages/core/tsconfig.json +13 -0
- package/packages/core/tsup.config.ts +9 -0
- package/packages/react/package.json +80 -0
- package/packages/react/src/hooks/createResourceHooks.ts +872 -0
- package/packages/react/src/hooks/useAlertMessage.ts +45 -0
- package/packages/react/src/hooks/useAsyncState.ts +110 -0
- package/packages/react/src/hooks/useCallTimer.ts +37 -0
- package/packages/react/src/hooks/useModal.ts +71 -0
- package/packages/react/src/hooks/usePagination.ts +57 -0
- package/packages/react/src/index.ts +43 -0
- package/packages/react/src/routing/createRouteContract.ts +483 -0
- package/packages/react/src/socket/createSocketHooks.ts +351 -0
- package/packages/react/tsconfig.json +14 -0
- package/packages/react/tsup.config.ts +10 -0
- package/pnpm-workspace.yaml +2 -0
- package/tsconfig.base.json +12 -0
package/README.md
ADDED
|
@@ -0,0 +1,2261 @@
|
|
|
1
|
+
# @void-snippets
|
|
2
|
+
|
|
3
|
+
> Stop rewriting the same loading states, error handlers, and URL builders. Define your data shape once β get typed API hooks, sockets, routing contracts, and UI helpers for free.
|
|
4
|
+
|
|
5
|
+
π **[Documentation & live examples β](https://void-snippets.vercel.app)**
|
|
6
|
+
|
|
7
|
+
[](https://opensource.org/licenses/MIT)
|
|
8
|
+
[](https://www.typescriptlang.org/)
|
|
9
|
+
[](https://pnpm.io/)
|
|
10
|
+
[](https://void-snippets.vercel.app)
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## What problem does this solve?
|
|
15
|
+
|
|
16
|
+
Every CRUD feature you build ends up with roughly the same boilerplate: fetch the list, track `isLoading`, handle the error, invalidate the cache after a mutation, build a URL string, parse query params... The code is not difficult, just repetitive. And every time you copy it, you risk a slightly different naming convention, a missed edge case, or a type that's `any` because it was 3am.
|
|
17
|
+
|
|
18
|
+
`@void-snippets` is that boilerplate, extracted once and made fully typed. You describe the shape of your data β one file β and the library generates the hooks, cache management, URL builders, and state machines for you.
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## Packages
|
|
23
|
+
|
|
24
|
+
| Package | Version | What's inside |
|
|
25
|
+
| ------------------------------------------------------- | ------- | ---------------------------------------------------------------------------------------------- |
|
|
26
|
+
| [`@void-snippets/core`](#package-void-snippetscore) | `0.3.0` | Branded ID types, shared interfaces, the `catchError` helper |
|
|
27
|
+
| [`@void-snippets/client`](#package-void-snippetsclient) | `0.3.0` | Generic typed HTTP service β extend once per resource, get CRUD for free |
|
|
28
|
+
| [`@void-snippets/react`](#package-void-snippetsreact) | `0.6.0` | TanStack Query hooks factory, Socket.IO hooks factory, routing contract, and everyday UI hooks |
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## Table of Contents
|
|
33
|
+
|
|
34
|
+
- [Installation](#installation)
|
|
35
|
+
- [core β Foundation Types and Utilities](#package-void-snippetscore)
|
|
36
|
+
- [VSId β Branded IDs](#vsid--branded-ids)
|
|
37
|
+
- [stringToId β Cast raw strings safely](#stringtoid)
|
|
38
|
+
- [catchError β Tuple-style error handling](#catcherror)
|
|
39
|
+
- [Shared Types](#shared-types)
|
|
40
|
+
- [Adapters β Translating API responses](#adapters)
|
|
41
|
+
- [client β HTTP Service Layer](#package-void-snippetsclient)
|
|
42
|
+
- [configure β Register your axios instance](#configure)
|
|
43
|
+
- [ResourceService β Generic CRUD base class](#resourceservice)
|
|
44
|
+
- [handleApiError β Normalised error handling](#handleapierror)
|
|
45
|
+
- [react β React Hooks and Factories](#package-void-snippetsreact)
|
|
46
|
+
- [createResourceHooks](#createresourcehooks)
|
|
47
|
+
- [useList](#uselist)
|
|
48
|
+
- [useGet](#useget)
|
|
49
|
+
- [useMutations](#usemutations)
|
|
50
|
+
- [useInfinite](#useinfinite)
|
|
51
|
+
- [Optimistic Updates](#optimistic-updates)
|
|
52
|
+
- [createSocketHooks](#createsockethooks)
|
|
53
|
+
- [useSocketEmit](#usesocketemit)
|
|
54
|
+
- [useSocketListener](#usesocketlistener)
|
|
55
|
+
- [useSocketConnection](#usesocketconnection)
|
|
56
|
+
- [createRouteContract](#createroutecontract)
|
|
57
|
+
- [defineRoute](#defineroute)
|
|
58
|
+
- [build()](#build)
|
|
59
|
+
- [useTypedSearchParams](#usetypedsearchparams)
|
|
60
|
+
- [useAlertMessage](#usealertmessage)
|
|
61
|
+
- [useAsyncState](#useasyncstate)
|
|
62
|
+
- [useCallTimer](#usecalltimer)
|
|
63
|
+
- [useModal](#usemodal)
|
|
64
|
+
- [usePagination](#usepagination)
|
|
65
|
+
- [Full Example β Contacts Feature from Scratch](#full-example)
|
|
66
|
+
- [Build and Publish](#build-and-publish)
|
|
67
|
+
- [License](#license)
|
|
68
|
+
|
|
69
|
+
---
|
|
70
|
+
|
|
71
|
+
## Installation
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
# The full React stack
|
|
75
|
+
pnpm add @void-snippets/core @void-snippets/client @void-snippets/react
|
|
76
|
+
pnpm add axios @tanstack/react-query
|
|
77
|
+
|
|
78
|
+
# Optional β only if you use socket hooks
|
|
79
|
+
pnpm add socket.io-client
|
|
80
|
+
|
|
81
|
+
# Optional β only if you use the routing contract
|
|
82
|
+
pnpm add react-router
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
**Peer dependency minimum versions:**
|
|
86
|
+
|
|
87
|
+
| Peer | Min version | Needed for |
|
|
88
|
+
| ----------------------- | ----------- | ----------------------- |
|
|
89
|
+
| `react` | `>=17.0.0` | All React hooks |
|
|
90
|
+
| `axios` | `^1.6.0` | `@void-snippets/client` |
|
|
91
|
+
| `@tanstack/react-query` | `^5.0.0` | `createResourceHooks` |
|
|
92
|
+
| `socket.io-client` | `>=4.6.0` | `createSocketHooks` |
|
|
93
|
+
| `react-router` | `>=7.0.0` | `createRouteContract` |
|
|
94
|
+
| TypeScript | `^5.4.0` | Everything |
|
|
95
|
+
|
|
96
|
+
---
|
|
97
|
+
|
|
98
|
+
## Package: `@void-snippets/core`
|
|
99
|
+
|
|
100
|
+
The foundation layer. No runtime dependencies β just types and pure utility functions that everything else builds on.
|
|
101
|
+
|
|
102
|
+
```ts
|
|
103
|
+
import {
|
|
104
|
+
stringToId,
|
|
105
|
+
catchError,
|
|
106
|
+
createDefaultAdapters,
|
|
107
|
+
} from "@void-snippets/core";
|
|
108
|
+
import type {
|
|
109
|
+
VSId,
|
|
110
|
+
VSPagination,
|
|
111
|
+
VSQueryParams,
|
|
112
|
+
VSListResult,
|
|
113
|
+
VSAdapters,
|
|
114
|
+
} from "@void-snippets/core";
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
---
|
|
118
|
+
|
|
119
|
+
### `VSId` β Branded IDs
|
|
120
|
+
|
|
121
|
+
**The problem it solves:** In TypeScript, `ContactId` and `UserId` are both just `string` under the hood. Nothing stops you from accidentally passing a user's ID where a contact's ID is expected β they're structurally identical, so TypeScript won't complain.
|
|
122
|
+
|
|
123
|
+
`VSId` fixes this by attaching an invisible compile-time "brand" to a string. Two branded IDs with different brands are incompatible, even though both are still plain strings at runtime.
|
|
124
|
+
|
|
125
|
+
**Type signature:**
|
|
126
|
+
|
|
127
|
+
```ts
|
|
128
|
+
type VSId<K, T> = K & { __brand: T };
|
|
129
|
+
// K = the underlying primitive (usually string)
|
|
130
|
+
// T = the brand tag (a string literal that names the ID)
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
**How to declare your ID types:**
|
|
134
|
+
|
|
135
|
+
```ts
|
|
136
|
+
// contacts/contacts.types.ts
|
|
137
|
+
import type { VSId } from "@void-snippets/core";
|
|
138
|
+
|
|
139
|
+
export type ContactId = VSId<string, "Contact">;
|
|
140
|
+
export type UserId = VSId<string, "User">;
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
**What this gives you:**
|
|
144
|
+
|
|
145
|
+
```ts
|
|
146
|
+
function deleteContact(id: ContactId): Promise<void> { ... }
|
|
147
|
+
|
|
148
|
+
const contactId: ContactId = '...' as ContactId;
|
|
149
|
+
const userId: UserId = '...' as UserId;
|
|
150
|
+
|
|
151
|
+
deleteContact(contactId); // β
correct
|
|
152
|
+
deleteContact(userId); // β TypeScript error β 'User' brand is not 'Contact' brand
|
|
153
|
+
deleteContact('raw-string'); // β TypeScript error β plain string is not ContactId
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Runtime behaviour is unchanged β it's still just a string. The brand only lives in the type system.
|
|
157
|
+
|
|
158
|
+
---
|
|
159
|
+
|
|
160
|
+
### `stringToId`
|
|
161
|
+
|
|
162
|
+
**The problem it solves:** You have a `VSId` type in your app, but raw strings come in from URL params, API responses, and localStorage. You need one safe, explicit place to cross that boundary.
|
|
163
|
+
|
|
164
|
+
**Signature:**
|
|
165
|
+
|
|
166
|
+
```ts
|
|
167
|
+
function stringToId<T>(id: string): T;
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
**Input:**
|
|
171
|
+
|
|
172
|
+
| Parameter | Type | Description |
|
|
173
|
+
| --------- | -------- | ------------------------------------------------------------ |
|
|
174
|
+
| `id` | `string` | Any raw string β URL param, API response field, stored value |
|
|
175
|
+
|
|
176
|
+
**Output:** `T` β the branded ID type you specify as the generic
|
|
177
|
+
|
|
178
|
+
**Example:**
|
|
179
|
+
|
|
180
|
+
```ts
|
|
181
|
+
import { stringToId } from "@void-snippets/core";
|
|
182
|
+
|
|
183
|
+
// In a React Router component, params.contactId is just `string`
|
|
184
|
+
const { contactId: rawId } = useParams();
|
|
185
|
+
|
|
186
|
+
// Cast it once at the boundary β now it's typed everywhere downstream
|
|
187
|
+
const contactId = stringToId<ContactId>(rawId);
|
|
188
|
+
|
|
189
|
+
loadContact(contactId); // β
TypeScript is satisfied
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
---
|
|
193
|
+
|
|
194
|
+
### `catchError`
|
|
195
|
+
|
|
196
|
+
**The problem it solves:** `try/catch` blocks break the linear flow of async code, especially when you need the result in the same scope as the error. You end up either nesting logic inside the `try`, or declaring a `let result` above it. Neither is clean.
|
|
197
|
+
|
|
198
|
+
`catchError` wraps any Promise in a `[error, data]` tuple β the same pattern Go uses. Success and failure are both handled in one line.
|
|
199
|
+
|
|
200
|
+
**Signature:**
|
|
201
|
+
|
|
202
|
+
```ts
|
|
203
|
+
async function catchError<T>(
|
|
204
|
+
promise: Promise<T>,
|
|
205
|
+
): Promise<[Error, null] | [null, T]>;
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
**Input:**
|
|
209
|
+
|
|
210
|
+
| Parameter | Type | Description |
|
|
211
|
+
| --------- | ------------ | ------------------------------------------------- |
|
|
212
|
+
| `promise` | `Promise<T>` | Any Promise β API call, file read, anything async |
|
|
213
|
+
|
|
214
|
+
**Output:** `Promise<[Error, null] | [null, T]>`
|
|
215
|
+
|
|
216
|
+
| Outcome | First element | Second element |
|
|
217
|
+
| ------- | ------------- | ------------------------------------------------------- |
|
|
218
|
+
| Success | `null` | `T` β fully typed, not `T \| undefined` |
|
|
219
|
+
| Failure | `Error` | `null` β non-Error rejections are coerced automatically |
|
|
220
|
+
|
|
221
|
+
**Before and after:**
|
|
222
|
+
|
|
223
|
+
```ts
|
|
224
|
+
// β Before β try/catch forces you to declare result before the block
|
|
225
|
+
async function saveContact(data: Contact.Apis.Create) {
|
|
226
|
+
let contact: Contact.Detail | undefined;
|
|
227
|
+
try {
|
|
228
|
+
contact = await ContactsApis.create(data);
|
|
229
|
+
toast.success("Created!");
|
|
230
|
+
} catch (err) {
|
|
231
|
+
toast.error(err instanceof Error ? err.message : "Unknown error");
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
navigate(`/contacts/${contact._id}`); // contact could still be undefined here for TS
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// β
After β linear, no variable leaking, TypeScript knows exactly what contact is
|
|
238
|
+
async function saveContact(data: Contact.Apis.Create) {
|
|
239
|
+
const [err, contact] = await catchError(ContactsApis.create(data));
|
|
240
|
+
if (err) {
|
|
241
|
+
toast.error(err.message);
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
toast.success("Created!");
|
|
245
|
+
navigate(`/contacts/${contact._id}`); // contact is Contact.Detail β not undefined
|
|
246
|
+
}
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
---
|
|
250
|
+
|
|
251
|
+
### Shared Types
|
|
252
|
+
|
|
253
|
+
These interfaces are the common language between all three packages.
|
|
254
|
+
|
|
255
|
+
#### `VSPagination` β pagination metadata
|
|
256
|
+
|
|
257
|
+
```ts
|
|
258
|
+
interface VSPagination {
|
|
259
|
+
page: number; // current page, 1-based
|
|
260
|
+
limit: number; // items per page
|
|
261
|
+
totalPages: number; // total number of pages available
|
|
262
|
+
totalDocuments: number; // total record count across all pages
|
|
263
|
+
}
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
#### `VSQueryParams` β list query input
|
|
267
|
+
|
|
268
|
+
```ts
|
|
269
|
+
interface VSQueryParams {
|
|
270
|
+
page?: number;
|
|
271
|
+
limit?: number;
|
|
272
|
+
[key: string]: unknown; // add any filter, sort, or search params you need
|
|
273
|
+
}
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
#### `VSListResult<T>` β normalised list response
|
|
277
|
+
|
|
278
|
+
```ts
|
|
279
|
+
interface VSListResult<T> {
|
|
280
|
+
items: T[]; // the items for the current page
|
|
281
|
+
pagination: VSPagination; // page metadata
|
|
282
|
+
}
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
This is the internal format `useList` and `useInfinite` work with after the adapter transforms your raw API response.
|
|
286
|
+
|
|
287
|
+
---
|
|
288
|
+
|
|
289
|
+
### Adapters
|
|
290
|
+
|
|
291
|
+
**The problem they solve:** Your API returns data in its own shape. The library works with a standard internal shape. Adapters are the one-time translation layer between the two.
|
|
292
|
+
|
|
293
|
+
#### What shape does the library expect by default?
|
|
294
|
+
|
|
295
|
+
```json
|
|
296
|
+
// List endpoint
|
|
297
|
+
{ "data": { "items": [...], "page": 1, "limit": 10, "totalPages": 5, "totalDocuments": 42 } }
|
|
298
|
+
|
|
299
|
+
// Single item endpoint
|
|
300
|
+
{ "data": { "_id": "...", "name": "..." } }
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
If your API matches this, you don't write any adapters at all β the defaults just work.
|
|
304
|
+
|
|
305
|
+
#### `createDefaultAdapters<TBase, TDetail>()`
|
|
306
|
+
|
|
307
|
+
Returns a pre-built adapter pair for the default response shapes.
|
|
308
|
+
|
|
309
|
+
**Signature:**
|
|
310
|
+
|
|
311
|
+
```ts
|
|
312
|
+
function createDefaultAdapters<TBase, TDetail>(): VSAdapters<
|
|
313
|
+
VSDefaultPaginatedResponse<TBase>,
|
|
314
|
+
TBase,
|
|
315
|
+
VSDefaultSingleResponse<TDetail>,
|
|
316
|
+
TDetail
|
|
317
|
+
>;
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
**Input:** No runtime arguments. Pass your two type parameters.
|
|
321
|
+
|
|
322
|
+
**Output:** `VSAdapters` β an object with `fromList` and `fromSingle` functions ready to use
|
|
323
|
+
|
|
324
|
+
#### Writing a custom adapter
|
|
325
|
+
|
|
326
|
+
If your API returns something different, implement `VSAdapters` yourself:
|
|
327
|
+
|
|
328
|
+
```ts
|
|
329
|
+
interface VSAdapters<TListRaw, TBase, TSingleRaw, TDetail> {
|
|
330
|
+
fromList: (raw: TListRaw) => VSListResult<TBase>; // map your list response β internal format
|
|
331
|
+
fromSingle: (raw: TSingleRaw) => TDetail; // map your single response β your detail type
|
|
332
|
+
}
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
```ts
|
|
336
|
+
// Example: API returns { results: [...], meta: { currentPage, perPage, lastPage, total } }
|
|
337
|
+
import type { VSAdapters, VSListResult } from "@void-snippets/core";
|
|
338
|
+
|
|
339
|
+
const contactAdapters: VSAdapters<
|
|
340
|
+
ApiListResponse,
|
|
341
|
+
Contact.Base,
|
|
342
|
+
ApiSingleResponse,
|
|
343
|
+
Contact.Detail
|
|
344
|
+
> = {
|
|
345
|
+
fromList: (raw): VSListResult<Contact.Base> => ({
|
|
346
|
+
items: raw.results,
|
|
347
|
+
pagination: {
|
|
348
|
+
page: raw.meta.currentPage,
|
|
349
|
+
limit: raw.meta.perPage,
|
|
350
|
+
totalPages: raw.meta.lastPage,
|
|
351
|
+
totalDocuments: raw.meta.total,
|
|
352
|
+
},
|
|
353
|
+
}),
|
|
354
|
+
fromSingle: (raw): Contact.Detail => raw.data,
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
// Then pass it to createResourceHooks:
|
|
358
|
+
export const contactHooks = createResourceHooks("contacts", ContactsApis, {
|
|
359
|
+
adapters: contactAdapters,
|
|
360
|
+
});
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
---
|
|
364
|
+
|
|
365
|
+
## Package: `@void-snippets/client`
|
|
366
|
+
|
|
367
|
+
The HTTP layer. Works anywhere TypeScript runs β React, Vue, Node, plain scripts.
|
|
368
|
+
|
|
369
|
+
```ts
|
|
370
|
+
import {
|
|
371
|
+
configure,
|
|
372
|
+
ResourceService,
|
|
373
|
+
handleApiError,
|
|
374
|
+
} from "@void-snippets/client";
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
---
|
|
378
|
+
|
|
379
|
+
### `configure`
|
|
380
|
+
|
|
381
|
+
**What it does:** Registers a single axios instance that all your `ResourceService` subclasses will share. Call this once β at your app's entry point β and every service you write picks it up automatically.
|
|
382
|
+
|
|
383
|
+
**Signature:**
|
|
384
|
+
|
|
385
|
+
```ts
|
|
386
|
+
function configure(instance: AxiosInstance): void;
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
**Input:**
|
|
390
|
+
|
|
391
|
+
| Parameter | Type | Description |
|
|
392
|
+
| ---------- | --------------- | --------------------------------------------------------------------------------- |
|
|
393
|
+
| `instance` | `AxiosInstance` | Your configured axios instance β base URL, headers, interceptors already attached |
|
|
394
|
+
|
|
395
|
+
**Output:** `void`
|
|
396
|
+
|
|
397
|
+
**Example:**
|
|
398
|
+
|
|
399
|
+
```ts
|
|
400
|
+
// main.ts β run this before anything else
|
|
401
|
+
import axios from "axios";
|
|
402
|
+
import { configure } from "@void-snippets/client";
|
|
403
|
+
|
|
404
|
+
const httpClient = axios.create({
|
|
405
|
+
baseURL: import.meta.env.VITE_API_BASE_URL,
|
|
406
|
+
headers: { "Content-Type": "application/json" },
|
|
407
|
+
timeout: 10_000,
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
// Attach auth token to every request
|
|
411
|
+
httpClient.interceptors.request.use((config) => {
|
|
412
|
+
const token = localStorage.getItem("access_token");
|
|
413
|
+
if (token) config.headers.Authorization = `Bearer ${token}`;
|
|
414
|
+
return config;
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
// Redirect to login on 401
|
|
418
|
+
httpClient.interceptors.response.use(
|
|
419
|
+
(res) => res,
|
|
420
|
+
(err) => {
|
|
421
|
+
if (err.response?.status === 401) window.location.href = "/auth/login";
|
|
422
|
+
return Promise.reject(err);
|
|
423
|
+
},
|
|
424
|
+
);
|
|
425
|
+
|
|
426
|
+
configure(httpClient); // β done. All services are wired up.
|
|
427
|
+
```
|
|
428
|
+
|
|
429
|
+
---
|
|
430
|
+
|
|
431
|
+
### `ResourceService`
|
|
432
|
+
|
|
433
|
+
**What it does:** A generic base class. You extend it once per API resource, pass the URL prefix in the constructor, and get five typed HTTP methods for free. No more writing the same `axios.get('/contacts')`, `axios.post('/contacts')`, etc., for every resource.
|
|
434
|
+
|
|
435
|
+
**Class signature:**
|
|
436
|
+
|
|
437
|
+
```ts
|
|
438
|
+
class ResourceService<
|
|
439
|
+
TId, // the resource's ID type (e.g. Contact.Id)
|
|
440
|
+
TBase, // shape in list responses (e.g. Contact.Base)
|
|
441
|
+
TDetail = TBase, // shape in single responses (e.g. Contact.Detail)
|
|
442
|
+
TCreate = Partial<TBase>, // create payload shape
|
|
443
|
+
TUpdate = Partial<TBase>, // update payload shape
|
|
444
|
+
TListRaw = VSDefaultPaginatedResponse<TBase>, // raw API list response
|
|
445
|
+
TSingleRaw = VSDefaultSingleResponse<TDetail> // raw API single response
|
|
446
|
+
>
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
**Methods:**
|
|
450
|
+
|
|
451
|
+
| Method | Input | Output | HTTP call |
|
|
452
|
+
| --------------------- | --------------- | --------------------- | ----------------------------------- |
|
|
453
|
+
| `list(params?)` | `VSQueryParams` | `Promise<TListRaw>` | `GET /endpoint?page=1&limit=10&...` |
|
|
454
|
+
| `get(id)` | `TId` | `Promise<TSingleRaw>` | `GET /endpoint/:id` |
|
|
455
|
+
| `create(payload)` | `TCreate` | `Promise<TSingleRaw>` | `POST /endpoint` |
|
|
456
|
+
| `update(id, payload)` | `TId, TUpdate` | `Promise<TSingleRaw>` | `PATCH /endpoint/:id` |
|
|
457
|
+
| `delete(id)` | `TId` | `Promise<TSingleRaw>` | `DELETE /endpoint/:id` |
|
|
458
|
+
|
|
459
|
+
All five methods normalise errors through `handleApiError` β so you always get a plain `Error` object with a readable `.message`, never a raw axios error.
|
|
460
|
+
|
|
461
|
+
**Example β defining a resource service:**
|
|
462
|
+
|
|
463
|
+
Start with your type definitions. Using a namespace keeps everything tidy:
|
|
464
|
+
|
|
465
|
+
```ts
|
|
466
|
+
// contacts/contacts.types.ts
|
|
467
|
+
import type { VSId } from "@void-snippets/core";
|
|
468
|
+
|
|
469
|
+
export namespace Contact {
|
|
470
|
+
export type Id = VSId<string, "Contact">;
|
|
471
|
+
|
|
472
|
+
// Shape returned in list endpoints (lean β just what the table needs)
|
|
473
|
+
export interface Base {
|
|
474
|
+
_id: Id;
|
|
475
|
+
name: string;
|
|
476
|
+
email: string;
|
|
477
|
+
phone: string;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// Shape returned in single-item endpoints (richer β full detail page)
|
|
481
|
+
export interface Detail extends Base {
|
|
482
|
+
createdBy: { _id: string; name: string };
|
|
483
|
+
notes: string;
|
|
484
|
+
createdAt: string;
|
|
485
|
+
updatedAt: string;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// Payload shapes for mutations
|
|
489
|
+
export namespace Apis {
|
|
490
|
+
export interface CreatePayload {
|
|
491
|
+
name: string;
|
|
492
|
+
email: string;
|
|
493
|
+
phone: string;
|
|
494
|
+
}
|
|
495
|
+
export interface UpdatePayload {
|
|
496
|
+
name?: string;
|
|
497
|
+
email?: string;
|
|
498
|
+
phone?: string;
|
|
499
|
+
notes?: string;
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
```
|
|
504
|
+
|
|
505
|
+
Then the service:
|
|
506
|
+
|
|
507
|
+
```ts
|
|
508
|
+
// contacts/contacts.api.ts
|
|
509
|
+
import { ResourceService } from "@void-snippets/client";
|
|
510
|
+
import type { Contact } from "./contacts.types";
|
|
511
|
+
|
|
512
|
+
export class ContactsApiService extends ResourceService<
|
|
513
|
+
Contact.Id, // TId
|
|
514
|
+
Contact.Base, // TBase β list shape
|
|
515
|
+
Contact.Detail, // TDetail β single-item shape
|
|
516
|
+
Contact.Apis.CreatePayload, // TCreate
|
|
517
|
+
Contact.Apis.UpdatePayload // TUpdate
|
|
518
|
+
> {
|
|
519
|
+
constructor() {
|
|
520
|
+
super("/contacts"); // base path β list is GET /contacts, single is GET /contacts/:id
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// Export one shared instance
|
|
525
|
+
export const ContactsApis = new ContactsApiService();
|
|
526
|
+
```
|
|
527
|
+
|
|
528
|
+
You can now call `ContactsApis.list({ page: 1 })`, `ContactsApis.get(id)`, etc., with full type safety anywhere in your app.
|
|
529
|
+
|
|
530
|
+
---
|
|
531
|
+
|
|
532
|
+
### `handleApiError`
|
|
533
|
+
|
|
534
|
+
**What it does:** Normalises any error thrown by axios into a clean, standard `Error` object. It's called automatically inside every `ResourceService` method, but you can also use it directly if you make axios calls outside of a service.
|
|
535
|
+
|
|
536
|
+
**Signature:**
|
|
537
|
+
|
|
538
|
+
```ts
|
|
539
|
+
function handleApiError(error: unknown): never;
|
|
540
|
+
```
|
|
541
|
+
|
|
542
|
+
**Input:**
|
|
543
|
+
|
|
544
|
+
| Parameter | Type | Description |
|
|
545
|
+
| --------- | --------- | ------------------------------------------------------------------ |
|
|
546
|
+
| `error` | `unknown` | Anything β an AxiosError, a plain Error, a string, anything thrown |
|
|
547
|
+
|
|
548
|
+
**Output:** `never` β this function always throws. It never returns.
|
|
549
|
+
|
|
550
|
+
**What it throws:**
|
|
551
|
+
|
|
552
|
+
| What was caught | What gets thrown |
|
|
553
|
+
| ----------------------------------------- | ----------------------------------------------------------- |
|
|
554
|
+
| `AxiosError` with `response.data.message` | `new Error(serverMessage)` β the message your API sent |
|
|
555
|
+
| `AxiosError` without a server message | `new Error(axiosError.message)` β the axios network message |
|
|
556
|
+
| A standard `Error` | Re-throws the same error unchanged |
|
|
557
|
+
| Anything else (string, object, etc.) | `new Error("An unexpected error occurred.")` |
|
|
558
|
+
|
|
559
|
+
**Example β using it directly:**
|
|
560
|
+
|
|
561
|
+
```ts
|
|
562
|
+
import axios from "axios";
|
|
563
|
+
import { handleApiError } from "@void-snippets/client";
|
|
564
|
+
|
|
565
|
+
async function uploadFile(file: File) {
|
|
566
|
+
try {
|
|
567
|
+
const res = await axios.post("/upload", formData);
|
|
568
|
+
return res.data;
|
|
569
|
+
} catch (err) {
|
|
570
|
+
handleApiError(err); // always throws a clean Error with a readable message
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
```
|
|
574
|
+
|
|
575
|
+
---
|
|
576
|
+
|
|
577
|
+
## Package: `@void-snippets/react`
|
|
578
|
+
|
|
579
|
+
Everything React-specific lives here. Three factories that generate typed hooks from your definitions, plus five standalone utility hooks.
|
|
580
|
+
|
|
581
|
+
```ts
|
|
582
|
+
import {
|
|
583
|
+
createResourceHooks,
|
|
584
|
+
createSocketHooks,
|
|
585
|
+
createRouteContract,
|
|
586
|
+
defineRoute,
|
|
587
|
+
useTypedSearchParams,
|
|
588
|
+
useAlertMessage,
|
|
589
|
+
useAsyncState,
|
|
590
|
+
useCallTimer,
|
|
591
|
+
useModal,
|
|
592
|
+
usePagination,
|
|
593
|
+
} from "@void-snippets/react";
|
|
594
|
+
```
|
|
595
|
+
|
|
596
|
+
**One-time setup** before using the resource hooks:
|
|
597
|
+
|
|
598
|
+
```ts
|
|
599
|
+
// main.tsx
|
|
600
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
601
|
+
|
|
602
|
+
const queryClient = new QueryClient({
|
|
603
|
+
defaultOptions: {
|
|
604
|
+
queries: { retry: 1, staleTime: 30_000 },
|
|
605
|
+
},
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
// Wrap your app
|
|
609
|
+
<QueryClientProvider client={queryClient}>
|
|
610
|
+
<App />
|
|
611
|
+
</QueryClientProvider>
|
|
612
|
+
```
|
|
613
|
+
|
|
614
|
+
---
|
|
615
|
+
|
|
616
|
+
### `createResourceHooks`
|
|
617
|
+
|
|
618
|
+
**What it does:** The main factory. Give it your `ResourceService` instance and a cache key prefix, and it returns four fully typed TanStack Query hooks β `useList`, `useGet`, `useMutations`, and `useInfinite`. All types flow from your service definition, so you never write a generic at the call site.
|
|
619
|
+
|
|
620
|
+
**Signature:**
|
|
621
|
+
|
|
622
|
+
```ts
|
|
623
|
+
function createResourceHooks<K extends string, S extends ResourceService>(
|
|
624
|
+
queryKeyPrefix: K,
|
|
625
|
+
apiService: S,
|
|
626
|
+
options?: VSResourceHooksOptions,
|
|
627
|
+
): { useList; useGet; useMutations; useInfinite };
|
|
628
|
+
```
|
|
629
|
+
|
|
630
|
+
**Input:**
|
|
631
|
+
|
|
632
|
+
| Argument | Type | Description |
|
|
633
|
+
| ---------------- | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
|
|
634
|
+
| `queryKeyPrefix` | `string` | The TanStack Query cache namespace for this resource. Typically the resource name in lowercase: `'contacts'`, `'users'`. |
|
|
635
|
+
| `apiService` | `ResourceService` subclass instance | Your service. Types for all four hooks are inferred from this. |
|
|
636
|
+
| `options` | `VSResourceHooksOptions` (optional) | Adapters, default pagination params, and optimistic update handlers. |
|
|
637
|
+
|
|
638
|
+
**`VSResourceHooksOptions`:**
|
|
639
|
+
|
|
640
|
+
```ts
|
|
641
|
+
interface VSResourceHooksOptions {
|
|
642
|
+
adapters?: VSAdapters; // override the default response-shape adapters
|
|
643
|
+
defaultParams?: VSQueryParams; // default page/limit used when none are passed (default: { page: 1, limit: 10 })
|
|
644
|
+
optimistic?: VSOptimisticHandlers; // optimistic update functions (covered in the Optimistic Updates section)
|
|
645
|
+
}
|
|
646
|
+
```
|
|
647
|
+
|
|
648
|
+
**Output:** `{ useList, useGet, useMutations, useInfinite }` β all described below.
|
|
649
|
+
|
|
650
|
+
**Example:**
|
|
651
|
+
|
|
652
|
+
```ts
|
|
653
|
+
// contacts/contacts.hooks.ts
|
|
654
|
+
import { createResourceHooks } from "@void-snippets/react";
|
|
655
|
+
import { ContactsApis } from "./contacts.api";
|
|
656
|
+
|
|
657
|
+
// Minimal β no options needed if your API matches the default shape
|
|
658
|
+
export const contactHooks = createResourceHooks("contacts", ContactsApis);
|
|
659
|
+
|
|
660
|
+
// With options
|
|
661
|
+
export const contactHooks = createResourceHooks("contacts", ContactsApis, {
|
|
662
|
+
defaultParams: { page: 1, limit: 25 },
|
|
663
|
+
optimistic: {
|
|
664
|
+
remove: (cache, id) => cache.filter((c) => c._id !== id),
|
|
665
|
+
// ... more handlers
|
|
666
|
+
},
|
|
667
|
+
});
|
|
668
|
+
```
|
|
669
|
+
|
|
670
|
+
---
|
|
671
|
+
|
|
672
|
+
#### `useList`
|
|
673
|
+
|
|
674
|
+
**What it does:** Fetches a paginated list for the resource. Separates three different kinds of "loading" so you can show the right UI for each one.
|
|
675
|
+
|
|
676
|
+
**Signature:**
|
|
677
|
+
|
|
678
|
+
```ts
|
|
679
|
+
contactHooks.useList(params?: VSQueryParams): VSUseListReturn<Contact.Base>
|
|
680
|
+
```
|
|
681
|
+
|
|
682
|
+
**Input:**
|
|
683
|
+
|
|
684
|
+
| Parameter | Type | Default | Description |
|
|
685
|
+
| --------- | --------------- | --------------- | ---------------------------------------------------------------------------------------- |
|
|
686
|
+
| `params` | `VSQueryParams` | `defaultParams` | Page, limit, and any extra filters your API accepts (`search`, `status`, `sortBy`, etc.) |
|
|
687
|
+
|
|
688
|
+
**Output β `VSUseListReturn<TBase>`:**
|
|
689
|
+
|
|
690
|
+
| Field | Type | Description |
|
|
691
|
+
| -------------- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
692
|
+
| `list` | `TBase[]` | The items for the current page. Always an array β never `undefined`. |
|
|
693
|
+
| `pagination` | `VSPagination` | Page metadata β `{ page, limit, totalPages, totalDocuments }` |
|
|
694
|
+
| `isLoading` | `boolean` | `true` only on the **very first fetch** when there is no cached data yet. Use this for full-page skeletons. |
|
|
695
|
+
| `isFetching` | `boolean` | `true` during **any** network request β including background refetches. Use for a subtle loading bar at the top. |
|
|
696
|
+
| `isRefetching` | `boolean` | `true` during a **background refetch** while data is already showing. Use for a small "Refreshingβ¦" badge. |
|
|
697
|
+
| `isError` | `boolean` | `true` when the most recent fetch threw an error |
|
|
698
|
+
| `error` | `Error \| null` | The error itself β check `error.message` |
|
|
699
|
+
| `refetch` | `() => Promise` | Manually fire this specific query again. Great for "Try again" buttons. |
|
|
700
|
+
| `invalidate` | `() => void` | Marks the whole resource cache as stale. TanStack Query refetches every mounted `useList` and `useGet` for this resource in the background. |
|
|
701
|
+
|
|
702
|
+
> **Why three loading states?**
|
|
703
|
+
> `isLoading` going true will blank out your whole page β only use it when there's genuinely nothing to show yet. `isRefetching` is true after a mutation invalidates the cache (data is still showing, just potentially stale). `isFetching` covers both. Using `isFetching` as a full-page spinner guard will cause your table to disappear and reappear after every create/delete β which looks broken.
|
|
704
|
+
|
|
705
|
+
**Example:**
|
|
706
|
+
|
|
707
|
+
```tsx
|
|
708
|
+
function ContactsPage() {
|
|
709
|
+
const { queryParams, onPaginationChange } = usePagination(1, 20);
|
|
710
|
+
const [search, setSearch] = useState("");
|
|
711
|
+
|
|
712
|
+
const { list, pagination, isLoading, isRefetching, isError, error, refetch } =
|
|
713
|
+
contactHooks.useList({ ...queryParams, search });
|
|
714
|
+
|
|
715
|
+
if (isLoading) return <TableSkeleton />;
|
|
716
|
+
|
|
717
|
+
if (isError)
|
|
718
|
+
return (
|
|
719
|
+
<EmptyState
|
|
720
|
+
icon={<AlertIcon />}
|
|
721
|
+
title="Could not load contacts"
|
|
722
|
+
description={error?.message}
|
|
723
|
+
action={<Button onClick={refetch}>Try again</Button>}
|
|
724
|
+
/>
|
|
725
|
+
);
|
|
726
|
+
|
|
727
|
+
return (
|
|
728
|
+
<>
|
|
729
|
+
{/* Linear progress bar β only shows on background refetch, not first load */}
|
|
730
|
+
{isRefetching && <LinearProgress />}
|
|
731
|
+
|
|
732
|
+
<SearchInput value={search} onChange={setSearch} />
|
|
733
|
+
|
|
734
|
+
{list.map((contact) => (
|
|
735
|
+
<ContactRow key={contact._id} contact={contact} />
|
|
736
|
+
))}
|
|
737
|
+
|
|
738
|
+
<Pagination
|
|
739
|
+
current={pagination.page}
|
|
740
|
+
pageSize={pagination.limit}
|
|
741
|
+
total={pagination.totalDocuments}
|
|
742
|
+
onChange={onPaginationChange}
|
|
743
|
+
/>
|
|
744
|
+
</>
|
|
745
|
+
);
|
|
746
|
+
}
|
|
747
|
+
```
|
|
748
|
+
|
|
749
|
+
---
|
|
750
|
+
|
|
751
|
+
#### `useGet`
|
|
752
|
+
|
|
753
|
+
**What it does:** Fetches a single item by its ID. Automatically skips the request when the ID isn't ready yet β no conditional hook calls, no `if (id)` guards.
|
|
754
|
+
|
|
755
|
+
**Signature:**
|
|
756
|
+
|
|
757
|
+
```ts
|
|
758
|
+
contactHooks.useGet(id: Contact.Id | undefined | null | '', staleTime?: number): VSUseGetReturn<Contact.Detail>
|
|
759
|
+
```
|
|
760
|
+
|
|
761
|
+
**Input:**
|
|
762
|
+
|
|
763
|
+
| Parameter | Type | Default | Description |
|
|
764
|
+
| ----------- | -------------------------------- | -------- | ---------------------------------------------------------------------------------------- |
|
|
765
|
+
| `id` | `TId \| undefined \| null \| ''` | required | The item's ID. Query is automatically disabled when this is falsy. |
|
|
766
|
+
| `staleTime` | `number` (ms) | `30_000` | How long the cached result is considered fresh before a background refetch is triggered. |
|
|
767
|
+
|
|
768
|
+
**Output β `VSUseGetReturn<TDetail>`:**
|
|
769
|
+
|
|
770
|
+
| Field | Type | Description |
|
|
771
|
+
| -------------- | ---------------------- | ---------------------------------------------------------------------------------------- |
|
|
772
|
+
| `item` | `TDetail \| undefined` | The fetched item. `undefined` on first load, while loading, or if the query is disabled. |
|
|
773
|
+
| `isLoading` | `boolean` | `true` on the first fetch when there's no cached data |
|
|
774
|
+
| `isFetching` | `boolean` | `true` during any network request for this item |
|
|
775
|
+
| `isRefetching` | `boolean` | `true` during a background refetch while `item` is already showing |
|
|
776
|
+
| `isError` | `boolean` | `true` when the last fetch failed |
|
|
777
|
+
| `error` | `Error \| null` | The error from the last failed fetch |
|
|
778
|
+
| `refetch` | `() => Promise` | Manually re-fetch this item |
|
|
779
|
+
|
|
780
|
+
**Example:**
|
|
781
|
+
|
|
782
|
+
```tsx
|
|
783
|
+
function ContactDetailPage() {
|
|
784
|
+
const { contactId } = useParams();
|
|
785
|
+
|
|
786
|
+
// stringToId converts the URL string to a branded ContactId
|
|
787
|
+
// When contactId is undefined (first render), the query stays disabled
|
|
788
|
+
const {
|
|
789
|
+
item: contact,
|
|
790
|
+
isLoading,
|
|
791
|
+
isError,
|
|
792
|
+
error,
|
|
793
|
+
refetch,
|
|
794
|
+
} = contactHooks.useGet(
|
|
795
|
+
contactId ? stringToId<Contact.Id>(contactId) : undefined,
|
|
796
|
+
);
|
|
797
|
+
|
|
798
|
+
if (isLoading) return <DetailSkeleton />;
|
|
799
|
+
if (isError)
|
|
800
|
+
return <ErrorBanner message={error?.message} onRetry={refetch} />;
|
|
801
|
+
if (!contact) return null;
|
|
802
|
+
|
|
803
|
+
return (
|
|
804
|
+
<div>
|
|
805
|
+
<h1>{contact.name}</h1>
|
|
806
|
+
<p>Email: {contact.email}</p>
|
|
807
|
+
<p>Phone: {contact.phone}</p>
|
|
808
|
+
<p>Created by: {contact.createdBy.name}</p>
|
|
809
|
+
<p>Notes: {contact.notes}</p>
|
|
810
|
+
</div>
|
|
811
|
+
);
|
|
812
|
+
}
|
|
813
|
+
```
|
|
814
|
+
|
|
815
|
+
---
|
|
816
|
+
|
|
817
|
+
#### `useMutations`
|
|
818
|
+
|
|
819
|
+
**What it does:** Returns three mutation objects β `create`, `update`, and `remove`. When any mutation completes (success or error), TanStack Query automatically invalidates the cache for this resource and triggers a background refetch.
|
|
820
|
+
|
|
821
|
+
**Signature:**
|
|
822
|
+
|
|
823
|
+
```ts
|
|
824
|
+
contactHooks.useMutations(): {
|
|
825
|
+
create: UseMutationResult<Contact.Detail, Error, Contact.Apis.CreatePayload>;
|
|
826
|
+
update: UseMutationResult<Contact.Detail, Error, { _id: Contact.Id; payload: Contact.Apis.UpdatePayload }>;
|
|
827
|
+
remove: UseMutationResult<Contact.Detail, Error, Contact.Id>;
|
|
828
|
+
}
|
|
829
|
+
```
|
|
830
|
+
|
|
831
|
+
> `remove` is named `remove` instead of `delete` because `delete` is a reserved keyword in JavaScript.
|
|
832
|
+
|
|
833
|
+
**Each mutation object has these fields:**
|
|
834
|
+
|
|
835
|
+
| Field | Type | Description |
|
|
836
|
+
| ------------------------ | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
|
|
837
|
+
| `mutate(variables)` | `(vars) => void` | Fire and forget. Errors are caught internally unless you pass `onError`. Good for simple actions like delete buttons. |
|
|
838
|
+
| `mutateAsync(variables)` | `(vars) => Promise<TDetail>` | Returns a Promise. Use with `await` and `try/catch` when you need sequential control β like closing a modal only after success. |
|
|
839
|
+
| `isPending` | `boolean` | `true` while the request is in flight. Use to disable and show loading on buttons. |
|
|
840
|
+
| `isSuccess` | `boolean` | `true` after the last call completed successfully |
|
|
841
|
+
| `isError` | `boolean` | `true` after the last call threw an error |
|
|
842
|
+
| `error` | `Error \| null` | The error from the last failed call |
|
|
843
|
+
| `data` | `TDetail \| undefined` | The server response from the last successful call |
|
|
844
|
+
| `reset()` | `() => void` | Resets `isSuccess`, `isError`, and `error` back to the idle state |
|
|
845
|
+
|
|
846
|
+
**The key pattern β `await` before closing a modal:**
|
|
847
|
+
|
|
848
|
+
```tsx
|
|
849
|
+
function ContactsPage() {
|
|
850
|
+
const modal = useModal<Contact.Base>();
|
|
851
|
+
const { create, update, remove } = contactHooks.useMutations();
|
|
852
|
+
|
|
853
|
+
const handleSave = async (
|
|
854
|
+
formData: Contact.Apis.CreatePayload | Contact.Apis.UpdatePayload,
|
|
855
|
+
) => {
|
|
856
|
+
try {
|
|
857
|
+
if (modal.data) {
|
|
858
|
+
// edit mode
|
|
859
|
+
await update.mutateAsync({
|
|
860
|
+
_id: modal.data._id,
|
|
861
|
+
payload: formData as Contact.Apis.UpdatePayload,
|
|
862
|
+
});
|
|
863
|
+
} else {
|
|
864
|
+
// create mode
|
|
865
|
+
await create.mutateAsync(formData as Contact.Apis.CreatePayload);
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
// Only runs if the request succeeded
|
|
869
|
+
modal.closeModal();
|
|
870
|
+
toast.success(modal.data ? "Contact updated" : "Contact created");
|
|
871
|
+
} catch (err) {
|
|
872
|
+
// Modal stays open β all form fields still there
|
|
873
|
+
// User only needs to fix the one thing that was wrong
|
|
874
|
+
toast.error(err instanceof Error ? err.message : "Something went wrong");
|
|
875
|
+
}
|
|
876
|
+
};
|
|
877
|
+
|
|
878
|
+
return (
|
|
879
|
+
<>
|
|
880
|
+
<Button onClick={modal.openCreateModal}>+ New</Button>
|
|
881
|
+
|
|
882
|
+
{list.map((contact) => (
|
|
883
|
+
<ContactRow
|
|
884
|
+
key={contact._id}
|
|
885
|
+
contact={contact}
|
|
886
|
+
onEdit={() => modal.openEditModal(contact)}
|
|
887
|
+
onDelete={() => remove.mutate(contact._id)} // fire and forget β list refreshes automatically
|
|
888
|
+
/>
|
|
889
|
+
))}
|
|
890
|
+
|
|
891
|
+
<ContactModal
|
|
892
|
+
open={modal.isOpen}
|
|
893
|
+
data={modal.data}
|
|
894
|
+
isSaving={create.isPending || update.isPending}
|
|
895
|
+
onSave={handleSave}
|
|
896
|
+
onClose={modal.closeModal}
|
|
897
|
+
/>
|
|
898
|
+
</>
|
|
899
|
+
);
|
|
900
|
+
}
|
|
901
|
+
```
|
|
902
|
+
|
|
903
|
+
---
|
|
904
|
+
|
|
905
|
+
#### `useInfinite`
|
|
906
|
+
|
|
907
|
+
**What it does:** Loads a paginated list where each new page is fetched on demand β "Load more" or infinite scroll. Internally uses TanStack Query's `useInfiniteQuery`.
|
|
908
|
+
|
|
909
|
+
**Signature:**
|
|
910
|
+
|
|
911
|
+
```ts
|
|
912
|
+
contactHooks.useInfinite(params?: VSQueryParams): UseInfiniteQueryResult<VSListResult<Contact.Base>, Error>
|
|
913
|
+
```
|
|
914
|
+
|
|
915
|
+
**Input:**
|
|
916
|
+
|
|
917
|
+
| Parameter | Type | Default | Description |
|
|
918
|
+
| --------- | --------------- | --------------- | -------------------------------------------------------------------- |
|
|
919
|
+
| `params` | `VSQueryParams` | `defaultParams` | Filters and limit. The `page` is managed internally β don't pass it. |
|
|
920
|
+
|
|
921
|
+
**Key output fields:**
|
|
922
|
+
|
|
923
|
+
| Field | Type | Description |
|
|
924
|
+
| -------------------- | ----------------------- | ----------------------------------------------------------------------- |
|
|
925
|
+
| `data.pages` | `VSListResult<TBase>[]` | Array of all fetched pages. Each has `items` and `pagination`. |
|
|
926
|
+
| `fetchNextPage()` | `() => void` | Fetches the next page. Only does anything when `hasNextPage` is `true`. |
|
|
927
|
+
| `hasNextPage` | `boolean` | `false` when you've reached the last page |
|
|
928
|
+
| `isFetchingNextPage` | `boolean` | `true` while the next page is loading |
|
|
929
|
+
| `isLoading` | `boolean` | `true` on the very first fetch |
|
|
930
|
+
| `isError` | `boolean` | `true` when a fetch failed |
|
|
931
|
+
|
|
932
|
+
**Example β "Load more" button:**
|
|
933
|
+
|
|
934
|
+
```tsx
|
|
935
|
+
function ContactsFeed() {
|
|
936
|
+
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } =
|
|
937
|
+
contactHooks.useInfinite({ limit: 15 });
|
|
938
|
+
|
|
939
|
+
// Flatten all pages into one array for rendering
|
|
940
|
+
const contacts = data?.pages.flatMap((page) => page.items) ?? [];
|
|
941
|
+
|
|
942
|
+
if (isLoading) return <Spinner />;
|
|
943
|
+
|
|
944
|
+
return (
|
|
945
|
+
<div>
|
|
946
|
+
{contacts.map((contact) => (
|
|
947
|
+
<ContactCard key={contact._id} contact={contact} />
|
|
948
|
+
))}
|
|
949
|
+
|
|
950
|
+
{hasNextPage && (
|
|
951
|
+
<Button
|
|
952
|
+
onClick={() => fetchNextPage()}
|
|
953
|
+
loading={isFetchingNextPage}
|
|
954
|
+
disabled={isFetchingNextPage}
|
|
955
|
+
>
|
|
956
|
+
{isFetchingNextPage ? "Loadingβ¦" : "Load more"}
|
|
957
|
+
</Button>
|
|
958
|
+
)}
|
|
959
|
+
|
|
960
|
+
{!hasNextPage && contacts.length > 0 && (
|
|
961
|
+
<p className="text-muted">
|
|
962
|
+
You've seen all {contacts.length} contacts.
|
|
963
|
+
</p>
|
|
964
|
+
)}
|
|
965
|
+
</div>
|
|
966
|
+
);
|
|
967
|
+
}
|
|
968
|
+
```
|
|
969
|
+
|
|
970
|
+
---
|
|
971
|
+
|
|
972
|
+
### Optimistic Updates
|
|
973
|
+
|
|
974
|
+
**What it does:** Makes create, update, and delete feel instant by applying the change to the UI cache immediately β before the API responds. If the request fails, the change is automatically rolled back. If it succeeds, the server data replaces the optimistic version.
|
|
975
|
+
|
|
976
|
+
You configure this by passing an `optimistic` object to `createResourceHooks`. Each handler is a pure function that takes the current cached list and returns a new one.
|
|
977
|
+
|
|
978
|
+
**Configuration:**
|
|
979
|
+
|
|
980
|
+
```ts
|
|
981
|
+
export const contactHooks = createResourceHooks("contacts", ContactsApis, {
|
|
982
|
+
optimistic: {
|
|
983
|
+
update: (cache, { _id, payload }) =>
|
|
984
|
+
cache.map((item) => (item._id === _id ? { ...item, ...payload } : item)),
|
|
985
|
+
|
|
986
|
+
remove: (cache, id) => cache.filter((item) => item._id !== id),
|
|
987
|
+
|
|
988
|
+
create: (cache, { payload, tempId }) => [
|
|
989
|
+
{
|
|
990
|
+
...payload,
|
|
991
|
+
_id: tempId as Contact.Id, // library-generated UUID, replaced by real ID on success
|
|
992
|
+
createdAt: new Date().toISOString(),
|
|
993
|
+
},
|
|
994
|
+
...cache,
|
|
995
|
+
],
|
|
996
|
+
|
|
997
|
+
// Called after rollback completes β cache is already restored when this fires
|
|
998
|
+
onError: (error, operation) => {
|
|
999
|
+
toast.error(`Failed to ${operation.kind}: ${error.message}`);
|
|
1000
|
+
},
|
|
1001
|
+
|
|
1002
|
+
// Called after the server confirms the operation
|
|
1003
|
+
onSuccess: (operation) => {
|
|
1004
|
+
if (operation.kind === "create") analytics.track("contact_created");
|
|
1005
|
+
},
|
|
1006
|
+
},
|
|
1007
|
+
});
|
|
1008
|
+
```
|
|
1009
|
+
|
|
1010
|
+
**Handler reference β `VSOptimisticHandlers`:**
|
|
1011
|
+
|
|
1012
|
+
| Handler | Input | Output | What it does |
|
|
1013
|
+
| ------------------------------------ | ------------------------------------------------- | --------- | ----------------------------------------------------------------------------------------------------------------------- |
|
|
1014
|
+
| `update(cache, { _id, payload })` | `TBase[]`, `{ _id: TId, payload: TUpdate }` | `TBase[]` | Apply the update to the matching item. Return a new array β never mutate in place. |
|
|
1015
|
+
| `updateSingle(current, payload)` | `TDetail`, `TUpdate` | `TDetail` | Override the default shallow-merge on the `useGet` cache. Use when `TDetail` has nested objects that need deep merging. |
|
|
1016
|
+
| `remove(cache, id)` | `TBase[]`, `TId` | `TBase[]` | Filter out the item. Pagination totals are adjusted automatically. |
|
|
1017
|
+
| `create(cache, { payload, tempId })` | `TBase[]`, `{ payload: TCreate, tempId: string }` | `TBase[]` | Insert the new item. `tempId` is a UUID β use it as `_id`. Pagination totals are adjusted automatically. |
|
|
1018
|
+
| `onError(error, operation)` | `Error`, `VSOptimisticOperation` | `void` | Notification after rollback. Cache is already correct when this fires. |
|
|
1019
|
+
| `onSuccess(operation)` | `VSOptimisticOperation` | `void` | Notification after server confirmation. |
|
|
1020
|
+
|
|
1021
|
+
**`VSOptimisticOperation` β passed to `onError` and `onSuccess`:**
|
|
1022
|
+
|
|
1023
|
+
```ts
|
|
1024
|
+
type VSOptimisticOperation<TId, TCreate, TUpdate> =
|
|
1025
|
+
| { kind: "create"; payload: TCreate; tempId: string }
|
|
1026
|
+
| { kind: "update"; _id: TId; payload: TUpdate }
|
|
1027
|
+
| { kind: "remove"; _id: TId };
|
|
1028
|
+
```
|
|
1029
|
+
|
|
1030
|
+
Check `operation.kind` to handle each mutation type differently in your callbacks.
|
|
1031
|
+
|
|
1032
|
+
**How concurrent rollback works:**
|
|
1033
|
+
|
|
1034
|
+
The library keeps an ordered stack of pending operations and a "before" snapshot of the cache. If you delete item A, rename item B, and create item C all at the same time, and B's rename fails:
|
|
1035
|
+
|
|
1036
|
+
1. Item B's rename is removed from the stack
|
|
1037
|
+
2. The cache is restored to the original "before" snapshot
|
|
1038
|
+
3. The delete for A and the create for C are replayed in order
|
|
1039
|
+
|
|
1040
|
+
None of A's or C's in-flight changes are lost.
|
|
1041
|
+
|
|
1042
|
+
---
|
|
1043
|
+
|
|
1044
|
+
### `createSocketHooks`
|
|
1045
|
+
|
|
1046
|
+
**What it does:** Generates three typed hooks β `useSocketEmit`, `useSocketListener`, and `useSocketConnection` β bound to a specific Socket.IO socket instance. Your event type definitions are passed once to the factory, so every hook call site is fully typed without generics.
|
|
1047
|
+
|
|
1048
|
+
Requires `socket.io-client β₯4.6.0`.
|
|
1049
|
+
|
|
1050
|
+
**Signature:**
|
|
1051
|
+
|
|
1052
|
+
```ts
|
|
1053
|
+
function createSocketHooks<TClientEvents, TServerEvents>(
|
|
1054
|
+
socket: Socket<TServerEvents, TClientEvents>,
|
|
1055
|
+
): { useSocketEmit; useSocketListener; useSocketConnection };
|
|
1056
|
+
```
|
|
1057
|
+
|
|
1058
|
+
**Input:**
|
|
1059
|
+
|
|
1060
|
+
| Parameter | Type | Description |
|
|
1061
|
+
| --------- | -------------------------------------- | ------------------------------------------------------------------ |
|
|
1062
|
+
| `socket` | `Socket<TServerEvents, TClientEvents>` | A socket.io-client `Socket` instance β you own it and configure it |
|
|
1063
|
+
|
|
1064
|
+
**Output:** `{ useSocketEmit, useSocketListener, useSocketConnection }`
|
|
1065
|
+
|
|
1066
|
+
**Setup β two files, done once:**
|
|
1067
|
+
|
|
1068
|
+
```ts
|
|
1069
|
+
// 1. Declare your event types (global, project-level)
|
|
1070
|
+
// socket-protocols.d.ts
|
|
1071
|
+
declare global {
|
|
1072
|
+
interface IClientToServerEvents {
|
|
1073
|
+
"join-room": (roomId: string) => void;
|
|
1074
|
+
"send-message": (msg: { text: string; roomId: string }) => void;
|
|
1075
|
+
"update-profile": (
|
|
1076
|
+
name: string,
|
|
1077
|
+
callback: (res: { status: "ok" | "error" }) => void,
|
|
1078
|
+
) => void;
|
|
1079
|
+
}
|
|
1080
|
+
interface IServerToClientEvents {
|
|
1081
|
+
"new-message": (data: {
|
|
1082
|
+
text: string;
|
|
1083
|
+
from: string;
|
|
1084
|
+
roomId: string;
|
|
1085
|
+
}) => void;
|
|
1086
|
+
"user-joined": (userId: string) => void;
|
|
1087
|
+
error: (code: number, msg: string) => void;
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
```
|
|
1091
|
+
|
|
1092
|
+
```ts
|
|
1093
|
+
// 2. Create the socket and the hooks
|
|
1094
|
+
// services/socket-hooks.ts
|
|
1095
|
+
import { createSocketHooks } from "@void-snippets/react";
|
|
1096
|
+
import { io } from "socket.io-client";
|
|
1097
|
+
|
|
1098
|
+
const socket = io(import.meta.env.VITE_SOCKET_URL, {
|
|
1099
|
+
autoConnect: false, // you call connect() explicitly β don't auto-connect on import
|
|
1100
|
+
reconnectionAttempts: 5,
|
|
1101
|
+
reconnectionDelay: 2000,
|
|
1102
|
+
});
|
|
1103
|
+
|
|
1104
|
+
export const { useSocketEmit, useSocketListener, useSocketConnection } =
|
|
1105
|
+
createSocketHooks<IClientToServerEvents, IServerToClientEvents>(socket);
|
|
1106
|
+
```
|
|
1107
|
+
|
|
1108
|
+
Import `useSocketEmit`, `useSocketListener`, and `useSocketConnection` from this file throughout your app.
|
|
1109
|
+
|
|
1110
|
+
---
|
|
1111
|
+
|
|
1112
|
+
#### `useSocketEmit`
|
|
1113
|
+
|
|
1114
|
+
**What it does:** Returns two functions for sending events to the server. Both are stable references β they don't re-create on re-renders.
|
|
1115
|
+
|
|
1116
|
+
**Signature:**
|
|
1117
|
+
|
|
1118
|
+
```ts
|
|
1119
|
+
useSocketEmit(): { emit, emitWithAck }
|
|
1120
|
+
```
|
|
1121
|
+
|
|
1122
|
+
**`emit(event, ...args): void`**
|
|
1123
|
+
|
|
1124
|
+
Sends an event without waiting for the server to respond. Throws synchronously if the socket is not connected.
|
|
1125
|
+
|
|
1126
|
+
| Input | Type | Description |
|
|
1127
|
+
| --------- | ------------------------ | ---------------------------------------------------- |
|
|
1128
|
+
| `event` | `keyof TClientEvents` | The event name β autocompleted from your interface |
|
|
1129
|
+
| `...args` | Inferred from event type | The event arguments, minus any trailing ACK callback |
|
|
1130
|
+
|
|
1131
|
+
| Output | Type |
|
|
1132
|
+
| ------------ | ------ |
|
|
1133
|
+
| Return value | `void` |
|
|
1134
|
+
|
|
1135
|
+
```ts
|
|
1136
|
+
const { emit } = useSocketEmit();
|
|
1137
|
+
|
|
1138
|
+
// TypeScript knows join-room takes one string argument
|
|
1139
|
+
emit("join-room", roomId);
|
|
1140
|
+
|
|
1141
|
+
// TypeScript knows send-message takes { text, roomId }
|
|
1142
|
+
emit("send-message", { text: "Hello everyone!", roomId });
|
|
1143
|
+
|
|
1144
|
+
// β TypeScript error β wrong argument shape
|
|
1145
|
+
emit("send-message", "just a string");
|
|
1146
|
+
```
|
|
1147
|
+
|
|
1148
|
+
**`emitWithAck(event, ...args): Promise<AckResponse>`**
|
|
1149
|
+
|
|
1150
|
+
Sends an event and waits for the server to acknowledge with a response. Returns a rejected `Promise` if the socket is not connected. TypeScript gives a compile-time error if you call this on an event that has no callback in its type signature.
|
|
1151
|
+
|
|
1152
|
+
| Input | Type | Description |
|
|
1153
|
+
| --------- | ------------------------------------------------ | ------------------------------------------------------ |
|
|
1154
|
+
| `event` | Keys of `TClientEvents` that end with a callback | Only events declared with a trailing callback function |
|
|
1155
|
+
| `...args` | Inferred from event type | The event arguments, minus the callback |
|
|
1156
|
+
|
|
1157
|
+
| Output | Type | Description |
|
|
1158
|
+
| ------------ | ---------------------- | -------------------------------------------------------------------------------- |
|
|
1159
|
+
| Return value | `Promise<AckResponse>` | Resolves with the first argument of the callback β inferred from your event type |
|
|
1160
|
+
|
|
1161
|
+
```ts
|
|
1162
|
+
const { emitWithAck } = useSocketEmit();
|
|
1163
|
+
|
|
1164
|
+
// update-profile is declared as: (name: string, callback: (res: { status: 'ok' | 'error' }) => void) => void
|
|
1165
|
+
// emitWithAck strips the callback and returns Promise<{ status: 'ok' | 'error' }>
|
|
1166
|
+
const result = await emitWithAck("update-profile", "New Name");
|
|
1167
|
+
|
|
1168
|
+
if (result.status === "ok") toast.success("Name updated!");
|
|
1169
|
+
else toast.error("Server rejected the update");
|
|
1170
|
+
|
|
1171
|
+
// β TypeScript error β join-room has no callback in its type
|
|
1172
|
+
await emitWithAck("join-room", roomId);
|
|
1173
|
+
```
|
|
1174
|
+
|
|
1175
|
+
---
|
|
1176
|
+
|
|
1177
|
+
#### `useSocketListener`
|
|
1178
|
+
|
|
1179
|
+
**What it does:** Subscribes to a server event for the lifetime of the component. The listener is added when the component mounts and removed when it unmounts β no manual cleanup. Uses a ref internally to always call the latest version of your handler, so inline arrow functions are safe.
|
|
1180
|
+
|
|
1181
|
+
**Signature:**
|
|
1182
|
+
|
|
1183
|
+
```ts
|
|
1184
|
+
useSocketListener(
|
|
1185
|
+
event: keyof TServerEvents,
|
|
1186
|
+
handler: TServerEvents[typeof event],
|
|
1187
|
+
options?: { enabled?: boolean }
|
|
1188
|
+
): void
|
|
1189
|
+
```
|
|
1190
|
+
|
|
1191
|
+
**Input:**
|
|
1192
|
+
|
|
1193
|
+
| Parameter | Type | Default | Description |
|
|
1194
|
+
| ----------------- | ---------------------- | -------- | -------------------------------------------------------------------------------------------- |
|
|
1195
|
+
| `event` | `keyof TServerEvents` | required | The event name to listen for |
|
|
1196
|
+
| `handler` | Inferred function type | required | Called every time the event fires. Always uses the latest version β no `useCallback` needed. |
|
|
1197
|
+
| `options.enabled` | `boolean` | `true` | Pass `false` to deactivate the listener without unmounting. Flip dynamically. |
|
|
1198
|
+
|
|
1199
|
+
**Output:** `void` β setup and cleanup are handled automatically.
|
|
1200
|
+
|
|
1201
|
+
**Example:**
|
|
1202
|
+
|
|
1203
|
+
```tsx
|
|
1204
|
+
function ChatRoom({ roomId }: { roomId: string }) {
|
|
1205
|
+
const [messages, setMessages] = useState<Message[]>([]);
|
|
1206
|
+
const { isConnected } = useSocketConnection();
|
|
1207
|
+
|
|
1208
|
+
// This handler is always active while the component is mounted.
|
|
1209
|
+
// The inline arrow function is fine β the ref pattern ensures it's always current.
|
|
1210
|
+
useSocketListener("new-message", (data) => {
|
|
1211
|
+
setMessages((prev) => [...prev, { text: data.text, from: data.from }]);
|
|
1212
|
+
});
|
|
1213
|
+
|
|
1214
|
+
// Only active once we're actually connected and in a room
|
|
1215
|
+
useSocketListener(
|
|
1216
|
+
"user-joined",
|
|
1217
|
+
(userId) => {
|
|
1218
|
+
toast.info(`${userId} joined the room`);
|
|
1219
|
+
},
|
|
1220
|
+
{ enabled: isConnected && !!roomId },
|
|
1221
|
+
);
|
|
1222
|
+
|
|
1223
|
+
return <MessageList messages={messages} />;
|
|
1224
|
+
}
|
|
1225
|
+
```
|
|
1226
|
+
|
|
1227
|
+
---
|
|
1228
|
+
|
|
1229
|
+
#### `useSocketConnection`
|
|
1230
|
+
|
|
1231
|
+
**What it does:** Tracks the socket's connection state reactively and exposes `connect`/`disconnect` controls. Safe to call from multiple components simultaneously.
|
|
1232
|
+
|
|
1233
|
+
**Signature:**
|
|
1234
|
+
|
|
1235
|
+
```ts
|
|
1236
|
+
useSocketConnection(): VSSocketConnectionReturn
|
|
1237
|
+
```
|
|
1238
|
+
|
|
1239
|
+
**Output β `VSSocketConnectionReturn`:**
|
|
1240
|
+
|
|
1241
|
+
| Field | Type | Description |
|
|
1242
|
+
| -------------- | --------------------- | ------------------------------------------------------------------------------ |
|
|
1243
|
+
| `isConnected` | `boolean` | `true` when the socket has an active confirmed connection |
|
|
1244
|
+
| `isConnecting` | `boolean` | `true` during the initial connection attempt or while trying to reconnect |
|
|
1245
|
+
| `socketId` | `string \| undefined` | The server-assigned socket ID. `undefined` when disconnected. |
|
|
1246
|
+
| `error` | `Error \| null` | The most recent connection error. `null` when connected or before any attempt. |
|
|
1247
|
+
| `connect()` | `() => void` | Initiates a connection. No-op if already connected. |
|
|
1248
|
+
| `disconnect()` | `() => void` | Gracefully closes the connection and stops reconnection attempts. |
|
|
1249
|
+
|
|
1250
|
+
**Events tracked internally:**
|
|
1251
|
+
|
|
1252
|
+
| Event | Source | What changes |
|
|
1253
|
+
| ------------------- | ------- | -------------------------------------------------------------------------------- |
|
|
1254
|
+
| `connect` | socket | `isConnected β true`, `isConnecting β false`, `socketId` updated, `error β null` |
|
|
1255
|
+
| `disconnect` | socket | `isConnected β false`, `isConnecting β false`, `socketId β undefined` |
|
|
1256
|
+
| `connect_error` | socket | `isConnected β false`, `isConnecting β false`, `error` set |
|
|
1257
|
+
| `reconnect_attempt` | Manager | `isConnecting β true` |
|
|
1258
|
+
| `reconnect_failed` | Manager | `isConnecting β false`, `error` set |
|
|
1259
|
+
|
|
1260
|
+
**Example:**
|
|
1261
|
+
|
|
1262
|
+
```tsx
|
|
1263
|
+
function AppShell() {
|
|
1264
|
+
const { connect, disconnect, isConnected, isConnecting, error } =
|
|
1265
|
+
useSocketConnection();
|
|
1266
|
+
|
|
1267
|
+
useEffect(() => {
|
|
1268
|
+
connect();
|
|
1269
|
+
return () => disconnect();
|
|
1270
|
+
}, []);
|
|
1271
|
+
|
|
1272
|
+
return (
|
|
1273
|
+
<>
|
|
1274
|
+
{isConnecting && <Banner color="blue">Connecting to serverβ¦</Banner>}
|
|
1275
|
+
{error && !isConnecting && (
|
|
1276
|
+
<Banner color="red">
|
|
1277
|
+
Connection failed: {error.message}
|
|
1278
|
+
<Button size="sm" onClick={connect}>
|
|
1279
|
+
Retry
|
|
1280
|
+
</Button>
|
|
1281
|
+
</Banner>
|
|
1282
|
+
)}
|
|
1283
|
+
<App />
|
|
1284
|
+
</>
|
|
1285
|
+
);
|
|
1286
|
+
}
|
|
1287
|
+
```
|
|
1288
|
+
|
|
1289
|
+
---
|
|
1290
|
+
|
|
1291
|
+
### `createRouteContract`
|
|
1292
|
+
|
|
1293
|
+
**What it does:** Converts a tree of `defineRoute()` definitions into a typed route contract. Every route gets a `build()` function that TypeScript checks at compile time β missing path params, wrong types in search params, forgotten required fields are all caught before the code runs.
|
|
1294
|
+
|
|
1295
|
+
Requires `react-router β₯7.0.0`.
|
|
1296
|
+
|
|
1297
|
+
**Signature:**
|
|
1298
|
+
|
|
1299
|
+
```ts
|
|
1300
|
+
function createRouteContract<T extends RouteTree>(tree: T): ProcessedTree<T>;
|
|
1301
|
+
```
|
|
1302
|
+
|
|
1303
|
+
**Input:** A nested object of `defineRoute()` calls.
|
|
1304
|
+
|
|
1305
|
+
**Output:** The same tree shape, with every route leaf replaced by a `ProcessedRoute` that has `path`, your metadata fields, and `build()`.
|
|
1306
|
+
|
|
1307
|
+
**Setup β one file, imported everywhere:**
|
|
1308
|
+
|
|
1309
|
+
```ts
|
|
1310
|
+
// routes.ts
|
|
1311
|
+
import { createRouteContract, defineRoute } from "@void-snippets/react";
|
|
1312
|
+
|
|
1313
|
+
export const AppRoutes = createRouteContract({
|
|
1314
|
+
auth: {
|
|
1315
|
+
login: defineRoute("/auth/login").search<{ redirect?: string }>(),
|
|
1316
|
+
register: defineRoute("/auth/register"),
|
|
1317
|
+
},
|
|
1318
|
+
dashboard: {
|
|
1319
|
+
root: defineRoute("/dashboard", {
|
|
1320
|
+
breadcrumb: "Home",
|
|
1321
|
+
title: "Dashboard",
|
|
1322
|
+
}),
|
|
1323
|
+
users: {
|
|
1324
|
+
list: defineRoute("/dashboard/users", {
|
|
1325
|
+
permissions: ["ADMIN"],
|
|
1326
|
+
breadcrumb: "Users",
|
|
1327
|
+
title: "User Management",
|
|
1328
|
+
}).search<{ page: number; sort?: "asc" | "desc"; q?: string }>(),
|
|
1329
|
+
|
|
1330
|
+
detail: defineRoute("/dashboard/users/:userId", {
|
|
1331
|
+
permissions: ["ADMIN"],
|
|
1332
|
+
breadcrumb: "User Detail",
|
|
1333
|
+
}).search<{ tab?: "profile" | "settings" | "activity" }>(),
|
|
1334
|
+
},
|
|
1335
|
+
settings: defineRoute("/dashboard/settings", {
|
|
1336
|
+
breadcrumb: "Settings",
|
|
1337
|
+
}),
|
|
1338
|
+
},
|
|
1339
|
+
});
|
|
1340
|
+
```
|
|
1341
|
+
|
|
1342
|
+
---
|
|
1343
|
+
|
|
1344
|
+
#### `defineRoute`
|
|
1345
|
+
|
|
1346
|
+
**What it does:** Defines a single route with a path and optional metadata. Chain `.search<T>()` immediately after to declare the route's URL search parameter types.
|
|
1347
|
+
|
|
1348
|
+
**Signature:**
|
|
1349
|
+
|
|
1350
|
+
```ts
|
|
1351
|
+
function defineRoute(path: string, config?: RouteMetadata): RouteDefinition;
|
|
1352
|
+
```
|
|
1353
|
+
|
|
1354
|
+
**Input:**
|
|
1355
|
+
|
|
1356
|
+
| Parameter | Type | Description |
|
|
1357
|
+
| --------- | -------------------------- | --------------------------------------------------------------------- |
|
|
1358
|
+
| `path` | `string` | The route's absolute URL path. **Must be absolute** β see note below. |
|
|
1359
|
+
| `config` | `RouteMetadata` (optional) | Metadata that flows through to the processed route. |
|
|
1360
|
+
|
|
1361
|
+
**`RouteMetadata` fields:**
|
|
1362
|
+
|
|
1363
|
+
| Field | Type | Description |
|
|
1364
|
+
| ------------- | ------------------------- | -------------------------------------------------------------------------------- |
|
|
1365
|
+
| `permissions` | `string[]` | Access control identifiers. Read via `route.handle` in your React Router config. |
|
|
1366
|
+
| `breadcrumb` | `string` | Human-readable label for breadcrumb navigation. |
|
|
1367
|
+
| `title` | `string` | Page title β great for `<head>` management. |
|
|
1368
|
+
| `meta` | `Record<string, unknown>` | Any custom metadata β loader IDs, analytics events, feature flags. |
|
|
1369
|
+
|
|
1370
|
+
**`.search<T>()` β adding typed search params:**
|
|
1371
|
+
|
|
1372
|
+
Chain immediately after `defineRoute()`. The generic type argument declares what search params this route accepts. No value is passed β it's a pure TypeScript operation.
|
|
1373
|
+
|
|
1374
|
+
```ts
|
|
1375
|
+
// No search params at all β build() takes no arguments
|
|
1376
|
+
defineRoute("/dashboard/settings");
|
|
1377
|
+
|
|
1378
|
+
// All search params optional β build() argument is optional
|
|
1379
|
+
defineRoute("/auth/login").search<{ redirect?: string }>();
|
|
1380
|
+
|
|
1381
|
+
// Mixed β page is required, others are optional
|
|
1382
|
+
defineRoute("/dashboard/users").search<{
|
|
1383
|
+
page: number;
|
|
1384
|
+
sort?: "asc" | "desc";
|
|
1385
|
+
}>();
|
|
1386
|
+
|
|
1387
|
+
// Path params + optional search β TypeScript extracts :userId automatically
|
|
1388
|
+
defineRoute("/dashboard/users/:userId").search<{
|
|
1389
|
+
tab?: "profile" | "settings";
|
|
1390
|
+
}>();
|
|
1391
|
+
|
|
1392
|
+
// Path params only, no search
|
|
1393
|
+
defineRoute("/dashboard/users/:userId/posts/:postId");
|
|
1394
|
+
```
|
|
1395
|
+
|
|
1396
|
+
> **Use absolute paths.** Write `/dashboard/users` not `` `${DASHBOARD}/${USERS}` ``. TypeScript extracts path parameter names (`:userId`) using template literal type inference, and parent+child string concatenation causes exponential type-checking work as your app grows. Absolute paths keep your IDE fast.
|
|
1397
|
+
|
|
1398
|
+
---
|
|
1399
|
+
|
|
1400
|
+
#### `build()`
|
|
1401
|
+
|
|
1402
|
+
**What it does:** Constructs the full URL string for the route. The function signature is automatically shaped by what the route has β TypeScript tells you exactly what arguments are required or optional.
|
|
1403
|
+
|
|
1404
|
+
**Four possible signatures:**
|
|
1405
|
+
|
|
1406
|
+
| Route shape | Signature | Example |
|
|
1407
|
+
| ------------------------------ | ---------------------------- | ----------------------------------------------- |
|
|
1408
|
+
| No params, no search | `build() β string` | `'/dashboard/settings'` |
|
|
1409
|
+
| No params, all-optional search | `build(options?) β string` | `'/auth/login'` or `'/auth/login?redirect=%2F'` |
|
|
1410
|
+
| Required path params | `build({ params }) β string` | `'/users/123'` |
|
|
1411
|
+
| Required search key | `build({ search }) β string` | `'/users?page=1'` |
|
|
1412
|
+
|
|
1413
|
+
**Examples:**
|
|
1414
|
+
|
|
1415
|
+
```ts
|
|
1416
|
+
// Simple β no arguments needed at all
|
|
1417
|
+
AppRoutes.dashboard.settings.build();
|
|
1418
|
+
// β '/dashboard/settings'
|
|
1419
|
+
|
|
1420
|
+
// Optional search β you can call it with or without
|
|
1421
|
+
AppRoutes.auth.login.build();
|
|
1422
|
+
// β '/auth/login'
|
|
1423
|
+
AppRoutes.auth.login.build({ search: { redirect: "/dashboard" } });
|
|
1424
|
+
// β '/auth/login?redirect=%2Fdashboard'
|
|
1425
|
+
|
|
1426
|
+
// Required path param
|
|
1427
|
+
AppRoutes.dashboard.users.detail.build({ params: { userId: "123" } });
|
|
1428
|
+
// β '/dashboard/users/123'
|
|
1429
|
+
|
|
1430
|
+
// Required path param + optional search
|
|
1431
|
+
AppRoutes.dashboard.users.detail.build({
|
|
1432
|
+
params: { userId: "123" },
|
|
1433
|
+
search: { tab: "settings" },
|
|
1434
|
+
});
|
|
1435
|
+
// β '/dashboard/users/123?tab=settings'
|
|
1436
|
+
|
|
1437
|
+
// Required search key (page is not optional)
|
|
1438
|
+
AppRoutes.dashboard.users.list.build({ search: { page: 1 } });
|
|
1439
|
+
// β '/dashboard/users?page=1'
|
|
1440
|
+
AppRoutes.dashboard.users.list.build({
|
|
1441
|
+
search: { page: 2, sort: "asc", q: "john" },
|
|
1442
|
+
});
|
|
1443
|
+
// β '/dashboard/users?page=2&sort=asc&q=john'
|
|
1444
|
+
|
|
1445
|
+
// TypeScript catches these at compile time β before the browser ever runs
|
|
1446
|
+
AppRoutes.dashboard.users.detail.build(); // β params required
|
|
1447
|
+
AppRoutes.dashboard.users.detail.build({ params: {} }); // β userId required
|
|
1448
|
+
AppRoutes.dashboard.users.list.build(); // β search.page required
|
|
1449
|
+
AppRoutes.dashboard.users.list.build({ search: { page: "1" } }); // β page must be number
|
|
1450
|
+
```
|
|
1451
|
+
|
|
1452
|
+
**Wiring up React Router β one source of truth:**
|
|
1453
|
+
|
|
1454
|
+
```ts
|
|
1455
|
+
// router.tsx
|
|
1456
|
+
import { createBrowserRouter } from 'react-router';
|
|
1457
|
+
import { AppRoutes } from './routes';
|
|
1458
|
+
|
|
1459
|
+
const router = createBrowserRouter([
|
|
1460
|
+
{
|
|
1461
|
+
path: AppRoutes.auth.login.path,
|
|
1462
|
+
element: <LoginPage />,
|
|
1463
|
+
},
|
|
1464
|
+
{
|
|
1465
|
+
path: AppRoutes.dashboard.root.path,
|
|
1466
|
+
element: <DashboardLayout />,
|
|
1467
|
+
handle: {
|
|
1468
|
+
title: AppRoutes.dashboard.root.title,
|
|
1469
|
+
breadcrumb: AppRoutes.dashboard.root.breadcrumb,
|
|
1470
|
+
},
|
|
1471
|
+
children: [
|
|
1472
|
+
{
|
|
1473
|
+
path: AppRoutes.dashboard.users.list.path,
|
|
1474
|
+
element: <UsersListPage />,
|
|
1475
|
+
handle: {
|
|
1476
|
+
permissions: AppRoutes.dashboard.users.list.permissions,
|
|
1477
|
+
breadcrumb: AppRoutes.dashboard.users.list.breadcrumb,
|
|
1478
|
+
},
|
|
1479
|
+
},
|
|
1480
|
+
{
|
|
1481
|
+
path: AppRoutes.dashboard.users.detail.path, // '/dashboard/users/:userId'
|
|
1482
|
+
element: <UserDetailPage />,
|
|
1483
|
+
handle: { permissions: AppRoutes.dashboard.users.detail.permissions },
|
|
1484
|
+
},
|
|
1485
|
+
],
|
|
1486
|
+
},
|
|
1487
|
+
]);
|
|
1488
|
+
```
|
|
1489
|
+
|
|
1490
|
+
**Navigating:**
|
|
1491
|
+
|
|
1492
|
+
```tsx
|
|
1493
|
+
// Programmatic navigation
|
|
1494
|
+
const navigate = useNavigate();
|
|
1495
|
+
navigate(
|
|
1496
|
+
AppRoutes.dashboard.users.detail.build({ params: { userId: contact._id } }),
|
|
1497
|
+
);
|
|
1498
|
+
|
|
1499
|
+
// Link component
|
|
1500
|
+
<Link
|
|
1501
|
+
to={AppRoutes.auth.login.build({ search: { redirect: location.pathname } })}
|
|
1502
|
+
>
|
|
1503
|
+
Log in
|
|
1504
|
+
</Link>;
|
|
1505
|
+
```
|
|
1506
|
+
|
|
1507
|
+
---
|
|
1508
|
+
|
|
1509
|
+
#### `useTypedSearchParams`
|
|
1510
|
+
|
|
1511
|
+
**What it does:** Reads URL search params as a typed object matching the shape you declared on the route. Gives you a type-safe setter that merges updates into the existing URL without losing other params.
|
|
1512
|
+
|
|
1513
|
+
**Signature:**
|
|
1514
|
+
|
|
1515
|
+
```ts
|
|
1516
|
+
function useTypedSearchParams<P extends string, S>(
|
|
1517
|
+
route: ProcessedRoute<P, S>,
|
|
1518
|
+
): {
|
|
1519
|
+
search: Readonly<Partial<S>>;
|
|
1520
|
+
setSearch: (update: Partial<S>) => void;
|
|
1521
|
+
clearSearch: () => void;
|
|
1522
|
+
};
|
|
1523
|
+
```
|
|
1524
|
+
|
|
1525
|
+
**Input:**
|
|
1526
|
+
|
|
1527
|
+
| Parameter | Type | Description |
|
|
1528
|
+
| --------- | ---------------------- | ----------------------------------------------------------------------------------------- |
|
|
1529
|
+
| `route` | `ProcessedRoute<P, S>` | Any route from your `AppRoutes`. TypeScript infers `S` automatically β no generic needed. |
|
|
1530
|
+
|
|
1531
|
+
**Output:**
|
|
1532
|
+
|
|
1533
|
+
| Field | Type | Description |
|
|
1534
|
+
| ------------------- | ------------------------------ | ----------------------------------------------------------------------------------------------------------------------- |
|
|
1535
|
+
| `search` | `Readonly<Partial<S>>` | Current search params as a typed object. `Partial` because the URL might not have every declared key. |
|
|
1536
|
+
| `setSearch(update)` | `(update: Partial<S>) => void` | Merges the given keys into the URL. Keys you don't mention are left unchanged. Pass `undefined` for a key to remove it. |
|
|
1537
|
+
| `clearSearch()` | `() => void` | Removes all search params from the URL. |
|
|
1538
|
+
|
|
1539
|
+
> β οΈ **All URL values are strings at runtime.** React Router's `useSearchParams` returns everything as a string. If you declare `page: number`, `search.page` will be `"1"` (a string) at runtime even though TypeScript says it's a `number`. Coerce where needed: `Number(search.page ?? 1)`. This is a deliberate trade-off β adding a runtime schema library like Zod would be a much heavier dependency for a marginal gain.
|
|
1540
|
+
|
|
1541
|
+
**Example:**
|
|
1542
|
+
|
|
1543
|
+
```tsx
|
|
1544
|
+
// This component lives at /dashboard/users
|
|
1545
|
+
// The route is: .search<{ page: number; sort?: 'asc' | 'desc'; q?: string }>()
|
|
1546
|
+
function UsersListPage() {
|
|
1547
|
+
const { queryParams, onPaginationChange, resetPagination } = usePagination(
|
|
1548
|
+
1,
|
|
1549
|
+
20,
|
|
1550
|
+
);
|
|
1551
|
+
const { search, setSearch, clearSearch } = useTypedSearchParams(
|
|
1552
|
+
AppRoutes.dashboard.users.list,
|
|
1553
|
+
);
|
|
1554
|
+
|
|
1555
|
+
// Coerce page from string β number (URL values are always strings at runtime)
|
|
1556
|
+
const page = Number(search.page ?? 1);
|
|
1557
|
+
|
|
1558
|
+
const { list, pagination, isLoading } = contactHooks.useList({
|
|
1559
|
+
page,
|
|
1560
|
+
limit: queryParams.limit,
|
|
1561
|
+
sort: search.sort,
|
|
1562
|
+
search: search.q,
|
|
1563
|
+
});
|
|
1564
|
+
|
|
1565
|
+
return (
|
|
1566
|
+
<div>
|
|
1567
|
+
<div className="toolbar">
|
|
1568
|
+
<input
|
|
1569
|
+
placeholder="Search contactsβ¦"
|
|
1570
|
+
value={search.q ?? ""}
|
|
1571
|
+
onChange={(e) => {
|
|
1572
|
+
// Update q, reset to page 1 β other params (sort) are preserved
|
|
1573
|
+
setSearch({ q: e.target.value || undefined, page: 1 });
|
|
1574
|
+
resetPagination();
|
|
1575
|
+
}}
|
|
1576
|
+
/>
|
|
1577
|
+
<select
|
|
1578
|
+
value={search.sort ?? ""}
|
|
1579
|
+
onChange={(e) =>
|
|
1580
|
+
setSearch({ sort: (e.target.value as "asc" | "desc") || undefined })
|
|
1581
|
+
}
|
|
1582
|
+
>
|
|
1583
|
+
<option value="">Default</option>
|
|
1584
|
+
<option value="asc">A β Z</option>
|
|
1585
|
+
<option value="desc">Z β A</option>
|
|
1586
|
+
</select>
|
|
1587
|
+
<Button variant="ghost" onClick={clearSearch}>
|
|
1588
|
+
Clear all filters
|
|
1589
|
+
</Button>
|
|
1590
|
+
</div>
|
|
1591
|
+
|
|
1592
|
+
{isLoading ? <Spinner /> : <ContactsTable contacts={list} />}
|
|
1593
|
+
|
|
1594
|
+
<Pagination
|
|
1595
|
+
current={pagination.page}
|
|
1596
|
+
pageSize={pagination.limit}
|
|
1597
|
+
total={pagination.totalDocuments}
|
|
1598
|
+
onChange={(page, limit) => {
|
|
1599
|
+
onPaginationChange(page, limit);
|
|
1600
|
+
// Merge page into URL β sort and q are preserved
|
|
1601
|
+
setSearch({ page });
|
|
1602
|
+
}}
|
|
1603
|
+
/>
|
|
1604
|
+
</div>
|
|
1605
|
+
);
|
|
1606
|
+
}
|
|
1607
|
+
```
|
|
1608
|
+
|
|
1609
|
+
---
|
|
1610
|
+
|
|
1611
|
+
### `useAlertMessage`
|
|
1612
|
+
|
|
1613
|
+
**What it does:** Manages the lifecycle of a single alert or notification β text, severity, and visibility. You own the UI component; this hook owns the state. The alert hides itself automatically after a configurable delay.
|
|
1614
|
+
|
|
1615
|
+
**Signature:**
|
|
1616
|
+
|
|
1617
|
+
```ts
|
|
1618
|
+
function useAlertMessage(autoHideDuration?: number): {
|
|
1619
|
+
alert: VSAlertState;
|
|
1620
|
+
showAlert: (message: ReactNode | string, type?: VSAlertVariant) => void;
|
|
1621
|
+
hideAlert: () => void;
|
|
1622
|
+
};
|
|
1623
|
+
```
|
|
1624
|
+
|
|
1625
|
+
**Input:**
|
|
1626
|
+
|
|
1627
|
+
| Parameter | Type | Default | Description |
|
|
1628
|
+
| ------------------ | ------------- | ------- | --------------------------------------------------------------------------------- |
|
|
1629
|
+
| `autoHideDuration` | `number` (ms) | `3000` | How long the alert stays visible before auto-hiding. Pass `0` to never auto-hide. |
|
|
1630
|
+
|
|
1631
|
+
**Output:**
|
|
1632
|
+
|
|
1633
|
+
| Field | Type | Description |
|
|
1634
|
+
| --------------------------- | -------------------------------- | ------------------------------------------------------------ |
|
|
1635
|
+
| `alert.message` | `ReactNode \| string` | The text or element to display in the alert |
|
|
1636
|
+
| `alert.type` | `'success' \| 'info' \| 'error'` | Severity β use to pick the alert colour |
|
|
1637
|
+
| `alert.isVisible` | `boolean` | Whether to render the alert |
|
|
1638
|
+
| `showAlert(message, type?)` | function | Show the alert. `type` defaults to `'info'` if not provided. |
|
|
1639
|
+
| `hideAlert()` | function | Immediately hide the alert without waiting for the timer. |
|
|
1640
|
+
|
|
1641
|
+
**Example:**
|
|
1642
|
+
|
|
1643
|
+
```tsx
|
|
1644
|
+
function ContactFormPage() {
|
|
1645
|
+
// 4 seconds then auto-hides
|
|
1646
|
+
const { alert, showAlert, hideAlert } = useAlertMessage(4000);
|
|
1647
|
+
|
|
1648
|
+
const handleSubmit = async (data: Contact.Apis.CreatePayload) => {
|
|
1649
|
+
const [err] = await catchError(ContactsApis.create(data));
|
|
1650
|
+
if (err) {
|
|
1651
|
+
showAlert(err.message, "error");
|
|
1652
|
+
} else {
|
|
1653
|
+
showAlert("Contact created successfully!", "success");
|
|
1654
|
+
}
|
|
1655
|
+
};
|
|
1656
|
+
|
|
1657
|
+
return (
|
|
1658
|
+
<>
|
|
1659
|
+
{alert.isVisible && (
|
|
1660
|
+
<div className={`alert alert-${alert.type}`}>
|
|
1661
|
+
{alert.message}
|
|
1662
|
+
<button onClick={hideAlert}>β</button>
|
|
1663
|
+
</div>
|
|
1664
|
+
)}
|
|
1665
|
+
<ContactForm onSubmit={handleSubmit} />
|
|
1666
|
+
</>
|
|
1667
|
+
);
|
|
1668
|
+
}
|
|
1669
|
+
```
|
|
1670
|
+
|
|
1671
|
+
---
|
|
1672
|
+
|
|
1673
|
+
### `useAsyncState`
|
|
1674
|
+
|
|
1675
|
+
**What it does:** A lightweight state machine for any async operation. Tracks four states β idle, pending, success, error β and provides an `execute` function that manages all transitions. Useful when you need to track loading/error state for something that isn't an API resource managed by TanStack Query.
|
|
1676
|
+
|
|
1677
|
+
**Signature:**
|
|
1678
|
+
|
|
1679
|
+
```ts
|
|
1680
|
+
function useAsyncState<T>(initialData?: T | null): VSUseAsyncStateReturn<T>;
|
|
1681
|
+
```
|
|
1682
|
+
|
|
1683
|
+
**Input:**
|
|
1684
|
+
|
|
1685
|
+
| Parameter | Type | Default | Description |
|
|
1686
|
+
| ------------- | ----------- | ------- | ---------------------------------------------------- |
|
|
1687
|
+
| `initialData` | `T \| null` | `null` | An optional pre-populated starting value for `data`. |
|
|
1688
|
+
|
|
1689
|
+
**Output β `VSUseAsyncStateReturn<T>`:**
|
|
1690
|
+
|
|
1691
|
+
| Field | Type | Description |
|
|
1692
|
+
| ----------------------- | --------------------------------------------- | --------------------------------------------------------------------------------- |
|
|
1693
|
+
| `data` | `T \| null` | The result of the last successful `execute` call |
|
|
1694
|
+
| `status` | `'idle' \| 'pending' \| 'success' \| 'error'` | Current state of the machine |
|
|
1695
|
+
| `error` | `Error \| null` | The error from the last failed `execute` |
|
|
1696
|
+
| `isLoading` | `boolean` | Shorthand for `status === 'pending'` |
|
|
1697
|
+
| `isSuccess` | `boolean` | Shorthand for `status === 'success'` |
|
|
1698
|
+
| `isError` | `boolean` | Shorthand for `status === 'error'` |
|
|
1699
|
+
| `execute(fn, options?)` | async function | Runs your async function, manages state transitions, returns a `catchError` tuple |
|
|
1700
|
+
| `setData(value)` | function | Manually set `data` β also sets `status` to `'success'` |
|
|
1701
|
+
| `setError(error)` | function | Manually set `error` β also sets `status` to `'error'` |
|
|
1702
|
+
| `reset()` | function | Returns everything to `{ status: 'idle', data: null, error: null }` |
|
|
1703
|
+
|
|
1704
|
+
**`execute` signature:**
|
|
1705
|
+
|
|
1706
|
+
```ts
|
|
1707
|
+
execute(
|
|
1708
|
+
asyncFn: () => Promise<T>,
|
|
1709
|
+
options?: {
|
|
1710
|
+
onSuccess?: (data: T) => void;
|
|
1711
|
+
onError?: (error: Error) => void;
|
|
1712
|
+
}
|
|
1713
|
+
): Promise<[Error, null] | [null, T]>
|
|
1714
|
+
```
|
|
1715
|
+
|
|
1716
|
+
Returns a `catchError`-style tuple so you can act on the result in the same scope.
|
|
1717
|
+
|
|
1718
|
+
**Example:**
|
|
1719
|
+
|
|
1720
|
+
```tsx
|
|
1721
|
+
function ExportPage() {
|
|
1722
|
+
const { isLoading, isSuccess, isError, error, execute, reset } =
|
|
1723
|
+
useAsyncState<{ downloadUrl: string }>();
|
|
1724
|
+
|
|
1725
|
+
const handleExport = async (format: "csv" | "xlsx") => {
|
|
1726
|
+
const [err, result] = await execute(() => ContactsApis.export({ format }), {
|
|
1727
|
+
onSuccess: (res) => toast.success("Export ready!"),
|
|
1728
|
+
onError: (err) => toast.error(err.message),
|
|
1729
|
+
});
|
|
1730
|
+
|
|
1731
|
+
// Can also act on the tuple directly
|
|
1732
|
+
if (result) {
|
|
1733
|
+
window.open(result.downloadUrl, "_blank");
|
|
1734
|
+
}
|
|
1735
|
+
};
|
|
1736
|
+
|
|
1737
|
+
return (
|
|
1738
|
+
<div>
|
|
1739
|
+
<h2>Export Contacts</h2>
|
|
1740
|
+
|
|
1741
|
+
{isError && (
|
|
1742
|
+
<ErrorBanner message={error?.message}>
|
|
1743
|
+
<Button onClick={reset}>Try again</Button>
|
|
1744
|
+
</ErrorBanner>
|
|
1745
|
+
)}
|
|
1746
|
+
|
|
1747
|
+
{isSuccess && (
|
|
1748
|
+
<SuccessBanner>
|
|
1749
|
+
Your file is ready. It will open automatically.
|
|
1750
|
+
</SuccessBanner>
|
|
1751
|
+
)}
|
|
1752
|
+
|
|
1753
|
+
<Button onClick={() => handleExport("csv")} loading={isLoading}>
|
|
1754
|
+
Export CSV
|
|
1755
|
+
</Button>
|
|
1756
|
+
<Button onClick={() => handleExport("xlsx")} loading={isLoading}>
|
|
1757
|
+
Export Excel
|
|
1758
|
+
</Button>
|
|
1759
|
+
</div>
|
|
1760
|
+
);
|
|
1761
|
+
}
|
|
1762
|
+
```
|
|
1763
|
+
|
|
1764
|
+
---
|
|
1765
|
+
|
|
1766
|
+
### `useCallTimer`
|
|
1767
|
+
|
|
1768
|
+
**What it does:** Displays how long something has been running as a live `"MM:SS"` string. Updates every second. Cleans up its interval automatically when the component unmounts β no memory leaks.
|
|
1769
|
+
|
|
1770
|
+
**Signature:**
|
|
1771
|
+
|
|
1772
|
+
```ts
|
|
1773
|
+
function useCallTimer(startedAt?: number | null): string;
|
|
1774
|
+
```
|
|
1775
|
+
|
|
1776
|
+
**Input:**
|
|
1777
|
+
|
|
1778
|
+
| Parameter | Type | Description |
|
|
1779
|
+
| ----------- | ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
1780
|
+
| `startedAt` | `number \| null \| undefined` | A Unix timestamp in milliseconds β e.g. `Date.now()` or the value stored on your call object. Pass `null` or `undefined` to display `"00:00"`. |
|
|
1781
|
+
|
|
1782
|
+
**Output:**
|
|
1783
|
+
|
|
1784
|
+
| Type | Format | Example |
|
|
1785
|
+
| -------- | --------- | --------------------------------------- |
|
|
1786
|
+
| `string` | `"MM:SS"` | `"00:00"` β `"00:01"` β ... β `"04:23"` |
|
|
1787
|
+
|
|
1788
|
+
**Example:**
|
|
1789
|
+
|
|
1790
|
+
```tsx
|
|
1791
|
+
function CallBanner({ call }: { call: ActiveCall | null }) {
|
|
1792
|
+
const elapsed = useCallTimer(call?.startedAt ?? null);
|
|
1793
|
+
|
|
1794
|
+
if (!call) return null;
|
|
1795
|
+
|
|
1796
|
+
return (
|
|
1797
|
+
<div className="fixed bottom-4 right-4 bg-green-600 text-white rounded-lg p-4 flex items-center gap-3">
|
|
1798
|
+
<PhoneIcon className="animate-pulse" />
|
|
1799
|
+
<div>
|
|
1800
|
+
<p className="font-medium">Active call β {call.contactName}</p>
|
|
1801
|
+
<p className="text-sm opacity-80">{elapsed}</p> {/* "04:23" */}
|
|
1802
|
+
</div>
|
|
1803
|
+
<Button variant="destructive" onClick={() => endCall(call.id)}>
|
|
1804
|
+
End
|
|
1805
|
+
</Button>
|
|
1806
|
+
</div>
|
|
1807
|
+
);
|
|
1808
|
+
}
|
|
1809
|
+
```
|
|
1810
|
+
|
|
1811
|
+
---
|
|
1812
|
+
|
|
1813
|
+
### `useModal`
|
|
1814
|
+
|
|
1815
|
+
**What it does:** Manages modal state for both "create" and "edit" flows from a single hook instance. Tracks whether the modal is open, what data it's editing (null in create mode, the entity in edit mode), and a loading flag for the save button.
|
|
1816
|
+
|
|
1817
|
+
**Signature:**
|
|
1818
|
+
|
|
1819
|
+
```ts
|
|
1820
|
+
function useModal<T = unknown>(): VSModalReturn<T>;
|
|
1821
|
+
```
|
|
1822
|
+
|
|
1823
|
+
**Generic `T`:** The type of the entity being edited. `data` is `T | null` β `null` means create mode, `T` means edit mode.
|
|
1824
|
+
|
|
1825
|
+
**Output β `VSModalReturn<T>`:**
|
|
1826
|
+
|
|
1827
|
+
| Field | Type | Description |
|
|
1828
|
+
| ----------------------- | ------------------------------------------- | -------------------------------------------------------- |
|
|
1829
|
+
| `isOpen` | `boolean` | Whether the modal is currently visible |
|
|
1830
|
+
| `data` | `T \| null` | `null` in create mode; the entity in edit mode |
|
|
1831
|
+
| `isLoading` | `boolean` | Loading flag β wire to your save button's `loading` prop |
|
|
1832
|
+
| `openCreateModal()` | `() => void` | Opens the modal with `data = null` |
|
|
1833
|
+
| `openEditModal(entity)` | `(entity: T) => void` | Opens the modal with `data = entity` |
|
|
1834
|
+
| `closeModal()` | `() => void` | Closes the modal and resets `data` to `null` |
|
|
1835
|
+
| `setLoading(bool)` | `(loading: boolean) => void` | Manually control the loading flag |
|
|
1836
|
+
| `setModal(open, data?)` | `(open: boolean, data?: T \| null) => void` | Low-level β set open state and data in one call |
|
|
1837
|
+
|
|
1838
|
+
**Example:**
|
|
1839
|
+
|
|
1840
|
+
```tsx
|
|
1841
|
+
function UsersPage() {
|
|
1842
|
+
const modal = useModal<User>();
|
|
1843
|
+
|
|
1844
|
+
// Distinguish create vs edit from modal.data
|
|
1845
|
+
const isEditing = modal.data !== null;
|
|
1846
|
+
|
|
1847
|
+
return (
|
|
1848
|
+
<>
|
|
1849
|
+
<Button onClick={modal.openCreateModal}>+ New User</Button>
|
|
1850
|
+
|
|
1851
|
+
<UserTable onEdit={(user) => modal.openEditModal(user)} />
|
|
1852
|
+
|
|
1853
|
+
<UserModal
|
|
1854
|
+
isOpen={modal.isOpen}
|
|
1855
|
+
title={isEditing ? `Edit ${modal.data?.name}` : "New User"}
|
|
1856
|
+
// null = empty form fields; User = pre-filled fields
|
|
1857
|
+
initialData={modal.data}
|
|
1858
|
+
isSaving={modal.isLoading}
|
|
1859
|
+
onSave={async (formData) => {
|
|
1860
|
+
modal.setLoading(true);
|
|
1861
|
+
try {
|
|
1862
|
+
if (isEditing) {
|
|
1863
|
+
await updateUser(modal.data!._id, formData);
|
|
1864
|
+
toast.success("User updated");
|
|
1865
|
+
} else {
|
|
1866
|
+
await createUser(formData);
|
|
1867
|
+
toast.success("User created");
|
|
1868
|
+
}
|
|
1869
|
+
modal.closeModal();
|
|
1870
|
+
} catch (err) {
|
|
1871
|
+
toast.error(err instanceof Error ? err.message : "Failed");
|
|
1872
|
+
} finally {
|
|
1873
|
+
modal.setLoading(false);
|
|
1874
|
+
}
|
|
1875
|
+
}}
|
|
1876
|
+
onClose={modal.closeModal}
|
|
1877
|
+
/>
|
|
1878
|
+
</>
|
|
1879
|
+
);
|
|
1880
|
+
}
|
|
1881
|
+
```
|
|
1882
|
+
|
|
1883
|
+
---
|
|
1884
|
+
|
|
1885
|
+
### `usePagination`
|
|
1886
|
+
|
|
1887
|
+
**What it does:** Manages page and limit state and produces a ready-to-use `queryParams` object for `useList`. Also handles the common pattern of resetting to page 1 when a filter changes.
|
|
1888
|
+
|
|
1889
|
+
**Signature:**
|
|
1890
|
+
|
|
1891
|
+
```ts
|
|
1892
|
+
function usePagination(
|
|
1893
|
+
initialPage?: number,
|
|
1894
|
+
initialLimit?: number,
|
|
1895
|
+
): VSPaginationReturn;
|
|
1896
|
+
```
|
|
1897
|
+
|
|
1898
|
+
**Input:**
|
|
1899
|
+
|
|
1900
|
+
| Parameter | Type | Default | Description |
|
|
1901
|
+
| -------------- | -------- | ------- | ------------------------ |
|
|
1902
|
+
| `initialPage` | `number` | `1` | The starting page number |
|
|
1903
|
+
| `initialLimit` | `number` | `10` | The starting page size |
|
|
1904
|
+
|
|
1905
|
+
**Output β `VSPaginationReturn`:**
|
|
1906
|
+
|
|
1907
|
+
| Field | Type | Description |
|
|
1908
|
+
| --------------------------------- | ----------------- | ------------------------------------------------------------------------- |
|
|
1909
|
+
| `page` | `number` | Current page number |
|
|
1910
|
+
| `limit` | `number` | Current items-per-page |
|
|
1911
|
+
| `queryParams` | `{ page, limit }` | Ready to spread into `useList()` |
|
|
1912
|
+
| `onPaginationChange(page, limit)` | function | Updates both page and limit at once β wire to your `Pagination` component |
|
|
1913
|
+
| `resetPagination()` | function | Resets to page 1. Call this whenever a filter or search term changes. |
|
|
1914
|
+
| `setPage(page)` | function | Update just the page number |
|
|
1915
|
+
| `setLimit(limit)` | function | Update just the page size |
|
|
1916
|
+
|
|
1917
|
+
**Example:**
|
|
1918
|
+
|
|
1919
|
+
```tsx
|
|
1920
|
+
function ContactsPage() {
|
|
1921
|
+
const [search, setSearch] = useState("");
|
|
1922
|
+
const [status, setStatus] = useState<"all" | "active" | "archived">("all");
|
|
1923
|
+
|
|
1924
|
+
const { queryParams, onPaginationChange, resetPagination } = usePagination(
|
|
1925
|
+
1,
|
|
1926
|
+
25,
|
|
1927
|
+
);
|
|
1928
|
+
|
|
1929
|
+
const { list, pagination, isLoading } = contactHooks.useList({
|
|
1930
|
+
...queryParams,
|
|
1931
|
+
search,
|
|
1932
|
+
status: status === "all" ? undefined : status,
|
|
1933
|
+
});
|
|
1934
|
+
|
|
1935
|
+
// Reset to page 1 when search or filter changes β otherwise you could be on
|
|
1936
|
+
// page 5 of a result set that now only has 1 page
|
|
1937
|
+
const handleSearchChange = (value: string) => {
|
|
1938
|
+
setSearch(value);
|
|
1939
|
+
resetPagination();
|
|
1940
|
+
};
|
|
1941
|
+
|
|
1942
|
+
const handleStatusChange = (value: typeof status) => {
|
|
1943
|
+
setStatus(value);
|
|
1944
|
+
resetPagination();
|
|
1945
|
+
};
|
|
1946
|
+
|
|
1947
|
+
return (
|
|
1948
|
+
<>
|
|
1949
|
+
<SearchInput value={search} onChange={handleSearchChange} />
|
|
1950
|
+
<StatusFilter value={status} onChange={handleStatusChange} />
|
|
1951
|
+
|
|
1952
|
+
<ContactsTable contacts={list} loading={isLoading} />
|
|
1953
|
+
|
|
1954
|
+
<Pagination
|
|
1955
|
+
currentPage={pagination.page}
|
|
1956
|
+
pageSize={pagination.limit}
|
|
1957
|
+
total={pagination.totalDocuments}
|
|
1958
|
+
onChange={onPaginationChange}
|
|
1959
|
+
/>
|
|
1960
|
+
</>
|
|
1961
|
+
);
|
|
1962
|
+
}
|
|
1963
|
+
```
|
|
1964
|
+
|
|
1965
|
+
---
|
|
1966
|
+
|
|
1967
|
+
## Full Example
|
|
1968
|
+
|
|
1969
|
+
A complete contacts feature, built from scratch with every layer shown.
|
|
1970
|
+
|
|
1971
|
+
**Step 1 β Types**
|
|
1972
|
+
|
|
1973
|
+
```ts
|
|
1974
|
+
// contacts/contacts.types.ts
|
|
1975
|
+
import type { VSId } from "@void-snippets/core";
|
|
1976
|
+
|
|
1977
|
+
export namespace Contact {
|
|
1978
|
+
export type Id = VSId<string, "Contact">;
|
|
1979
|
+
export interface Base {
|
|
1980
|
+
_id: Id;
|
|
1981
|
+
name: string;
|
|
1982
|
+
email: string;
|
|
1983
|
+
phone: string;
|
|
1984
|
+
}
|
|
1985
|
+
export interface Detail extends Base {
|
|
1986
|
+
createdBy: { name: string };
|
|
1987
|
+
notes: string;
|
|
1988
|
+
createdAt: string;
|
|
1989
|
+
}
|
|
1990
|
+
export namespace Apis {
|
|
1991
|
+
export interface Create {
|
|
1992
|
+
name: string;
|
|
1993
|
+
email: string;
|
|
1994
|
+
phone: string;
|
|
1995
|
+
}
|
|
1996
|
+
export interface Update {
|
|
1997
|
+
name?: string;
|
|
1998
|
+
email?: string;
|
|
1999
|
+
phone?: string;
|
|
2000
|
+
notes?: string;
|
|
2001
|
+
}
|
|
2002
|
+
}
|
|
2003
|
+
}
|
|
2004
|
+
```
|
|
2005
|
+
|
|
2006
|
+
**Step 2 β HTTP service**
|
|
2007
|
+
|
|
2008
|
+
```ts
|
|
2009
|
+
// contacts/contacts.api.ts
|
|
2010
|
+
import { ResourceService } from "@void-snippets/client";
|
|
2011
|
+
import type { Contact } from "./contacts.types";
|
|
2012
|
+
|
|
2013
|
+
export class ContactsApiService extends ResourceService<
|
|
2014
|
+
Contact.Id,
|
|
2015
|
+
Contact.Base,
|
|
2016
|
+
Contact.Detail,
|
|
2017
|
+
Contact.Apis.Create,
|
|
2018
|
+
Contact.Apis.Update
|
|
2019
|
+
> {
|
|
2020
|
+
constructor() {
|
|
2021
|
+
super("/contacts");
|
|
2022
|
+
}
|
|
2023
|
+
}
|
|
2024
|
+
|
|
2025
|
+
export const ContactsApis = new ContactsApiService();
|
|
2026
|
+
```
|
|
2027
|
+
|
|
2028
|
+
**Step 3 β React hooks**
|
|
2029
|
+
|
|
2030
|
+
```ts
|
|
2031
|
+
// contacts/contacts.hooks.ts
|
|
2032
|
+
import { createResourceHooks } from "@void-snippets/react";
|
|
2033
|
+
import { ContactsApis } from "./contacts.api";
|
|
2034
|
+
import type { Contact } from "./contacts.types";
|
|
2035
|
+
|
|
2036
|
+
export const contactHooks = createResourceHooks("contacts", ContactsApis, {
|
|
2037
|
+
optimistic: {
|
|
2038
|
+
update: (cache, { _id, payload }) =>
|
|
2039
|
+
cache.map((c) => (c._id === _id ? { ...c, ...payload } : c)),
|
|
2040
|
+
remove: (cache, id) => cache.filter((c) => c._id !== id),
|
|
2041
|
+
create: (cache, { payload, tempId }) => [
|
|
2042
|
+
{ ...payload, _id: tempId as Contact.Id },
|
|
2043
|
+
...cache,
|
|
2044
|
+
],
|
|
2045
|
+
onError: (err, op) => toast.error(`Failed to ${op.kind}: ${err.message}`),
|
|
2046
|
+
},
|
|
2047
|
+
});
|
|
2048
|
+
```
|
|
2049
|
+
|
|
2050
|
+
**Step 4 β Routes**
|
|
2051
|
+
|
|
2052
|
+
```ts
|
|
2053
|
+
// routes.ts
|
|
2054
|
+
import { createRouteContract, defineRoute } from "@void-snippets/react";
|
|
2055
|
+
|
|
2056
|
+
export const AppRoutes = createRouteContract({
|
|
2057
|
+
contacts: {
|
|
2058
|
+
list: defineRoute("/contacts", {
|
|
2059
|
+
breadcrumb: "Contacts",
|
|
2060
|
+
title: "Contact Management",
|
|
2061
|
+
}).search<{ page: number; sort?: "asc" | "desc"; q?: string }>(),
|
|
2062
|
+
|
|
2063
|
+
detail: defineRoute("/contacts/:contactId", {
|
|
2064
|
+
breadcrumb: "Contact Detail",
|
|
2065
|
+
}),
|
|
2066
|
+
},
|
|
2067
|
+
});
|
|
2068
|
+
```
|
|
2069
|
+
|
|
2070
|
+
**Step 5 β The page component**
|
|
2071
|
+
|
|
2072
|
+
```tsx
|
|
2073
|
+
// ContactsPage.tsx
|
|
2074
|
+
import { contactHooks } from "./contacts.hooks";
|
|
2075
|
+
import { AppRoutes } from "@/routes";
|
|
2076
|
+
|
|
2077
|
+
export function ContactsPage() {
|
|
2078
|
+
const navigate = useNavigate();
|
|
2079
|
+
const modal = useModal<Contact.Base>();
|
|
2080
|
+
const { alert, showAlert, hideAlert } = useAlertMessage(4000);
|
|
2081
|
+
const { queryParams, onPaginationChange, resetPagination } = usePagination(
|
|
2082
|
+
1,
|
|
2083
|
+
20,
|
|
2084
|
+
);
|
|
2085
|
+
const { search, setSearch, clearSearch } = useTypedSearchParams(
|
|
2086
|
+
AppRoutes.contacts.list,
|
|
2087
|
+
);
|
|
2088
|
+
|
|
2089
|
+
const { list, pagination, isLoading, isRefetching, isError, error, refetch } =
|
|
2090
|
+
contactHooks.useList({
|
|
2091
|
+
...queryParams,
|
|
2092
|
+
sort: search.sort,
|
|
2093
|
+
q: search.q,
|
|
2094
|
+
});
|
|
2095
|
+
|
|
2096
|
+
const { create, update, remove } = contactHooks.useMutations();
|
|
2097
|
+
|
|
2098
|
+
const handleSave = async (
|
|
2099
|
+
formData: Contact.Apis.Create | Contact.Apis.Update,
|
|
2100
|
+
) => {
|
|
2101
|
+
try {
|
|
2102
|
+
if (modal.data) {
|
|
2103
|
+
await update.mutateAsync({
|
|
2104
|
+
_id: modal.data._id,
|
|
2105
|
+
payload: formData as Contact.Apis.Update,
|
|
2106
|
+
});
|
|
2107
|
+
showAlert("Contact updated!", "success");
|
|
2108
|
+
} else {
|
|
2109
|
+
await create.mutateAsync(formData as Contact.Apis.Create);
|
|
2110
|
+
showAlert("Contact created!", "success");
|
|
2111
|
+
}
|
|
2112
|
+
modal.closeModal();
|
|
2113
|
+
} catch (err) {
|
|
2114
|
+
showAlert(
|
|
2115
|
+
err instanceof Error ? err.message : "Something went wrong",
|
|
2116
|
+
"error",
|
|
2117
|
+
);
|
|
2118
|
+
}
|
|
2119
|
+
};
|
|
2120
|
+
|
|
2121
|
+
if (isLoading) return <TableSkeleton />;
|
|
2122
|
+
if (isError) return <ErrorState message={error?.message} onRetry={refetch} />;
|
|
2123
|
+
|
|
2124
|
+
return (
|
|
2125
|
+
<>
|
|
2126
|
+
{alert.isVisible && (
|
|
2127
|
+
<Alert severity={alert.type} onClose={hideAlert}>
|
|
2128
|
+
{alert.message}
|
|
2129
|
+
</Alert>
|
|
2130
|
+
)}
|
|
2131
|
+
|
|
2132
|
+
<PageHeader title="Contacts">
|
|
2133
|
+
<Button onClick={modal.openCreateModal}>+ New Contact</Button>
|
|
2134
|
+
</PageHeader>
|
|
2135
|
+
|
|
2136
|
+
<Toolbar>
|
|
2137
|
+
<SearchInput
|
|
2138
|
+
value={search.q ?? ""}
|
|
2139
|
+
onChange={(q) => {
|
|
2140
|
+
setSearch({ q: q || undefined, page: 1 });
|
|
2141
|
+
resetPagination();
|
|
2142
|
+
}}
|
|
2143
|
+
placeholder="Search contactsβ¦"
|
|
2144
|
+
/>
|
|
2145
|
+
<SortSelect
|
|
2146
|
+
value={search.sort}
|
|
2147
|
+
onChange={(sort) => setSearch({ sort })}
|
|
2148
|
+
/>
|
|
2149
|
+
{(search.q || search.sort) && (
|
|
2150
|
+
<Button variant="ghost" size="sm" onClick={clearSearch}>
|
|
2151
|
+
Clear filters
|
|
2152
|
+
</Button>
|
|
2153
|
+
)}
|
|
2154
|
+
</Toolbar>
|
|
2155
|
+
|
|
2156
|
+
{isRefetching && <LinearProgress />}
|
|
2157
|
+
|
|
2158
|
+
<Table>
|
|
2159
|
+
<TableHead>
|
|
2160
|
+
<TableRow>
|
|
2161
|
+
<TableCell>Name</TableCell>
|
|
2162
|
+
<TableCell>Email</TableCell>
|
|
2163
|
+
<TableCell>Phone</TableCell>
|
|
2164
|
+
<TableCell>Actions</TableCell>
|
|
2165
|
+
</TableRow>
|
|
2166
|
+
</TableHead>
|
|
2167
|
+
<TableBody>
|
|
2168
|
+
{list.map((contact) => (
|
|
2169
|
+
<TableRow key={contact._id}>
|
|
2170
|
+
<TableCell>{contact.name}</TableCell>
|
|
2171
|
+
<TableCell>{contact.email}</TableCell>
|
|
2172
|
+
<TableCell>{contact.phone}</TableCell>
|
|
2173
|
+
<TableCell>
|
|
2174
|
+
<Button
|
|
2175
|
+
size="sm"
|
|
2176
|
+
variant="ghost"
|
|
2177
|
+
onClick={() =>
|
|
2178
|
+
navigate(
|
|
2179
|
+
AppRoutes.contacts.detail.build({
|
|
2180
|
+
params: { contactId: contact._id },
|
|
2181
|
+
}),
|
|
2182
|
+
)
|
|
2183
|
+
}
|
|
2184
|
+
>
|
|
2185
|
+
View
|
|
2186
|
+
</Button>
|
|
2187
|
+
<Button size="sm" onClick={() => modal.openEditModal(contact)}>
|
|
2188
|
+
Edit
|
|
2189
|
+
</Button>
|
|
2190
|
+
<Button
|
|
2191
|
+
size="sm"
|
|
2192
|
+
variant="destructive"
|
|
2193
|
+
loading={remove.isPending}
|
|
2194
|
+
onClick={() => remove.mutate(contact._id)}
|
|
2195
|
+
>
|
|
2196
|
+
Delete
|
|
2197
|
+
</Button>
|
|
2198
|
+
</TableCell>
|
|
2199
|
+
</TableRow>
|
|
2200
|
+
))}
|
|
2201
|
+
</TableBody>
|
|
2202
|
+
</Table>
|
|
2203
|
+
|
|
2204
|
+
<Pagination
|
|
2205
|
+
currentPage={pagination.page}
|
|
2206
|
+
pageSize={pagination.limit}
|
|
2207
|
+
total={pagination.totalDocuments}
|
|
2208
|
+
onChange={(page, limit) => {
|
|
2209
|
+
onPaginationChange(page, limit);
|
|
2210
|
+
setSearch({ page });
|
|
2211
|
+
}}
|
|
2212
|
+
/>
|
|
2213
|
+
|
|
2214
|
+
<ContactModal
|
|
2215
|
+
open={modal.isOpen}
|
|
2216
|
+
mode={modal.data ? "edit" : "create"}
|
|
2217
|
+
initialData={modal.data}
|
|
2218
|
+
isSaving={create.isPending || update.isPending}
|
|
2219
|
+
onSave={handleSave}
|
|
2220
|
+
onClose={modal.closeModal}
|
|
2221
|
+
/>
|
|
2222
|
+
</>
|
|
2223
|
+
);
|
|
2224
|
+
}
|
|
2225
|
+
```
|
|
2226
|
+
|
|
2227
|
+
---
|
|
2228
|
+
|
|
2229
|
+
## Build and Publish
|
|
2230
|
+
|
|
2231
|
+
```bash
|
|
2232
|
+
pnpm install # install all workspace dependencies
|
|
2233
|
+
pnpm dev # watch mode β rebuilds all packages on change
|
|
2234
|
+
pnpm build # production build for all packages
|
|
2235
|
+
pnpm build:react # build @void-snippets/react only
|
|
2236
|
+
|
|
2237
|
+
# Version and publish a single package
|
|
2238
|
+
pnpm --filter @void-snippets/react exec npm version minor
|
|
2239
|
+
pnpm --filter @void-snippets/react publish --access public --no-git-checks
|
|
2240
|
+
|
|
2241
|
+
# Publish all packages at once
|
|
2242
|
+
pnpm publish:all
|
|
2243
|
+
```
|
|
2244
|
+
|
|
2245
|
+
```
|
|
2246
|
+
void-snippets/
|
|
2247
|
+
βββ packages/
|
|
2248
|
+
β βββ core/src/
|
|
2249
|
+
β βββ client/src/
|
|
2250
|
+
β βββ react/src/
|
|
2251
|
+
β βββ hooks/ β createResourceHooks, useAlertMessage, useAsyncState, useCallTimer, useModal, usePagination
|
|
2252
|
+
β βββ socket/ β createSocketHooks
|
|
2253
|
+
β βββ routing/ β createRouteContract, defineRoute, useTypedSearchParams
|
|
2254
|
+
βββ pnpm-workspace.yaml
|
|
2255
|
+
```
|
|
2256
|
+
|
|
2257
|
+
---
|
|
2258
|
+
|
|
2259
|
+
## License
|
|
2260
|
+
|
|
2261
|
+
MIT Β© [shahtirthhh](https://github.com/shahtirthhh/void-snippets) Β· [Documentation](https://void-snippets.vercel.app)
|