@railway-ts/use-form 0.1.6 → 0.1.7
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 +206 -37
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -2,11 +2,29 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/@railway-ts/use-form) [](https://github.com/sakobu/railway-ts-use-form/actions) [](https://opensource.org/licenses/MIT) [](https://www.typescriptlang.org/) [](https://codecov.io/gh/sakobu/railway-ts-use-form)
|
|
4
4
|
|
|
5
|
-
React
|
|
5
|
+
Schema-first React forms with full TypeScript safety, composable validation, and native HTML bindings.
|
|
6
6
|
|
|
7
7
|
**~3.6 kB** minified + brotli
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
## Why?
|
|
10
|
+
|
|
11
|
+
Most React form solutions split validation from types from bindings. You define a schema in one place, extract types in another, wire up resolvers in a third, and manually plumb errors into your UI. Every layer is a seam where things drift.
|
|
12
|
+
|
|
13
|
+
This library treats the **schema as the single source of truth**. One declaration gives you:
|
|
14
|
+
|
|
15
|
+
- TypeScript types (inferred, never duplicated)
|
|
16
|
+
- Validation (composable, accumulates all errors in one pass)
|
|
17
|
+
- Field bindings (spread onto native HTML elements)
|
|
18
|
+
- Error handling (three layers with deterministic priority)
|
|
19
|
+
|
|
20
|
+
Bring your own Zod, Valibot, or ArkType via [Standard Schema](https://github.com/standard-schema/standard-schema), or use [@railway-ts/pipelines](https://github.com/sakobu/railway-ts-pipelines) natively for cross-field validation, targeted error placement, and `Result` types.
|
|
21
|
+
|
|
22
|
+
## Design
|
|
23
|
+
|
|
24
|
+
- **Schema-driven** -- define once, get types + validation + field paths
|
|
25
|
+
- **Three error layers** -- client, field async, server -- with deterministic priority
|
|
26
|
+
- **Native HTML bindings** -- spread onto inputs, selects, checkboxes, files, radios
|
|
27
|
+
- **Railway Result** -- `handleSubmit` returns `Result<T, E>` for pattern matching
|
|
10
28
|
|
|
11
29
|
## Install
|
|
12
30
|
|
|
@@ -14,7 +32,7 @@ React form hook where the schema is the source of truth. Define validation once,
|
|
|
14
32
|
bun add @railway-ts/use-form @railway-ts/pipelines # or npm, pnpm, yarn
|
|
15
33
|
```
|
|
16
34
|
|
|
17
|
-
Requires React 18+ and @railway-ts/pipelines ^0.1.
|
|
35
|
+
Requires React 18+ and @railway-ts/pipelines ^0.1.13.
|
|
18
36
|
|
|
19
37
|
## Quick Start
|
|
20
38
|
|
|
@@ -65,52 +83,173 @@ export function LoginForm() {
|
|
|
65
83
|
}
|
|
66
84
|
```
|
|
67
85
|
|
|
68
|
-
##
|
|
86
|
+
## Real-World Use Case
|
|
69
87
|
|
|
70
|
-
|
|
88
|
+
Registration form with cross-field validation (`refineAt` for password confirmation), per-field async validation (`fieldValidators` for username availability), and server errors -- all in one component:
|
|
71
89
|
|
|
72
|
-
```
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
90
|
+
```tsx
|
|
91
|
+
import { useNavigate } from 'react-router-dom';
|
|
92
|
+
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
|
93
|
+
import { useForm } from '@railway-ts/use-form';
|
|
94
|
+
import {
|
|
95
|
+
object,
|
|
96
|
+
string,
|
|
97
|
+
required,
|
|
98
|
+
chain,
|
|
99
|
+
refineAt,
|
|
100
|
+
nonEmpty,
|
|
101
|
+
email,
|
|
102
|
+
minLength,
|
|
103
|
+
ROOT_ERROR_KEY,
|
|
104
|
+
type InferSchemaType,
|
|
105
|
+
} from '@railway-ts/pipelines/schema';
|
|
78
106
|
|
|
79
|
-
|
|
107
|
+
// --- Schema ---
|
|
80
108
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
109
|
+
const schema = chain(
|
|
110
|
+
object({
|
|
111
|
+
username: required(chain(string(), nonEmpty(), minLength(3))),
|
|
112
|
+
email: required(chain(string(), nonEmpty(), email())),
|
|
113
|
+
password: required(chain(string(), nonEmpty(), minLength(8))),
|
|
114
|
+
confirmPassword: required(chain(string(), nonEmpty())),
|
|
115
|
+
}),
|
|
116
|
+
refineAt(
|
|
117
|
+
'confirmPassword',
|
|
118
|
+
(d) => d.password === d.confirmPassword,
|
|
119
|
+
'Passwords must match'
|
|
120
|
+
)
|
|
121
|
+
);
|
|
86
122
|
|
|
87
|
-
|
|
123
|
+
type Registration = InferSchemaType<typeof schema>;
|
|
124
|
+
|
|
125
|
+
// --- API layer ---
|
|
126
|
+
|
|
127
|
+
type UsernameCheck = { available: boolean };
|
|
128
|
+
type ServerErrors = Record<string, string>;
|
|
129
|
+
|
|
130
|
+
const toJsonIfOk = (res: Response) =>
|
|
131
|
+
res.ok ? res.json() : Promise.reject(`HTTP ${res.status}`);
|
|
132
|
+
|
|
133
|
+
const checkUsername = (username: string): Promise<UsernameCheck> =>
|
|
134
|
+
fetch(`/api/check-username?u=${encodeURIComponent(username)}`).then(
|
|
135
|
+
toJsonIfOk
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
class ApiValidationError extends Error {
|
|
139
|
+
constructor(public fieldErrors: ServerErrors) {
|
|
140
|
+
super('Validation failed');
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const registerUser = async (values: Registration): Promise<void> => {
|
|
145
|
+
const res = await fetch('/api/register', {
|
|
146
|
+
method: 'POST',
|
|
147
|
+
headers: { 'Content-Type': 'application/json' },
|
|
148
|
+
body: JSON.stringify(values),
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
if (!res.ok) throw new ApiValidationError(await res.json());
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
// --- Component ---
|
|
155
|
+
|
|
156
|
+
export function RegistrationForm() {
|
|
157
|
+
const navigate = useNavigate();
|
|
158
|
+
const queryClient = useQueryClient();
|
|
159
|
+
|
|
160
|
+
const mutation = useMutation({
|
|
161
|
+
mutationFn: registerUser,
|
|
162
|
+
onSuccess: () => navigate('/welcome'),
|
|
163
|
+
onError: (error) => {
|
|
164
|
+
if (error instanceof ApiValidationError) {
|
|
165
|
+
form.setServerErrors(error.fieldErrors);
|
|
166
|
+
} else {
|
|
167
|
+
form.setServerErrors({
|
|
168
|
+
[ROOT_ERROR_KEY]: 'Network error. Please try again.',
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
const form = useForm<Registration>(schema, {
|
|
175
|
+
initialValues: {
|
|
176
|
+
username: '',
|
|
177
|
+
email: '',
|
|
178
|
+
password: '',
|
|
179
|
+
confirmPassword: '',
|
|
180
|
+
},
|
|
181
|
+
fieldValidators: {
|
|
182
|
+
username: async (value) => {
|
|
183
|
+
const username = value as string;
|
|
184
|
+
if (username.length < 3) return undefined;
|
|
185
|
+
|
|
186
|
+
try {
|
|
187
|
+
const { available } = await queryClient.fetchQuery({
|
|
188
|
+
queryKey: ['check-username', username],
|
|
189
|
+
queryFn: () => checkUsername(username),
|
|
190
|
+
staleTime: 30_000,
|
|
191
|
+
});
|
|
192
|
+
return available ? undefined : 'Username is already taken';
|
|
193
|
+
} catch {
|
|
194
|
+
return 'Unable to check username availability';
|
|
195
|
+
}
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
onSubmit: (values) => mutation.mutate(values),
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
return (
|
|
202
|
+
<form onSubmit={(e) => void form.handleSubmit(e)}>
|
|
203
|
+
<input {...form.getFieldProps('username')} />
|
|
204
|
+
{form.validatingFields.username && <span>Checking...</span>}
|
|
205
|
+
{form.touched.username && form.errors.username && (
|
|
206
|
+
<span>{form.errors.username}</span>
|
|
207
|
+
)}
|
|
208
|
+
|
|
209
|
+
<input type="email" {...form.getFieldProps('email')} />
|
|
210
|
+
{form.touched.email && form.errors.email && (
|
|
211
|
+
<span>{form.errors.email}</span>
|
|
212
|
+
)}
|
|
213
|
+
|
|
214
|
+
<input type="password" {...form.getFieldProps('password')} />
|
|
215
|
+
{form.touched.password && form.errors.password && (
|
|
216
|
+
<span>{form.errors.password}</span>
|
|
217
|
+
)}
|
|
218
|
+
|
|
219
|
+
<input type="password" {...form.getFieldProps('confirmPassword')} />
|
|
220
|
+
{form.touched.confirmPassword && form.errors.confirmPassword && (
|
|
221
|
+
<span>{form.errors.confirmPassword}</span>
|
|
222
|
+
)}
|
|
223
|
+
|
|
224
|
+
{form.errors[ROOT_ERROR_KEY] && (
|
|
225
|
+
<span>{form.errors[ROOT_ERROR_KEY]}</span>
|
|
226
|
+
)}
|
|
227
|
+
|
|
228
|
+
<button
|
|
229
|
+
type="submit"
|
|
230
|
+
disabled={mutation.isPending || form.isValidating || !form.isValid}
|
|
231
|
+
>
|
|
232
|
+
{mutation.isPending ? 'Registering...' : 'Create Account'}
|
|
233
|
+
</button>
|
|
234
|
+
</form>
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
Cross-field validation, async username check, server errors, and React Query integration -- production patterns, zero glue code.
|
|
88
240
|
|
|
89
241
|
## What's Included
|
|
90
242
|
|
|
91
243
|
- **Type-safe field paths** -- autocomplete for nested fields, dot-notation everywhere
|
|
92
244
|
- **Railway validation** -- composable validators that accumulate all errors in one pass
|
|
93
|
-
- **Standard Schema v1** -- bring your own Zod, Valibot, or ArkType schema
|
|
245
|
+
- **Standard Schema v1** -- bring your own Zod, Valibot, or ArkType schema
|
|
94
246
|
- **Native HTML bindings** -- spread `getFieldProps` onto inputs, selects, checkboxes, files, radios, sliders
|
|
95
|
-
- **Three error layers** -- client
|
|
247
|
+
- **Three error layers** -- client, per-field async, and server errors with automatic priority
|
|
96
248
|
- **Array helpers** -- type-safe `push`, `remove`, `swap`, `move`, `insert`, `replace` with field bindings
|
|
97
|
-
- **Four validation modes** -- `live`, `blur`, `mount`, `submit`
|
|
249
|
+
- **Four validation modes** -- `live`, `blur`, `mount`, `submit`
|
|
98
250
|
- **Auto-submission** -- `useFormAutoSubmission` for search/filter forms with debounced submit
|
|
99
251
|
- **React 18 + 19** -- compatible with both, tree-shakeable ESM
|
|
100
252
|
|
|
101
|
-
## API at a Glance
|
|
102
|
-
|
|
103
|
-
The `useForm` hook returns:
|
|
104
|
-
|
|
105
|
-
| Category | Properties / Methods |
|
|
106
|
-
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
107
|
-
| **State** | `values`, `touched`, `errors`, `clientErrors`, `serverErrors`, `fieldErrors`, `isValid`, `isDirty`, `isSubmitting`, `isValidating`, `validatingFields`, `submitCount` |
|
|
108
|
-
| **Field Management** | `setFieldValue(field, value, shouldValidate?)`, `setFieldTouched(field, touched?, shouldValidate?)`, `setValues(values, shouldValidate?)` |
|
|
109
|
-
| **Server Errors** | `setServerErrors(errors)`, `clearServerErrors()` |
|
|
110
|
-
| **Form Actions** | `handleSubmit(e?)` → `Promise<Result>`, `resetForm()`, `validateForm(values)` |
|
|
111
|
-
| **Field Bindings** | `getFieldProps`, `getSelectFieldProps`, `getCheckboxProps`, `getSwitchProps`, `getSliderProps`, `getFileFieldProps`, `getCheckboxGroupOptionProps`, `getRadioGroupOptionProps` |
|
|
112
|
-
| **Arrays** | `arrayHelpers(field)` → `{ values, push, remove, insert, swap, move, replace, getFieldProps, ... }` |
|
|
113
|
-
|
|
114
253
|
## Works With
|
|
115
254
|
|
|
116
255
|
Any [Standard Schema v1](https://github.com/standard-schema/standard-schema) library works out of the box -- no adapters, no wrappers. Pass the schema directly to `useForm`:
|
|
@@ -120,16 +259,46 @@ Any [Standard Schema v1](https://github.com/standard-schema/standard-schema) lib
|
|
|
120
259
|
- **ArkType** 2.0+
|
|
121
260
|
- **@railway-ts/pipelines** (native)
|
|
122
261
|
|
|
123
|
-
See [Recipes: Standard Schema](./docs/RECIPES.md#standard-schema
|
|
262
|
+
See [Recipes: Standard Schema](./docs/RECIPES.md#standard-schema-bring-your-own-validator) for Zod and Valibot examples.
|
|
263
|
+
|
|
264
|
+
## Ecosystem
|
|
265
|
+
|
|
266
|
+
`@railway-ts/use-form` is built on [@railway-ts/pipelines](https://github.com/sakobu/railway-ts-pipelines) -- composable, type-safe validation with Railway-oriented Result types. Use pipelines standalone for backend validation, or pair it with this hook for full-stack type safety.
|
|
124
267
|
|
|
125
268
|
## Documentation
|
|
126
269
|
|
|
127
270
|
- **[Getting Started](docs/GETTING_STARTED.md)** -- Step-by-step from first form to arrays
|
|
128
271
|
- **[Recipes](./docs/RECIPES.md)** -- Patterns and techniques, each recipe self-contained
|
|
129
|
-
- **[Advanced](./docs/ADVANCED.md)** -- Error priority, discriminated unions, custom validators
|
|
130
272
|
- **[API Reference](./docs/API.md)** -- Complete API documentation
|
|
131
273
|
|
|
132
|
-
|
|
274
|
+
## Examples
|
|
275
|
+
|
|
276
|
+
Clone and run:
|
|
277
|
+
|
|
278
|
+
```bash
|
|
279
|
+
git clone https://github.com/sakobu/railway-ts-use-form.git
|
|
280
|
+
cd railway-ts-use-form
|
|
281
|
+
bun install
|
|
282
|
+
bun run example
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
Then open http://localhost:3000. The interactive app has tabs for:
|
|
286
|
+
|
|
287
|
+
- **Sync** -- Basic form with schema validation
|
|
288
|
+
- **Async (Cross-field)** -- Async schema with cross-field rules (password confirmation, date ranges)
|
|
289
|
+
- **Zod** -- Standard Schema integration with Zod
|
|
290
|
+
- **Valibot** -- Standard Schema integration with Valibot
|
|
291
|
+
- **Field Validators** -- Per-field async validation with loading indicators
|
|
292
|
+
|
|
293
|
+
Or try it live on [StackBlitz](https://stackblitz.com/edit/vitejs-vite-c3zpmon9?embed=1&file=src%2FApp.tsx).
|
|
294
|
+
|
|
295
|
+
## Philosophy
|
|
296
|
+
|
|
297
|
+
- Schema is the single source of truth
|
|
298
|
+
- Validation should accumulate, not short-circuit
|
|
299
|
+
- Types should be inferred, never duplicated
|
|
300
|
+
- Form state should be explicit and predictable
|
|
301
|
+
- Native HTML first, adapters never
|
|
133
302
|
|
|
134
303
|
## Contributing
|
|
135
304
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@railway-ts/use-form",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.7",
|
|
4
4
|
"description": "React form hook that works with @railway-ts/pipelines to manage form state, validation, and submission.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.mjs",
|
|
@@ -70,7 +70,7 @@
|
|
|
70
70
|
}
|
|
71
71
|
],
|
|
72
72
|
"peerDependencies": {
|
|
73
|
-
"@railway-ts/pipelines": "^0.1.
|
|
73
|
+
"@railway-ts/pipelines": "^0.1.13",
|
|
74
74
|
"react": "^18.0.0 || ^19.0.0",
|
|
75
75
|
"typescript": "^5"
|
|
76
76
|
},
|