This is Part 3 of Railway-Oriented TypeScript. Part 1 showed fieldValidators and setServerErrors eliminating the glue code. This part goes deeper into what the form hook actually does with those three error sources — and how one schema drives both the backend pipeline and the frontend form.
Every form has three sources of errors: the schema says "invalid email," an async check says "username taken," the server says "email already registered." In Part 1 you saw how fieldValidators and setServerErrors declare these without glue code. What wasn't shown is how the hook resolves them when multiple apply simultaneously.
The 3-Layer Error Priority System
In react-hook-form, all three error sources use setError() with different type strings. Priority is your responsibility — whichever you called last wins, or whichever you forgot to clearErrors. That's a deliberate design choice that gives you control. The tradeoff is that you have to exercise it correctly every time.
@railway-ts/use-form makes this deterministic:
| Priority | Source | How it's set | When it clears |
|---|---|---|---|
| 1 (lowest) | Schema validation | Automatic on change/blur/submit | On every validation run |
| 2 | Async field validators |
fieldValidators option |
When the field validator re-runs |
| 3 (highest) | Server errors | form.setServerErrors(...) |
When the user edits the affected field |
Higher priority wins. A server error stays visible even when schema validation passes — the server is the authority. An async "username taken" overrides a schema-level "too short" — the live check is more specific. Editing a field clears the server error and lets schema validation take over again.
You never manage this in component code. You read form.errors.email and display it.
<input type="email" {...form.getFieldProps("email")} />;
{
form.touched.email && form.errors.email && <span>{form.errors.email}</span>;
}
{
/* Could be schema error, async field error, or server error.
Always shows the highest-priority one. */
}
Installation
npm install @railway-ts/use-form @railway-ts/pipelines
The Schema Is the Form
The schema drives both the frontend form and the backend pipeline — covered in the full-stack section below. Define it once, export it from a shared file:
// schema.ts
import {
object,
required,
optional,
chain,
string,
nonEmpty,
email,
minLength,
parseNumber,
min,
max,
array,
stringEnum,
refineAt,
type InferSchemaType,
} from "@railway-ts/pipelines/schema";
export const registrationSchema = chain(
object({
username: required(
chain(string(), nonEmpty("Username is required"), minLength(3)),
),
email: required(chain(string(), nonEmpty("Email is required"), email())),
password: required(
chain(string(), nonEmpty("Password is required"), minLength(8)),
),
confirmPassword: required(
chain(string(), nonEmpty("Please confirm your password")),
),
age: required(
chain(parseNumber(), min(18, "Must be at least 18"), max(120)),
),
contacts: optional(array(stringEnum(["email", "phone", "sms"]))),
}),
refineAt(
"confirmPassword",
(d) => d.password === d.confirmPassword,
"Passwords must match",
),
);
export type Registration = InferSchemaType<typeof registrationSchema>;
The schema goes directly into useForm — no resolver, no adapter:
import { useForm } from "@railway-ts/use-form";
import { registrationSchema, type Registration } from "./schema";
const form = useForm<Registration>(registrationSchema, {
initialValues: {
username: "",
email: "",
password: "",
confirmPassword: "",
age: 0,
contacts: [],
},
fieldValidators: {
username: async (value) => {
const { available } = await fetch(
`/api/check-username?u=${encodeURIComponent(value)}`,
).then((r) => r.json());
return available ? undefined : "Username is already taken";
},
},
onSubmit: async (values) => {
const res = await fetch("/api/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(values),
});
if (!res.ok) form.setServerErrors(await res.json());
else navigate("/welcome");
},
});
Types propagate from the schema into initialValues, errors, touched, and getFieldProps — form.getFieldProps("usernam") is a TypeScript error. The fieldValidators key is typed to only accept valid field names from Registration.
Form-Level Errors
setServerErrors handles field-level errors. For errors not tied to a specific field — network failures, rate limiting — use ROOT_ERROR_KEY:
import { ROOT_ERROR_KEY } from "@railway-ts/pipelines/schema";
form.setServerErrors({
[ROOT_ERROR_KEY]: "Network error. Please try again.",
});
{
form.errors[ROOT_ERROR_KEY] && (
<div className="form-error">{form.errors[ROOT_ERROR_KEY]}</div>
);
}
ROOT_ERROR_KEY is the string "_root" — a reserved key for form-level errors, exported as a constant so you're not scattering string literals through component code.
Array Fields
Two patterns depending on the use case.
Checkbox groups — a static set of options mapped to an array field:
{
["email", "phone", "sms"].map((option) => (
<label key={option}>
<input
type="checkbox"
{...form.getCheckboxGroupOptionProps("contacts", option)}
/>
{option}
</label>
));
}
Dynamic lists — add/remove items at runtime:
const { push, remove, insert, swap, replace } = form.arrayHelpers("todos");
All operations are type-safe. Error paths are automatic — if todos[2].text fails validation, form.errors['todos.2.text'] has the message. You don't construct error path strings manually.
Backend: Same Schema, Full Loop
This is the payoff. The same registrationSchema that drives the frontend form validates the backend request — and the error format that comes out of the pipeline is the exact format setServerErrors expects in.
// server.ts
import { validate, formatErrors } from "@railway-ts/pipelines/schema";
import { pipeAsync } from "@railway-ts/pipelines/composition";
import { ok, err, flatMapWith, match } from "@railway-ts/pipelines/result";
import { registrationSchema, type Registration } from "./schema"; // same file
const checkEmailUnique = async (data: Registration) => {
const exists = await db.user.findUnique({ where: { email: data.email } });
return exists
? err([{ path: ["email"], message: "Email already registered" }])
: ok(data);
};
const createUser = async (data: Registration) => {
const user = await db.user.create({
data: {
username: data.username,
email: data.email,
password: await hash(data.password),
age: data.age,
},
});
return ok(user);
};
const handleRegistration = async (body: unknown) => {
const result = await pipeAsync(
validate(body, registrationSchema),
flatMapWith(checkEmailUnique),
flatMapWith(createUser),
);
return match(result, {
ok: (user) => ({ status: 201, body: { id: user.id } }),
err: (errors) => ({ status: 422, body: formatErrors(errors) }),
});
};
app.post("/api/register", async (req, res) => {
const { status, body } = await handleRegistration(req.body);
res.status(status).json(body);
});
Follow the data: validate(body, registrationSchema) returns Result<Registration, ValidationError[]>. If it passes, checkEmailUnique runs. If that passes, createUser runs. match branches once at the end.
formatErrors converts ValidationError[] to Record<string, string>:
// ValidationError[] from validate() or checkEmailUnique
[{ path: ["email"], message: "Email already registered" }];
// formatErrors() →
{
email: "Email already registered";
}
That's the exact shape form.setServerErrors() expects. The frontend calls form.setServerErrors(await res.json()) and the error appears on the email field. No conversion, no field name mapping, no adapter. Same format out of the backend, same format into the form hook — because they share the schema.
Already Using Zod?
The form hook accepts Zod and Valibot schemas directly via Standard Schema v1 auto-detection — no resolver, no adapter package:
import { z } from "zod";
import { useForm } from "@railway-ts/use-form";
const zodSchema = z.object({
username: z.string().min(3, "Username must be at least 3 characters"),
email: z.email("Invalid email address"),
password: z.string().min(8, "Password must be at least 8 characters"),
age: z.coerce.number().min(18, "Must be at least 18"),
});
type ZodUser = z.infer<typeof zodSchema>;
const form = useForm<ZodUser>(zodSchema, {
initialValues: { username: "", email: "", password: "", age: 0 },
onSubmit: (values) => console.log(values),
});
No zodResolver. No @hookform/resolvers. You keep the full hook API — getFieldProps, touched, setServerErrors, fieldValidators, arrayHelpers, and the 3-layer error system. The Standard Schema path means you can adopt the form hook now with your existing Zod schemas, and migrate to @railway-ts/pipelines/schema later when the shared backend/frontend schema is what you want.
Trade-offs
Re-renders
@railway-ts/use-form uses controlled inputs — every keystroke updates React state and triggers a re-render of the component. react-hook-form uses uncontrolled inputs backed by refs, updating the DOM directly without React involvement.
For most forms — login, signup, settings, CRUD with under ~30 fields — the performance difference is unmeasurable. Both feel instant.
For forms with 50+ fields, live-preview editors where every character re-renders a complex output, or performance-sensitive mobile contexts, react-hook-form's uncontrolled approach is measurably faster. If you're building a form where keystroke latency matters at scale, that's a real advantage worth keeping.
We chose controlled inputs because they're simpler to reason about — state is always in React, there's no ref/state divergence to debug, and the entire form state is inspectable at any point. For the 95% of forms where performance isn't a constraint, that tradeoff favors correctness and debuggability.
Ecosystem
react-hook-form has a DevTools extension, a plugin ecosystem, and years of edge cases documented in GitHub issues. @railway-ts/use-form doesn't have that history yet. For teams that encounter unusual component integrations regularly, community depth is a genuine advantage.
Feature comparison
| RHF + Zod | @railway-ts | |
|---|---|---|
| Bundle size | ~35.5 kB gzip† | ~10 kB gzip†† |
| npm packages | 3 | 2 |
| Adapter packages | 1 (@hookform/resolvers) |
0 |
| Error priority | Manual setError ordering |
Automatic 3-layer system |
| Async field validation | Manual | Built-in fieldValidators
|
| Cross-field validation | .refine() |
refineAt() |
| Standard Schema | Via resolver | Native |
handleSubmit returns |
void |
Promise<Result<T, E[]>> |
| Re-render strategy | Uncontrolled | Controlled |
| DevTools | Yes | No |
| Community size | Large | Small |
† Sizes from bundlephobia (gzip). †† @railway-ts sizes from size-limit; ~7.8 kB brotli. If your project already ships Zod, the marginal cost of RHF + resolvers is ~22.5 kB. The @railway-ts total includes both the form hook and the full pipeline/validation library — if you're using that on the backend anyway, the form hook adds ~4.8 kB gzip.
Putting It Together
The complete registration form — schema validation, cross-field rules, async username check, server errors, checkbox groups, loading states — is in the StackBlitz demo. Try the form hook directly — schema validation, async checks, and error priority in one runnable example.
GitHub:
- @railway-ts/pipelines — Schema, Result types, pipelines
- @railway-ts/use-form — React form hook
Next: Bonus — Data Processing Pipelines — the same pipeline library in an ETL context. Batch processing with combine, combineAll, and partition; reusable sub-pipelines; structured error reporting. No React, no UI.
Top comments (0)