@sirmekus/kwado 1.0.0
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 +575 -0
- package/package.json +56 -0
package/README.md
ADDED
|
@@ -0,0 +1,575 @@
|
|
|
1
|
+
# Kwado
|
|
2
|
+
|
|
3
|
+
A React hook that unifies **Zod** schema validation, **react-hook-form** state management, and **Oku** HTTP submission into one concise, declarative API - with an extensible response-handler pipeline that keeps app-specific logic (toasts, error mappers, redirects) fully under your control.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Why does this exist?
|
|
8
|
+
|
|
9
|
+
Zod and react-hook-form are individually excellent libraries, but combining them for a typical form-with-submission always produces the same boilerplate: wire the resolver, write a submit handler, call `handleSubmit`, check the response status, branch into success/error paths. Do this across dozens of forms in a project and you have a maintenance problem - inconsistent patterns, duplicated branching, and scattered HTTP logic.
|
|
10
|
+
|
|
11
|
+
`kwado` eliminates that by treating the full lifecycle - **validation → submission → response routing** — as a single, composable unit.
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## The problem in plain code
|
|
16
|
+
|
|
17
|
+
Here is a login form written with the three libraries used separately:
|
|
18
|
+
|
|
19
|
+
```tsx
|
|
20
|
+
import { useForm } from 'react-hook-form';
|
|
21
|
+
import { zodResolver } from '@hookform/resolvers/zod';
|
|
22
|
+
import { z } from 'zod';
|
|
23
|
+
import { post } from '@sirmekus/oku';
|
|
24
|
+
|
|
25
|
+
// 1. Schema defined once …
|
|
26
|
+
const loginSchema = z.object({
|
|
27
|
+
email: z.string().email(),
|
|
28
|
+
password: z.string().min(8),
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
function LoginForm() {
|
|
32
|
+
// 2. … but the resolver must be wired manually on every form
|
|
33
|
+
const form = useForm<z.infer<typeof loginSchema>>({
|
|
34
|
+
resolver: zodResolver(loginSchema),
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const { setValue, setError, reset } = form;
|
|
38
|
+
|
|
39
|
+
// 3. Submit handler written by hand every time
|
|
40
|
+
const handleSubmit = async (data: z.infer<typeof loginSchema>) => {
|
|
41
|
+
let response;
|
|
42
|
+
try {
|
|
43
|
+
response = await post({ url: '/api/auth/login', data, method: 'POST' });
|
|
44
|
+
} catch (err) {
|
|
45
|
+
// oku rejects on non-2xx — have to catch and re-shape
|
|
46
|
+
response = err;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// 4. Status branching duplicated across every form
|
|
50
|
+
if (response.status === 'success') {
|
|
51
|
+
// For instance,
|
|
52
|
+
toast.success(response.data.message);
|
|
53
|
+
router.push('/dashboard');
|
|
54
|
+
} else {
|
|
55
|
+
if (response.statusCode === 422) {
|
|
56
|
+
doSomething(response.data, setError);
|
|
57
|
+
} else {
|
|
58
|
+
toast.error(response.data?.message ?? 'Login failed');
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
// 5. handleSubmit wrapper required every time
|
|
64
|
+
return (
|
|
65
|
+
<form onSubmit={form.handleSubmit(handleSubmit)}>
|
|
66
|
+
<input {...form.register('email')} />
|
|
67
|
+
<input {...form.register('password')} type="password" />
|
|
68
|
+
<button disabled={form.formState.isSubmitting}>Log in</button>
|
|
69
|
+
</form>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
That is **five distinct wiring steps** repeated on every form. The branching logic is particularly painful — it is ad-hoc, hard to reuse, and easily diverges between forms.
|
|
75
|
+
|
|
76
|
+
---
|
|
77
|
+
|
|
78
|
+
## The same form with `kwado`
|
|
79
|
+
|
|
80
|
+
```tsx
|
|
81
|
+
import { useZodForm, whenStatusCode, whenSuccess, whenError } from '@sirmekus/kwado';
|
|
82
|
+
import { z } from 'zod';
|
|
83
|
+
|
|
84
|
+
const loginSchema = z.object({
|
|
85
|
+
email: z.string().email(),
|
|
86
|
+
password: z.string().min(8),
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
function LoginForm() {
|
|
90
|
+
const { register, onSubmit, formState: { errors, isSubmitting } } = useZodForm(
|
|
91
|
+
loginSchema,
|
|
92
|
+
{
|
|
93
|
+
endpoint: '/api/auth/login',
|
|
94
|
+
responseHandlers: [
|
|
95
|
+
whenStatusCode(422, (res, { setError }) => applyLaravelErrors(res.data, setError)),
|
|
96
|
+
whenSuccess((res) => { toast.success(res.data.message); router.push('/dashboard'); }),
|
|
97
|
+
whenError((res) => toast.error(res.data?.message ?? 'Login failed')),
|
|
98
|
+
],
|
|
99
|
+
},
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
return (
|
|
103
|
+
<form onSubmit={onSubmit}>
|
|
104
|
+
<input {...register('email')} />
|
|
105
|
+
<input {...register('password')} type="password" />
|
|
106
|
+
<button disabled={isSubmitting}>Log in</button>
|
|
107
|
+
</form>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
**What disappeared:**
|
|
113
|
+
|
|
114
|
+
| Eliminated boilerplate | How |
|
|
115
|
+
|---|---|
|
|
116
|
+
| `resolver: zodResolver(schema)` | Wired automatically from the schema argument |
|
|
117
|
+
| `form.handleSubmit(handler)` | `onSubmit` is returned pre-bound |
|
|
118
|
+
| `try/catch` around oku | Normalised internally; both HTTP errors and network failures reach the pipeline |
|
|
119
|
+
| Manual `if/else` on `response.status` | Replaced by the `responseHandlers` pipeline |
|
|
120
|
+
| Closing over `setValue`, `setError`, `reset` | Available as `helpers` in every callback |
|
|
121
|
+
|
|
122
|
+
The schema is the single source of truth. TypeScript infers the field types from it automatically — no `z.infer<typeof schema>` needed at the call site.
|
|
123
|
+
|
|
124
|
+
---
|
|
125
|
+
|
|
126
|
+
## Installation
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
npm install @sirmekus/kwado
|
|
130
|
+
# or
|
|
131
|
+
pnpm add @sirmekus/kwado
|
|
132
|
+
# or
|
|
133
|
+
bun add @sirmekus/kwado
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Install peer dependencies if not already present:
|
|
137
|
+
|
|
138
|
+
```bash
|
|
139
|
+
npm install zod react-hook-form @hookform/resolvers @sirmekus/oku
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
---
|
|
143
|
+
|
|
144
|
+
## Quick start
|
|
145
|
+
|
|
146
|
+
```tsx
|
|
147
|
+
import { useZodForm, whenSuccess, whenError } from '@sirmekus/kwado';
|
|
148
|
+
import { z } from 'zod';
|
|
149
|
+
|
|
150
|
+
const contactSchema = z.object({
|
|
151
|
+
name: z.string().min(1, 'Name is required'),
|
|
152
|
+
email: z.string().email(),
|
|
153
|
+
message: z.string().min(10),
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
function ContactForm() {
|
|
157
|
+
const { register, onSubmit, formState: { errors, isSubmitting } } = useZodForm(
|
|
158
|
+
contactSchema,
|
|
159
|
+
{
|
|
160
|
+
endpoint: '/api/contact',
|
|
161
|
+
responseHandlers: [
|
|
162
|
+
whenSuccess(() => alert('Message sent!')),
|
|
163
|
+
whenError((res) => alert(res.data?.message)),
|
|
164
|
+
],
|
|
165
|
+
},
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
return (
|
|
169
|
+
<form onSubmit={onSubmit}>
|
|
170
|
+
<input {...register('name')} />
|
|
171
|
+
{errors.name && <p>{errors.name.message}</p>}
|
|
172
|
+
|
|
173
|
+
<input {...register('email')} />
|
|
174
|
+
{errors.email && <p>{errors.email.message}</p>}
|
|
175
|
+
|
|
176
|
+
<textarea {...register('message')} />
|
|
177
|
+
{errors.message && <p>{errors.message.message}</p>}
|
|
178
|
+
|
|
179
|
+
<button type="submit" disabled={isSubmitting}>
|
|
180
|
+
{isSubmitting ? 'Sending…' : 'Send'}
|
|
181
|
+
</button>
|
|
182
|
+
</form>
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
---
|
|
188
|
+
|
|
189
|
+
## Core concept: the response-handler pipeline
|
|
190
|
+
|
|
191
|
+
Instead of writing `if/else` branches inside a submit handler, you declare an ordered list of handlers. Each one has two parts:
|
|
192
|
+
|
|
193
|
+
- **`detect`** — a predicate that inspects the response and returns `true` if this handler owns it.
|
|
194
|
+
- **`handle`** — an async-capable function that reacts to the response.
|
|
195
|
+
|
|
196
|
+
The pipeline walks the list in order. The **first handler whose `detect` returns `true`** is executed and the rest — including the fallback `onSuccess` / `onError` — are skipped. This makes the response logic explicit, isolated, and easy to reorder or extract into reusable modules.
|
|
197
|
+
|
|
198
|
+
```
|
|
199
|
+
HTTP response
|
|
200
|
+
│
|
|
201
|
+
▼
|
|
202
|
+
┌─────────────┐ detect → false
|
|
203
|
+
│ handler[0] │──────────────────► skip
|
|
204
|
+
└─────────────┘
|
|
205
|
+
│ detect → true
|
|
206
|
+
▼
|
|
207
|
+
handle() ◄── setError, reset, redirect, toast, anything
|
|
208
|
+
│
|
|
209
|
+
end (handlers[1..n] and onSuccess/onError are not called)
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
---
|
|
213
|
+
|
|
214
|
+
## Handler factory functions
|
|
215
|
+
|
|
216
|
+
All factories are exported from the package and accept an optional async `handle` function that receives the response object and a `helpers` object.
|
|
217
|
+
|
|
218
|
+
### `whenStatusCode(code, handle)`
|
|
219
|
+
|
|
220
|
+
Fires when `response.statusCode` equals `code`.
|
|
221
|
+
|
|
222
|
+
```ts
|
|
223
|
+
// Map 422 validation errors back onto form fields (Laravel, Django, etc.)
|
|
224
|
+
whenStatusCode(422, (res, { setError }) => applyMyServerErrors(res.data, setError))
|
|
225
|
+
|
|
226
|
+
// Redirect on session expiry
|
|
227
|
+
whenStatusCode(401, () => router.push('/login'))
|
|
228
|
+
|
|
229
|
+
// Show a specific message for conflict errors
|
|
230
|
+
whenStatusCode(409, (res) => toast.warning(res.data.message))
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
### `whenStatusRange(min, max, handle)`
|
|
234
|
+
|
|
235
|
+
Fires when `min ≤ response.statusCode ≤ max`. Useful for class-level handling.
|
|
236
|
+
|
|
237
|
+
```ts
|
|
238
|
+
// Report all 5xx errors to your monitoring service
|
|
239
|
+
whenStatusRange(500, 599, (res) => Sentry.captureMessage(res.data?.message))
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
### `whenSuccess(handle)`
|
|
243
|
+
|
|
244
|
+
Fires when `response.status === 'success'`.
|
|
245
|
+
|
|
246
|
+
```ts
|
|
247
|
+
whenSuccess((res) => {
|
|
248
|
+
toast.success(res.data.message ?? 'Done!');
|
|
249
|
+
router.push('/dashboard');
|
|
250
|
+
})
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
### `whenError(handle)`
|
|
254
|
+
|
|
255
|
+
Fires when `response.status === 'error'`.
|
|
256
|
+
|
|
257
|
+
```ts
|
|
258
|
+
whenError((res) => toast.error(res.data?.message ?? 'Something went wrong'))
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
### `whenResponse(detect, handle)`
|
|
262
|
+
|
|
263
|
+
Fully custom predicate. Use this when the other factories cannot express what you need — for example, matching on response body content.
|
|
264
|
+
|
|
265
|
+
```ts
|
|
266
|
+
// Handle a soft "action required" response that arrives as a 200
|
|
267
|
+
whenResponse(
|
|
268
|
+
(res) => res.data?.action === 'TWO_FACTOR_REQUIRED',
|
|
269
|
+
() => router.push('/auth/2fa'),
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
// Handle business-logic flags in the response body
|
|
273
|
+
whenResponse(
|
|
274
|
+
(res) => res.data?.requiresEmailVerification === true,
|
|
275
|
+
(res, { reset }) => { reset(); router.push('/verify-email'); },
|
|
276
|
+
)
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
### `always(handle)`
|
|
280
|
+
|
|
281
|
+
Unconditional catch-all. Always fires. Place it **last** in the list.
|
|
282
|
+
|
|
283
|
+
```ts
|
|
284
|
+
responseHandlers: [
|
|
285
|
+
whenStatusCode(422, applyErrors),
|
|
286
|
+
whenSuccess(redirectToDashboard),
|
|
287
|
+
always((_, { reset }) => reset()), // clean up the form no matter what
|
|
288
|
+
]
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
---
|
|
292
|
+
|
|
293
|
+
## `useZodForm` API reference
|
|
294
|
+
|
|
295
|
+
```ts
|
|
296
|
+
function useZodForm<T extends ZodRawShape>(
|
|
297
|
+
schema: ZodObject<T>,
|
|
298
|
+
options: UseZodFormOptions<z.infer<ZodObject<T>>>,
|
|
299
|
+
)
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
### Options
|
|
303
|
+
|
|
304
|
+
| Option | Type | Description |
|
|
305
|
+
|---|---|---|
|
|
306
|
+
| `endpoint` | `string` | URL for the built-in oku HTTP call. Ignored when `submit` is provided. |
|
|
307
|
+
| `method` | `'GET' \| 'POST' \| 'PUT' \| 'PATCH' \| 'DELETE'` | HTTP method. Defaults to `'POST'`. Ignored when `submit` is provided. |
|
|
308
|
+
| `defaultValues` | `Partial<TForm>` | Initial field values passed to react-hook-form. |
|
|
309
|
+
| `transform` | `(data: TForm) => unknown` | Reshape the validated payload before it is sent. |
|
|
310
|
+
| `responseHandlers` | `ResponseHandler[]` | Ordered response-handler pipeline (see above). |
|
|
311
|
+
| `onSuccess` | `(res, helpers) => void` | Fallback called on success when no handler matched. |
|
|
312
|
+
| `onError` | `(err, helpers) => void` | Fallback called on error when no handler matched, or on network failure. |
|
|
313
|
+
| `submit` | `(payload) => Promise<ResponseLike>` | Replace oku with any custom HTTP function. |
|
|
314
|
+
| `requestOptions` | `Record<string, unknown>` | Extra options forwarded verbatim to oku (headers, credentials, etc.). |
|
|
315
|
+
| `onBeforeSubmit` | `() => void` | Called immediately before submission starts. |
|
|
316
|
+
| `onAfterSubmit` | `() => void` | Called immediately after submission ends (always paired with `onBeforeSubmit`). |
|
|
317
|
+
|
|
318
|
+
### Return value
|
|
319
|
+
|
|
320
|
+
`useZodForm` spreads the entire return value of react-hook-form's `useForm` onto its own return object, so **every function and property that `useForm` exposes is available directly** from `useZodForm` — no secondary form reference needed.
|
|
321
|
+
|
|
322
|
+
#### react-hook-form surface (all available, unchanged)
|
|
323
|
+
|
|
324
|
+
| Property / method | Description |
|
|
325
|
+
|---|---|
|
|
326
|
+
| `register(name, options?)` | Register a field and return its ref, `onChange`, `onBlur`, and `name` props. |
|
|
327
|
+
| `control` | `Controller` / `useController` integration object. |
|
|
328
|
+
| `formState` | Reactive state bag: `errors`, `isSubmitting`, `isValid`, `isDirty`, `dirtyFields`, `touchedFields`, `isLoading`, and more. |
|
|
329
|
+
| `watch(name?)` | Subscribe to field value changes. Returns the current value or an object of all values. |
|
|
330
|
+
| `getValues(name?)` | Read field values without subscribing to re-renders. |
|
|
331
|
+
| `setValue(name, value, options?)` | Imperatively set a field value. |
|
|
332
|
+
| `setError(name, error, options?)` | Manually set a field error (e.g. from a server response). |
|
|
333
|
+
| `clearErrors(name?)` | Clear one or all field errors. |
|
|
334
|
+
| `reset(values?, options?)` | Reset the form to its default values (or supplied values). |
|
|
335
|
+
| `resetField(name, options?)` | Reset a single field. |
|
|
336
|
+
| `trigger(name?)` | Manually trigger validation on one or all fields. |
|
|
337
|
+
| `setFocus(name, options?)` | Programmatically focus a registered field. |
|
|
338
|
+
| `unregister(name, options?)` | Unregister a field and optionally remove its value. |
|
|
339
|
+
| `getFieldState(name)` | Read the dirty/invalid/error state of a specific field. |
|
|
340
|
+
| `handleSubmit(fn, onError?)` | Wrap a custom handler with react-hook-form's validation gate. Not needed in normal usage since `onSubmit` is pre-bound, but available if you need a second submission path. |
|
|
341
|
+
|
|
342
|
+
All `formState` properties react-hook-form documents - `errors`, `isSubmitting`, `isValid`, `isDirty`, `isLoading`, `isSubmitSuccessful`, `submitCount`, `dirtyFields`, `touchedFields` - are accessible through `formState` exactly as they are in plain react-hook-form.
|
|
343
|
+
|
|
344
|
+
```tsx
|
|
345
|
+
const {
|
|
346
|
+
register,
|
|
347
|
+
control,
|
|
348
|
+
watch,
|
|
349
|
+
setValue,
|
|
350
|
+
getValues,
|
|
351
|
+
setError,
|
|
352
|
+
clearErrors,
|
|
353
|
+
reset,
|
|
354
|
+
trigger,
|
|
355
|
+
setFocus,
|
|
356
|
+
formState: { errors, isSubmitting, isValid, isDirty },
|
|
357
|
+
onSubmit, // ← added by useZodForm
|
|
358
|
+
helpers, // ← added by useZodForm
|
|
359
|
+
} = useZodForm(schema, options);
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
#### Added by `useZodForm`
|
|
363
|
+
|
|
364
|
+
| Property | Description |
|
|
365
|
+
|---|---|
|
|
366
|
+
| `onSubmit` | Pre-bound handler for `<form onSubmit={onSubmit}>`. Runs validation, submission, and the full pipeline. `formState.isSubmitting` is `true` for its entire async duration. |
|
|
367
|
+
| `helpers` | A curated object — `{ setValue, setError, reset, getValues, watch, trigger, clearErrors }` — forwarded into every `responseHandlers` callback and `onSuccess` / `onError`, so you can imperatively update the form from inside response logic without closing over the full form object. |
|
|
368
|
+
|
|
369
|
+
---
|
|
370
|
+
|
|
371
|
+
## Recipes
|
|
372
|
+
|
|
373
|
+
### Pre-filling a form for editing
|
|
374
|
+
|
|
375
|
+
```tsx
|
|
376
|
+
const { register, onSubmit } = useZodForm(userSchema, {
|
|
377
|
+
endpoint: `/api/users/${userId}`,
|
|
378
|
+
method: 'PUT',
|
|
379
|
+
defaultValues: existingUser, // pre-populates every field
|
|
380
|
+
responseHandlers: [
|
|
381
|
+
whenSuccess(() => toast.success('Profile updated')),
|
|
382
|
+
whenError((res) => toast.error(res.data?.message)),
|
|
383
|
+
],
|
|
384
|
+
});
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
### Transforming the payload before submission
|
|
388
|
+
|
|
389
|
+
```tsx
|
|
390
|
+
const { register, onSubmit } = useZodForm(signupSchema, {
|
|
391
|
+
endpoint: '/api/auth/signup',
|
|
392
|
+
transform: (data) => ({
|
|
393
|
+
...data,
|
|
394
|
+
// strip the UI-only confirmation field
|
|
395
|
+
passwordConfirmation: undefined,
|
|
396
|
+
// inject a device fingerprint
|
|
397
|
+
deviceId: getDeviceFingerprint(),
|
|
398
|
+
}),
|
|
399
|
+
responseHandlers: [
|
|
400
|
+
whenStatusCode(422, (res, { setError }) => applyLaravelErrors(res.data, setError)),
|
|
401
|
+
whenSuccess(() => router.push('/dashboard')),
|
|
402
|
+
],
|
|
403
|
+
});
|
|
404
|
+
```
|
|
405
|
+
|
|
406
|
+
### Global loading indicator with `onBeforeSubmit` / `onAfterSubmit`
|
|
407
|
+
|
|
408
|
+
```tsx
|
|
409
|
+
const { register, onSubmit } = useZodForm(schema, {
|
|
410
|
+
endpoint: '/api/data',
|
|
411
|
+
onBeforeSubmit: () => globalLoadingStore.setLoading(true),
|
|
412
|
+
onAfterSubmit: () => globalLoadingStore.setLoading(false),
|
|
413
|
+
onSuccess: () => toast.success('Saved'),
|
|
414
|
+
});
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
### File uploads
|
|
418
|
+
|
|
419
|
+
oku automatically switches from JSON to `FormData` when any field value is a `File` or `FileList`, so no extra configuration is needed:
|
|
420
|
+
|
|
421
|
+
```tsx
|
|
422
|
+
const uploadSchema = z.object({
|
|
423
|
+
title: z.string(),
|
|
424
|
+
file: z.instanceof(File),
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
const { register, onSubmit } = useZodForm(uploadSchema, {
|
|
428
|
+
endpoint: '/api/upload',
|
|
429
|
+
method: 'POST',
|
|
430
|
+
// oku detects the File and sends multipart/form-data automatically
|
|
431
|
+
responseHandlers: [
|
|
432
|
+
whenSuccess((res) => toast.success(`Uploaded: ${res.data.filename}`)),
|
|
433
|
+
whenError((res) => toast.error(res.data?.message)),
|
|
434
|
+
],
|
|
435
|
+
});
|
|
436
|
+
```
|
|
437
|
+
|
|
438
|
+
### Custom HTTP client (bypass oku entirely)
|
|
439
|
+
|
|
440
|
+
Use `submit` to plug in any async function — fetch, axios, GraphQL, a mock — as long as it returns a `ResponseLike` object.
|
|
441
|
+
|
|
442
|
+
```tsx
|
|
443
|
+
const { register, onSubmit } = useZodForm(schema, {
|
|
444
|
+
submit: async (payload) => {
|
|
445
|
+
const res = await axios.post('/api/login', payload);
|
|
446
|
+
return {
|
|
447
|
+
status: res.status < 400 ? 'success' : 'error',
|
|
448
|
+
statusCode: res.status,
|
|
449
|
+
data: res.data,
|
|
450
|
+
};
|
|
451
|
+
},
|
|
452
|
+
responseHandlers: [
|
|
453
|
+
whenSuccess(() => router.push('/dashboard')),
|
|
454
|
+
whenError((res) => toast.error(res.data?.message)),
|
|
455
|
+
],
|
|
456
|
+
});
|
|
457
|
+
```
|
|
458
|
+
|
|
459
|
+
### Reusable handler modules
|
|
460
|
+
|
|
461
|
+
Because handlers are plain objects (`{ detect, handle }`), you can define them once and share them across forms:
|
|
462
|
+
|
|
463
|
+
```ts
|
|
464
|
+
// lib/formHandlers.ts
|
|
465
|
+
import { whenStatusCode, whenStatusRange } from '@sirmekus/kwado';
|
|
466
|
+
|
|
467
|
+
export const handleValidationErrors = (setError) =>
|
|
468
|
+
whenStatusCode(422, (res) => applyLaravelErrors(res.data, setError));
|
|
469
|
+
|
|
470
|
+
export const reportServerErrors =
|
|
471
|
+
whenStatusRange(500, 599, (res) => Sentry.captureMessage(res.data?.message));
|
|
472
|
+
|
|
473
|
+
export const handleSessionExpiry =
|
|
474
|
+
whenStatusCode(401, () => router.push('/login'));
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
```tsx
|
|
478
|
+
// In any form — setError is received as a helper argument, not closed over
|
|
479
|
+
import { reportServerErrors, handleSessionExpiry } from '@/lib/formHandlers';
|
|
480
|
+
|
|
481
|
+
const { register, onSubmit } = useZodForm(schema, {
|
|
482
|
+
endpoint: '/api/resource',
|
|
483
|
+
responseHandlers: [
|
|
484
|
+
whenStatusCode(422, (res, { setError }) => applyLaravelErrors(res.data, setError)),
|
|
485
|
+
reportServerErrors,
|
|
486
|
+
handleSessionExpiry,
|
|
487
|
+
whenSuccess(() => toast.success('Saved!')),
|
|
488
|
+
],
|
|
489
|
+
});
|
|
490
|
+
```
|
|
491
|
+
|
|
492
|
+
---
|
|
493
|
+
|
|
494
|
+
## TypeScript
|
|
495
|
+
|
|
496
|
+
The hook is fully generic. Field names, types, and `formState.errors` are all inferred from your Zod schema with no manual annotation required.
|
|
497
|
+
|
|
498
|
+
```ts
|
|
499
|
+
const schema = z.object({
|
|
500
|
+
email: z.string().email(),
|
|
501
|
+
password: z.string().min(8),
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
const { register, formState: { errors } } = useZodForm(schema, { ... });
|
|
505
|
+
|
|
506
|
+
// errors.email — fully typed, no 'any'
|
|
507
|
+
// errors.password — fully typed
|
|
508
|
+
// errors.typo — TypeScript error ✗
|
|
509
|
+
```
|
|
510
|
+
|
|
511
|
+
You can also type the response data for full inference inside handlers by passing it as the second type argument:
|
|
512
|
+
|
|
513
|
+
```ts
|
|
514
|
+
interface LoginResponse {
|
|
515
|
+
token: string;
|
|
516
|
+
user: { id: number; name: string };
|
|
517
|
+
message: string;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const { onSubmit } = useZodForm<typeof loginSchema['shape'], LoginResponse>(loginSchema, {
|
|
521
|
+
endpoint: '/api/auth/login',
|
|
522
|
+
responseHandlers: [
|
|
523
|
+
whenSuccess<LoginResponse>((res) => {
|
|
524
|
+
// res.data is LoginResponse — fully typed
|
|
525
|
+
localStorage.setItem('token', res.data.token);
|
|
526
|
+
console.log(res.data.user.name);
|
|
527
|
+
}),
|
|
528
|
+
],
|
|
529
|
+
});
|
|
530
|
+
```
|
|
531
|
+
|
|
532
|
+
---
|
|
533
|
+
|
|
534
|
+
## How the response pipeline handles oku's rejection model
|
|
535
|
+
|
|
536
|
+
oku resolves on 2xx and **rejects** on non-2xx HTTP responses and network failures. `kwado` catches both cases transparently:
|
|
537
|
+
|
|
538
|
+
- **Non-2xx HTTP response** — oku rejects with a `ResponseObject`. The hook detects the shape (`status`, `statusCode`, `data`) and feeds it through the `responseHandlers` pipeline as normal, so `whenStatusCode(422, ...)`, `whenError(...)`, etc. all work without any extra handling on your part.
|
|
539
|
+
- **Network failure** (connection refused, timeout) — the error is a plain `Error` object with no `status` / `statusCode`. The hook detects this and calls `onError` directly, bypassing the pipeline.
|
|
540
|
+
|
|
541
|
+
You never need to write a `try/catch` around `useZodForm`.
|
|
542
|
+
|
|
543
|
+
---
|
|
544
|
+
|
|
545
|
+
## Peer dependencies
|
|
546
|
+
|
|
547
|
+
| Package | Version |
|
|
548
|
+
|---|---|
|
|
549
|
+
| `react` | `>=18.0.0` |
|
|
550
|
+
| `react-hook-form` | `>=7.0.0` |
|
|
551
|
+
| `@hookform/resolvers` | `>=3.0.0` |
|
|
552
|
+
| `zod` | `>=3.0.0` |
|
|
553
|
+
| `@sirmekus/oku` | `>=1.0.0` |
|
|
554
|
+
|
|
555
|
+
---
|
|
556
|
+
|
|
557
|
+
## Building from source
|
|
558
|
+
|
|
559
|
+
```bash
|
|
560
|
+
npm install
|
|
561
|
+
npm run build # emits dist/ (CJS + ESM + .d.ts)
|
|
562
|
+
npm run typecheck # tsc --noEmit
|
|
563
|
+
```
|
|
564
|
+
|
|
565
|
+
The build uses [tsup](https://tsup.egoist.dev/) and produces:
|
|
566
|
+
|
|
567
|
+
- `dist/index.js` — CommonJS
|
|
568
|
+
- `dist/index.mjs` — ESM
|
|
569
|
+
- `dist/index.d.ts` — TypeScript declarations
|
|
570
|
+
|
|
571
|
+
---
|
|
572
|
+
|
|
573
|
+
## License
|
|
574
|
+
|
|
575
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@sirmekus/kwado",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Unify Zod validation, react-hook-form, and oku HTTP submission in one hook — with an extensible response-handler pipeline.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"react",
|
|
7
|
+
"zod",
|
|
8
|
+
"react-hook-form",
|
|
9
|
+
"oku",
|
|
10
|
+
"form",
|
|
11
|
+
"validation",
|
|
12
|
+
"typescript",
|
|
13
|
+
"hooks"
|
|
14
|
+
],
|
|
15
|
+
"author": "sirmekus (aka Emmy Boy)",
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"sideEffects": false,
|
|
18
|
+
"main": "./dist/index.js",
|
|
19
|
+
"module": "./dist/index.mjs",
|
|
20
|
+
"types": "./dist/index.d.ts",
|
|
21
|
+
"exports": {
|
|
22
|
+
".": {
|
|
23
|
+
"types": "./dist/index.d.ts",
|
|
24
|
+
"import": "./dist/index.mjs",
|
|
25
|
+
"require": "./dist/index.js"
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
"files": [
|
|
29
|
+
"dist"
|
|
30
|
+
],
|
|
31
|
+
"engines": {
|
|
32
|
+
"node": ">=18"
|
|
33
|
+
},
|
|
34
|
+
"scripts": {
|
|
35
|
+
"build": "tsup",
|
|
36
|
+
"dev": "tsup --watch",
|
|
37
|
+
"typecheck": "tsc --noEmit"
|
|
38
|
+
},
|
|
39
|
+
"peerDependencies": {
|
|
40
|
+
"@hookform/resolvers": ">=3.0.0",
|
|
41
|
+
"@sirmekus/oku": ">=1.0.0",
|
|
42
|
+
"react": ">=18.0.0",
|
|
43
|
+
"react-hook-form": ">=7.0.0",
|
|
44
|
+
"zod": ">=3.0.0"
|
|
45
|
+
},
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"@hookform/resolvers": "^5.0.1",
|
|
48
|
+
"@sirmekus/oku": "^1.0.0",
|
|
49
|
+
"@types/react": "^18.3.0",
|
|
50
|
+
"react": "^18.3.0",
|
|
51
|
+
"react-hook-form": "^7.56.0",
|
|
52
|
+
"tsup": "^8.4.0",
|
|
53
|
+
"typescript": "^5.7.0",
|
|
54
|
+
"zod": "^3.24.3"
|
|
55
|
+
}
|
|
56
|
+
}
|