7 min read

Type‑Safe Functional Reactive Forms in React & Next.js: Zod, RxJS, and Custom Hooks for Declarative Validation

Combine Zod, RxJS, and React hooks to build fully type‑safe, observable forms that stay in sync with UI state and server contracts.
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:

  1. Define a Zod schema that represents the exact shape of the data you send to the backend.
  2. Turn the schema into a type‑safe RxJS stream that emits validation results on every change.
  3. Wrap the stream in a custom React hook (useReactiveForm) that gives components a declarative API – value, error, setValue, reset, isValid, …
  4. 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$,
  };
}
  • BehaviorSubject holds the latest raw payload.
  • safeParse never 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.
  • setField updates 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 read value?.email without 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 useEffect boilerplate 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

Happy coding!