Type‑Safe Functional Reactive Forms in React & Next.js: Zod, RxJS, and Custom Hooks for Declarative Validation
Introduction
Forms are the most common source of bugs in web applications: missing fields, mismatched types, and stale validation logic creep in as the UI evolves.
In a TypeScript‑first codebase we can eliminate most of those problems by hoisting validation into a single source of truth and making the form state observable.
This article shows how to:
- Define a Zod schema that represents the exact shape of the data you send to the backend.
- Turn the schema into a type‑safe RxJS stream that emits validation results on every change.
- Wrap the stream in a custom React hook (
useReactiveForm) that gives components a declarative API –value,error,setValue,reset,isValid, … - Use the hook in a Next.js page and in a nested component without losing type safety or performance.
No external form libraries are required; the solution relies only on Zod, RxJS, and plain React/Next.js.
1. The Core Idea
UI input → RxJS Subject → Zod validation → React hook state → UI
- Subject – captures every keystroke (or any input change).
- Zod – parses and validates the raw value, producing a typed result (
ParseResult<T>). - Hook – subscribes to the validation observable, exposing the latest value, error, and isValid flags as React state.
Because the Zod schema is the single source of truth, the TypeScript type inferred from it (z.infer<typeof schema>) is automatically propagated to the UI, the hook, and any API client you write.
2. Defining the Form Schema
// src/schemas/userForm.ts
import { z } from "zod";
export const userFormSchema = z.object({
firstName: z.string().min(1, "First name is required"),
lastName: z.string().min(1, "Last name is required"),
email: z.string().email("Invalid e‑mail address"),
age: z.preprocess((val) => Number(val), z
.number()
.int()
.min(13, "You must be at least 13")
.max(120, "Age looks unrealistic")),
newsletter: z.boolean().optional(),
});
export type UserForm = z.infer<typeof userFormSchema>;
All fields are validated at compile time – if you try to read data.age as a string you’ll get a TypeScript error.
The schema can be reused for server‑side validation (e.g. in an API route) without duplication.
3. Building the Reactive Core
3.1. The Validation Observable
// src/lib/reactiveForm.ts
import { BehaviorSubject, combineLatest, map } from "rxjs";
import { z, ZodError, ZodTypeAny } from "zod";
/**
* Returns a tuple:
* - a function to push raw input values
* - an observable that emits { value, error, isValid }
*/
export function createFormStream<T extends ZodTypeAny>(schema: T) {
const raw$ = new BehaviorSubject<unknown>({}); // initial empty payload
const result$ = raw$.pipe(
map((raw) => {
const parse = schema.safeParse(raw);
return {
value: parse.success ? (parse.data as z.infer<T>) : undefined,
error: parse.success ? undefined : (parse.error as ZodError),
isValid: parse.success,
};
})
);
return {
push: (payload: unknown) => raw$.next(payload),
result$: result$,
};
}
BehaviorSubjectholds the latest raw payload.safeParsenever throws; it returns a discriminated union that we turn into a simple{value,error,isValid}object.
3.2. The Hook
// src/hooks/useReactiveForm.ts
import { useEffect, useState, useCallback } from "react";
import { Observable } from "rxjs";
import { ZodError, ZodTypeAny } from "zod";
/**
* Hook exposing a type‑safe, observable form.
*/
export function useReactiveForm<T extends ZodTypeAny>(
schema: T,
initialData: Partial<z.infer<T>> = {}
) {
const [value, setValue] = useState<z.infer<T> | undefined>(undefined);
const [error, setError] = useState<ZodError | undefined>(undefined);
const [isValid, setIsValid] = useState(false);
// Create the stream once.
const { push, result$ } = useMemo(() => createFormStream(schema), [schema]);
// Subscribe to validation results.
useEffect(() => {
const sub = result$.subscribe(({ value, error, isValid }) => {
setValue(value);
setError(error);
setIsValid(isValid);
});
// Seed the stream with initial data.
push(initialData);
return () => sub.unsubscribe();
}, [push, result$]);
// Helper to update a single field.
const setField = useCallback(
(field: keyof z.infer<T>, fieldValue: any) => {
setValue((prev) => {
const next = { ...(prev ?? {}), [field]: fieldValue };
push(next);
return next;
});
},
[push]
);
const reset = useCallback(
(data: Partial<z.infer<T>> = {}) => {
push(data);
},
[push]
);
return {
value,
error,
isValid,
setField,
reset,
// expose raw push for advanced use‑cases
_push: push,
};
}
Why this works:
- The hook subscribes to the RxJS stream only once, avoiding re‑creation on every render.
setFieldupdates the local React state and pushes the new raw payload into the observable, triggering re‑validation.- All returned values are typed thanks to
z.infer<T>– the component can safely readvalue?.emailwithout a cast.
4. Using the Hook in a Next.js Page
// pages/register.tsx
import { NextPage } from "next";
import { userFormSchema, UserForm } from "../schemas/userForm";
import { useReactiveForm } from "../hooks/useReactiveForm";
const RegisterPage: NextPage = () => {
const {
value,
error,
isValid,
setField,
reset,
} = useReactiveForm(userFormSchema);
const onSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!isValid || !value) return;
const res = await fetch("/api/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(value),
});
if (res.ok) {
alert("User created!");
reset();
} else {
const data = await res.json();
alert(`Server error: ${data.message}`);
}
};
return (
<form onSubmit={onSubmit} className="max-w-md mx-auto p-4">
<h1 className="text-2xl mb-4">Register</h1>
{/* First name */}
<label className="block">
First name
<input
className="border w-full"
type="text"
value={value?.firstName ?? ""}
onChange={(e) => setField("firstName", e.target.value)}
/>
{error?.format().firstName?.[0] && (
<p className="text-red-600">{error.format().firstName?.[0]}</p>
)}
</label>
{/* Last name */}
<label className="block mt-2">
Last name
<input
className="border w-full"
type="text"
value={value?.lastName ?? ""}
onChange={(e) => setField("lastName", e.target.value)}
/>
{error?.format().lastName?.[0] && (
<p className="text-red-600">{error.format().lastName?.[0]}</p>
)}
</label>
{/* Email */}
<label className="block mt-2">
Email
<input
className="border w-full"
type="email"
value={value?.email ?? ""}
onChange={(e) => setField("email", e.target.value)}
/>
{error?.format().email?.[0] && (
<p className="text-red-600">{error.format().email?.[0]}</p>
)}
</label>
{/* Age */}
<label className="block mt-2">
Age
<input
className="border w-full"
type="number"
value={value?.age ?? ""}
onChange={(e) => setField("age", e.target.value)}
/>
{error?.format().age?.[0] && (
<p className="text-red-600">{error.format().age?.[0]}</p>
)}
</label>
{/* Newsletter */}
<label className="inline-flex items-center mt-2">
<input
type="checkbox"
checked={value?.newsletter ?? false}
onChange={(e) => setField("newsletter", e.target.checked)}
/>
<span className="ml-2">Subscribe to newsletter</span>
</label>
<button
type="submit"
disabled={!isValid}
className={`mt-4 px-4 py-2 bg-blue-600 text-white rounded ${
!isValid ? "opacity-50 cursor-not-allowed" : ""
}`}
>
Register
</button>
</form>
);
};
export default RegisterPage;
What we get
| Feature | Implementation |
|---|---|
| Compile‑time safety | value is inferred as UserForm. Accessing a non‑existent field triggers a TS error. |
| Live validation | As soon as a field changes, the Zod schema runs, and error updates automatically. |
| Single source of truth | The same userFormSchema can be imported in pages/api/users.ts for server validation. |
| No extra dependencies | Only zod, rxjs, and React hooks are used. |
5. Nesting Forms – A Reusable Component
Often a form contains a sub‑form (e.g., address fields). Because the hook returns a push function, you can compose streams:
// components/AddressFields.tsx
import { z } from "zod";
import { useReactiveForm } from "../hooks/useReactiveForm";
const addressSchema = z.object({
street: z.string().min(1),
city: z.string().min(1),
zip: z.string().regex(/^\d{5}$/, "ZIP must be 5 digits"),
});
export type Address = z.infer<typeof addressSchema>;
type Props = {
parentPush: (payload: Partial<any>) => void; // function from parent form
initial?: Partial<Address>;
};
export const AddressFields = ({ parentPush, initial }: Props) => {
const { value, error, setField } = useReactiveForm(addressSchema, initial);
// Whenever local validation changes, push the whole address object up.
useEffect(() => {
if (value) parentPush({ address: value });
}, [value, parentPush]);
return (
<div className="border p-2 mt-4">
<h2 className="text-lg mb-2">Address</h2>
<label className="block">
Street
<input
type="text"
value={value?.street ?? ""}
onChange={(e) => setField("street", e.target.value)}
/>
{error?.format().street?.[0] && (
<p className="text-red-600">{error.format().street?.[0]}</p>
)}
</label>
{/* city & zip omitted for brevity */}
</div>
);
};
Parent usage
// pages/register.tsx (excerpt)
const {
value,
error,
isValid,
setField,
reset,
_push, // raw push
} = useReactiveForm(userFormSchema);
return (
<form onSubmit={onSubmit}>
{/* …previous fields… */}
<AddressFields parentPush={_push} />
{/* submit button */}
</form>
);
The child component never knows about the parent schema; it only pushes a partial payload ({ address: { … } }). The parent’s observable merges the fragments automatically, keeping the whole form validated.
6. Server‑Side Validation with the Same Schema
// pages/api/users.ts
import type { NextApiRequest, NextApiResponse } from "next";
import { userFormSchema } from "../../schemas/userForm";
export default function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== "POST") {
res.setHeader("Allow", "POST");
return res.status(405).end("Method Not Allowed");
}
const result = userFormSchema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({ message: "Invalid payload", errors: result.error.format() });
}
// At this point `result.data` is of type `UserForm`
// Persist to DB, send welcome email, etc.
res.status(201).json({ message: "User created", user: result.data });
}
The API reuses the exact same Zod definition, guaranteeing that what the client considers valid is also valid on the server. No duplication, no drift.
7. Performance Considerations
| Concern | Mitigation |
|---|---|
| Re‑render on each keystroke | The hook updates only the pieces of state (value, error, isValid) that changed. Components that read only isValid won’t re‑render when a different field changes. |
| Large forms | Use debounceTime in the stream if you want to delay validation (e.g., email availability checks). |
| Memory leaks | The subscription is disposed in useEffect cleanup. The BehaviorSubject lives as long as the component does. |
| SSR | The hook works on the server because RxJS and Zod are pure JS. However, you typically don’t need to validate on the server during SSR – just render the initial state. |
8. Extending the Pattern
8.1. Asynchronous Validation
Add a second observable that performs async checks (e.g., username uniqueness) and merges its result with the sync validation:
const asyncCheck$ = raw$.pipe(
debounceTime(300),
switchMap((payload) =>
fetch(`/api/check-email?email=${payload.email}`).then((r) => r.json())
),
map((resp) => (resp.available ? null : "E‑mail already in use"))
);
Combine it with the sync result$ using combineLatest, and surface the async error alongside the Zod error.
8.2. Form Arrays
For dynamic lists (e.g., “add another phone”), wrap the array in a Zod z.array(itemSchema) and keep the array in a separate BehaviorSubject. The same hook pattern applies; just map the array index to setField.
9. Recap
- Zod provides a single, type‑safe definition of the payload.
- RxJS turns raw input into a stream of validated results, enabling composable async checks.
- Custom hook (
useReactiveForm) bridges the observable world with React, exposing a clean API that feels like any other form library but with full compile‑time guarantees. - The approach works both on the client and in Next.js API routes, eliminating duplication and keeping the UI and backend in lockstep.
By adopting this pattern you gain:
- Zero runtime surprises – type mismatches are caught at compile time.
- Instant, declarative validation without writing
useEffectboilerplate for every field. - Composable forms – sub‑forms become independent streams that merge effortlessly.
Give it a try on a small feature flag form; once you see the type safety in action, you’ll wonder how you ever managed without it.
10. Further Reading
- Zod docs – https://zod.dev/
- RxJS operators cheat sheet – https://rxjs.dev/guide/operators
- React Hooks FAQ – https://reactjs.org/docs/hooks-faq.html
Happy coding!
Member discussion