Schema-First React Forms: One Schema, Three Error Layers, Zero Glue

Published: (February 19, 2026 at 08:45 PM EST)
7 min read
Source: Dev.to

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

PrioritySourceHow it’s setWhen it clears
1 (lowest)Schema validationAutomatic on change / blur / submitOn every validation run
2Async field validatorsfieldValidators optionWhen the field validator re‑runs
3 (highest)Server errorsform.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, and getFieldProps.
    form.getFieldProps("usernam") will raise a TypeScript error because the field name does not exist.
  • The fieldValidators key is typed to accept only valid field names from Registration.

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) returns Result.
  • If validation passes, checkEmailUnique runs.
  • If the email is unique, createUser runs.
  • match branches once at the end, turning the Result into 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

ApproachCharacteristics
@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.
FeatureUncontrolledControlled
Re‑render strategy
DevTools
Community sizeLargeSmall

† 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, Result types, 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.

0 views
Back to Blog

Related posts

Read more »