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.
Files changed (32) hide show
  1. package/README.md +2261 -0
  2. package/package.json +18 -0
  3. package/packages/client/package.json +47 -0
  4. package/packages/client/src/configure.ts +34 -0
  5. package/packages/client/src/index.ts +4 -0
  6. package/packages/client/src/services/base-api.service.ts +26 -0
  7. package/packages/client/src/services/resource-api.service.ts +117 -0
  8. package/packages/client/src/utils/handle-api-error.ts +20 -0
  9. package/packages/client/tsconfig.json +13 -0
  10. package/packages/client/tsup.config.ts +10 -0
  11. package/packages/core/package.json +41 -0
  12. package/packages/core/src/id.ts +19 -0
  13. package/packages/core/src/index.ts +4 -0
  14. package/packages/core/src/string-to-id.ts +22 -0
  15. package/packages/core/src/types/index.ts +86 -0
  16. package/packages/core/src/utils/catch-error.ts +20 -0
  17. package/packages/core/tsconfig.json +13 -0
  18. package/packages/core/tsup.config.ts +9 -0
  19. package/packages/react/package.json +80 -0
  20. package/packages/react/src/hooks/createResourceHooks.ts +872 -0
  21. package/packages/react/src/hooks/useAlertMessage.ts +45 -0
  22. package/packages/react/src/hooks/useAsyncState.ts +110 -0
  23. package/packages/react/src/hooks/useCallTimer.ts +37 -0
  24. package/packages/react/src/hooks/useModal.ts +71 -0
  25. package/packages/react/src/hooks/usePagination.ts +57 -0
  26. package/packages/react/src/index.ts +43 -0
  27. package/packages/react/src/routing/createRouteContract.ts +483 -0
  28. package/packages/react/src/socket/createSocketHooks.ts +351 -0
  29. package/packages/react/tsconfig.json +14 -0
  30. package/packages/react/tsup.config.ts +10 -0
  31. package/pnpm-workspace.yaml +2 -0
  32. 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
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
8
+ [![TypeScript](https://img.shields.io/badge/TypeScript-5.4%2B-blue)](https://www.typescriptlang.org/)
9
+ [![pnpm](https://img.shields.io/badge/pnpm-workspace-orange)](https://pnpm.io/)
10
+ [![Documentation](https://img.shields.io/badge/docs-void--snippets.vercel.app-blue)](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)