Schema-First React Forms: One Schema, Three Error Layers, Zero Glue
Source: Dev.to
Part 3 – Railway‑Oriented TypeScript
Part 1 showed how fieldValidators and setServerErrors eliminate the glue code.
In this part we go deeper into what the form hook actually does with the three error sources — and how a single schema drives both the backend pipeline and the frontend form.
The three sources of errors
| 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 simply read form.errors.email and display it.
{form.touched.email && form.errors.email && (
{form.errors.email}
)}
{/* Could be a schema error, async field error, or server error.
Always shows the highest‑priority one. */}
Install
npm install @railway-ts/use-form @railway-ts/pipelines
One schema, two worlds
The schema drives both the frontend form and the backend pipeline — covered in the full‑stack section below. Define it once and 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;
Using the schema with useForm
import { useForm } from "@railway-ts/use-form";
import { registrationSchema, type Registration } from "./schema";
const form = useForm(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 flow from the schema into
initialValues,errors,touched, andgetFieldProps.
form.getFieldProps("usernam")will raise a TypeScript error because the field name does not exist. - The
fieldValidatorskey is typed to accept only valid field names fromRegistration.
Server‑side errors
setServerErrors handles field‑level errors. For errors that are not tied to a specific field (e.g., network failures, rate limiting) use the reserved root 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] && (
{form.errors[ROOT_ERROR_KEY]}
)}
ROOT_ERROR_KEY is the string "_root" and is exported as a constant so you never have to scatter literal strings throughout your components.
Common UI patterns
Checkbox groups (static options → array field)
{["email", "phone", "sms"].map((option) => (
{option}
))}
Dynamic lists (add / remove at runtime)
const {
push,
remove,
insert,
swap,
replace,
} = form.arrayHelpers("todos");
All operations are type‑safe. Error paths are generated automatically — e.g., if todos[2].text fails validation, form.errors["todos.2.text"] contains the message. You never construct error‑path strings manually.
Full‑stack payoff
The same registrationSchema that drives the frontend form validates the backend request, and the error format produced by the pipeline matches exactly what setServerErrors expects.
// 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 } from "./schema";
export const register = pipeAsync(
async (req) => {
const body = await req.json();
return validate(registrationSchema, body);
},
flatMapWith((data) => {
// …business logic, e.g. create user in DB
return ok(data);
}),
match({
Ok: (data) => new Response(JSON.stringify(data), { status: 200 }),
Err: (e) =>
new Response(JSON.stringify(formatErrors(e)), { status: 400 }),
}),
);
Now the frontend and backend share a single source of truth for validation, error formatting, and TypeScript types — the essence of Railway‑Oriented TypeScript.
Backend – Registration Flow
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), // Result
flatMapWith(checkEmailUnique), // runs only if validation succeeded
flatMapWith(createUser), // runs only if email is unique
);
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);
});
validate(body, registrationSchema)returnsResult.- If validation passes,
checkEmailUniqueruns. - If the email is unique,
createUserruns. matchbranches once at the end, turning theResultinto an HTTP response.
Error formatting
// ValidationError[] from validate() or checkEmailUnique
[
{ path: ["email"], message: "Email already registered" }
]
// formatErrors() → Record
{
email: "Email already registered"
}
The shape produced by formatErrors is exactly what form.setServerErrors() expects on the frontend, so no extra conversion or field‑name mapping is required.
Frontend – @railway-ts/use-form Hook
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.string().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;
const form = useForm(zodSchema, {
initialValues: { username: "", email: "", password: "", age: 0 },
onSubmit: (values) => console.log(values),
});
- No resolver needed – the hook auto‑detects Zod (or Valibot) schemas.
- Full hook API is available:
getFieldProps,touched,setServerErrors,fieldValidators,arrayHelpers, and the 3‑layer error system. - Because the backend and frontend share the same schema, server‑side errors map directly onto the form fields.
Controlled vs. Uncontrolled Inputs
| Approach | Characteristics |
|---|---|
@railway-ts/use-form (controlled) | Every keystroke updates React state → component re‑render. Simpler mental model, full state visibility, easier debugging. |
| React‑Hook‑Form (uncontrolled) | Inputs are backed by refs; DOM updates happen without React re‑render. Faster for very large or highly interactive forms. |
| Feature | Uncontrolled | Controlled |
|---|---|---|
| Re‑render strategy | ✅ | ❌ |
| DevTools | ✅ | ❌ |
| 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 only ~4.8 kB gzip.
Demo & Resources
- StackBlitz demo – a complete registration form with schema validation, cross‑field rules, async username check, server errors, checkbox groups, and loading states.
- GitHub
@railway-ts/pipelines– Schema,Resulttypes, pipelines.@railway-ts/use-form– React form hook.
Bonus – Data‑Processing Pipelines
The same pipeline library can be used outside of React for ETL‑style batch processing: combine, combineAll, partition, reusable sub‑pipelines, and structured error reporting. No UI, just pure data transformation.