Web Engineering ·

Building a Form Engine in TypeScript: JSON Schema-Driven Rendering, Conditional Logic, and Multi-Step Validation

How to build a dynamic form engine that renders from JSON schema definitions, handles conditional field visibility, manages multi-step wizard state, and integrates Zod validation with resume-later persistence.

Building a Form Engine in TypeScript: JSON Schema-Driven Rendering, Conditional Logic, and Multi-Step Validation

Every SaaS product eventually needs forms that can’t be hardcoded. An onboarding wizard that differs by plan tier. A configuration screen whose fields depend on which integration a user picks. A compliance questionnaire that shows or hides sections based on jurisdiction. You can hardcode the first version. You cannot hardcode the fifth.

The usual solution is to reach for a form library: React Hook Form, Formik, react-final-form. These are good tools. But they solve a different problem: they make it easier to write form code. A form engine solves the problem of not writing form code at all. The form lives as data, and the engine renders it.

This article covers how to build that engine: schema definition, recursive rendering, conditional visibility, multi-step wizard state, Zod validation, and persistence for resume-later flows. Along the way it discusses where building your own is worth it and where it isn’t.

The Problem With Hardcoded Forms

When forms are code, every change is a deployment. A product manager who wants to add a field to the signup flow opens a Jira ticket, waits for a sprint, and gets a PR review. A compliance team that needs a new checkbox in a region-specific flow blocks on engineering capacity.

More subtly, hardcoded forms don’t compose. If you have a multi-tenant product where each tenant has slightly different intake requirements, you end up with either a single form full of conditional branches or a dozen near-duplicate form components. Neither scales.

A schema-driven engine flips the model. The form definition is data, stored wherever data lives (database, CDN, feature flags). Engineering changes the renderer once. Everything else is configuration.

Defining the Schema

Start with types that describe what a form field can be. Keep it narrow at first.

type FieldType =
  | "text"
  | "email"
  | "number"
  | "select"
  | "checkbox"
  | "textarea"
  | "date";

interface BaseField {
  id: string;
  type: FieldType;
  label: string;
  required?: boolean;
  placeholder?: string;
  defaultValue?: unknown;
}

interface SelectField extends BaseField {
  type: "select";
  options: Array<{ value: string; label: string }>;
}

interface ConditionRule {
  fieldId: string;
  operator: "eq" | "neq" | "in" | "gt" | "lt";
  value: unknown;
}

interface FieldWithCondition extends BaseField {
  showWhen?: ConditionRule;
}

type FormField = (BaseField | SelectField) & FieldWithCondition;

interface FormStep {
  id: string;
  title: string;
  fields: FormField[];
}

interface FormSchema {
  id: string;
  title: string;
  steps: FormStep[];
  version: number;
}

A concrete schema for an onboarding flow looks like this:

const onboardingSchema: FormSchema = {
  id: "onboarding-v3",
  title: "Account Setup",
  version: 3,
  steps: [
    {
      id: "basics",
      title: "Your Details",
      fields: [
        { id: "name", type: "text", label: "Full Name", required: true },
        { id: "email", type: "email", label: "Work Email", required: true },
        {
          id: "company_size",
          type: "select",
          label: "Company Size",
          required: true,
          options: [
            { value: "1-10", label: "1-10 employees" },
            { value: "11-50", label: "11-50 employees" },
            { value: "51-200", label: "51-200 employees" },
            { value: "201+", label: "201+" },
          ],
        },
      ],
    },
    {
      id: "context",
      title: "Your Use Case",
      fields: [
        {
          id: "enterprise_contact",
          type: "text",
          label: "Legal Entity Name",
          showWhen: {
            fieldId: "company_size",
            operator: "in",
            value: ["51-200", "201+"],
          },
        },
        {
          id: "use_case",
          type: "textarea",
          label: "What are you trying to build?",
          required: true,
        },
      ],
    },
  ],
};

The showWhen rule is intentionally simple. A single condition per field handles 90% of real-world cases. If you need compound conditions (AND/OR), you can extend later, but resist adding it before you need it.

Evaluating Conditions

The condition evaluator takes the current form values and a rule, and returns a boolean.

function evaluateCondition(
  rule: ConditionRule,
  values: Record<string, unknown>
): boolean {
  const fieldValue = values[rule.fieldId];

  switch (rule.operator) {
    case "eq":
      return fieldValue === rule.value;
    case "neq":
      return fieldValue !== rule.value;
    case "in":
      return Array.isArray(rule.value) && rule.value.includes(fieldValue);
    case "gt":
      return typeof fieldValue === "number" &&
        typeof rule.value === "number" &&
        fieldValue > rule.value;
    case "lt":
      return typeof fieldValue === "number" &&
        typeof rule.value === "number" &&
        fieldValue < rule.value;
    default:
      return true;
  }
}

function isFieldVisible(
  field: FormField,
  values: Record<string, unknown>
): boolean {
  if (!field.showWhen) return true;
  return evaluateCondition(field.showWhen, values);
}

Keep this function pure. No side effects, no React imports. It should be trivial to unit test in isolation, because visibility logic is exactly the kind of thing that breaks silently if you let it.

Recursive Rendering

The renderer takes a step and current form values, and outputs the visible fields. Each field delegates to a field component.

interface FieldRendererProps {
  field: FormField;
  value: unknown;
  onChange: (fieldId: string, value: unknown) => void;
  error?: string;
}

function FieldRenderer({ field, value, onChange, error }: FieldRendererProps) {
  const handleChange = (
    e: React.ChangeEvent<
      HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement
    >
  ) => {
    const newValue =
      field.type === "checkbox"
        ? (e.target as HTMLInputElement).checked
        : e.target.value;
    onChange(field.id, newValue);
  };

  switch (field.type) {
    case "select":
      return (
        <div>
          <label htmlFor={field.id}>{field.label}</label>
          <select
            id={field.id}
            value={(value as string) ?? ""}
            onChange={handleChange}
          >
            <option value="">Select...</option>
            {(field as SelectField).options.map((opt) => (
              <option key={opt.value} value={opt.value}>
                {opt.label}
              </option>
            ))}
          </select>
          {error && <span role="alert">{error}</span>}
        </div>
      );
    case "textarea":
      return (
        <div>
          <label htmlFor={field.id}>{field.label}</label>
          <textarea
            id={field.id}
            value={(value as string) ?? ""}
            placeholder={field.placeholder}
            onChange={handleChange}
          />
          {error && <span role="alert">{error}</span>}
        </div>
      );
    default:
      return (
        <div>
          <label htmlFor={field.id}>{field.label}</label>
          <input
            id={field.id}
            type={field.type}
            value={(value as string) ?? ""}
            placeholder={field.placeholder}
            onChange={handleChange}
          />
          {error && <span role="alert">{error}</span>}
        </div>
      );
  }
}

interface StepRendererProps {
  step: FormStep;
  values: Record<string, unknown>;
  errors: Record<string, string>;
  onChange: (fieldId: string, value: unknown) => void;
}

function StepRenderer({ step, values, errors, onChange }: StepRendererProps) {
  const visibleFields = step.fields.filter((field) =>
    isFieldVisible(field, values)
  );

  return (
    <fieldset>
      <legend>{step.title}</legend>
      {visibleFields.map((field) => (
        <FieldRenderer
          key={field.id}
          field={field}
          value={values[field.id]}
          onChange={onChange}
          error={errors[field.id]}
        />
      ))}
    </fieldset>
  );
}

Two things to note here. First, visibility filtering happens at render time, not at state update time. Hidden fields stay in the values map but just aren’t shown. This matters for validation: you need to skip validation on hidden fields. Second, StepRenderer is a thin wrapper around filtered field rendering. The recursive part comes in if you later add field groups or nested sections, but for a flat schema this is sufficient.

Multi-Step Wizard State

The wizard state tracks which step you’re on, the accumulated values, and the validation errors per step.

interface WizardState {
  currentStepIndex: number;
  values: Record<string, unknown>;
  stepErrors: Record<string, Record<string, string>>;
  completed: boolean;
}

type WizardAction =
  | { type: "SET_VALUE"; fieldId: string; value: unknown }
  | { type: "NEXT_STEP"; errors: Record<string, string> }
  | { type: "PREV_STEP" }
  | { type: "COMPLETE" }
  | { type: "RESTORE"; state: WizardState };

function wizardReducer(state: WizardState, action: WizardAction): WizardState {
  switch (action.type) {
    case "SET_VALUE":
      return {
        ...state,
        values: { ...state.values, [action.fieldId]: action.value },
      };
    case "NEXT_STEP":
      if (Object.keys(action.errors).length > 0) {
        return {
          ...state,
          stepErrors: {
            ...state.stepErrors,
            [state.currentStepIndex]: action.errors,
          },
        };
      }
      return {
        ...state,
        currentStepIndex: state.currentStepIndex + 1,
        stepErrors: { ...state.stepErrors, [state.currentStepIndex]: {} },
      };
    case "PREV_STEP":
      return {
        ...state,
        currentStepIndex: Math.max(0, state.currentStepIndex - 1),
      };
    case "COMPLETE":
      return { ...state, completed: true };
    case "RESTORE":
      return action.state;
    default:
      return state;
  }
}

A reducer is the right primitive here. Wizard state transitions are deterministic given current state plus action. The RESTORE action handles the resume-later flow.

Validation With Zod

Each step generates a Zod schema at runtime from the field definitions. This keeps validation co-located with the schema definition rather than duplicated in a separate validation file.

import { z } from "zod";

function buildStepSchema(
  step: FormStep,
  values: Record<string, unknown>
): z.ZodObject<Record<string, z.ZodTypeAny>> {
  const shape: Record<string, z.ZodTypeAny> = {};

  for (const field of step.fields) {
    if (!isFieldVisible(field, values)) continue;

    let fieldSchema: z.ZodTypeAny;

    switch (field.type) {
      case "email":
        fieldSchema = z.string().email("Invalid email address");
        break;
      case "number":
        fieldSchema = z.coerce.number();
        break;
      case "checkbox":
        fieldSchema = z.boolean();
        break;
      default:
        fieldSchema = z.string();
    }

    if (field.required) {
      if (field.type === "checkbox") {
        fieldSchema = z.literal(true, {
          errorMap: () => ({ message: `${field.label} is required` }),
        });
      } else {
        fieldSchema = (fieldSchema as z.ZodString).min(
          1,
          `${field.label} is required`
        );
      }
    } else {
      fieldSchema = fieldSchema.optional();
    }

    shape[field.id] = fieldSchema;
  }

  return z.object(shape);
}

function validateStep(
  step: FormStep,
  values: Record<string, unknown>
): Record<string, string> {
  const schema = buildStepSchema(step, values);
  const result = schema.safeParse(values);

  if (result.success) return {};

  const errors: Record<string, string> = {};
  for (const issue of result.error.issues) {
    const fieldId = issue.path[0] as string;
    if (fieldId) errors[fieldId] = issue.message;
  }
  return errors;
}

The key design decision: buildStepSchema takes current form values and skips hidden fields. A field with showWhen that evaluates to false is not added to the schema, so it won’t generate validation errors even if it would fail on its own terms. This is the correct behavior. Validating hidden fields would produce error messages for fields the user can’t see.

Persistence for Resume-Later Flows

Long forms get abandoned. A user starts a compliance questionnaire, gets pulled into a meeting, and comes back the next day. If their progress is gone, you’ve lost a user.

The simplest approach is to serialize the wizard state to localStorage after each value change.

const STORAGE_KEY = (formId: string, userId: string) =>
  `form_draft_${formId}_${userId}`;

function usePersistentWizard(
  schema: FormSchema,
  userId: string
): {
  state: WizardState;
  dispatch: React.Dispatch<WizardAction>;
  clearDraft: () => void;
} {
  const storageKey = STORAGE_KEY(schema.id, userId);

  const initialState: WizardState = {
    currentStepIndex: 0,
    values: {},
    stepErrors: {},
    completed: false,
  };

  const [state, dispatch] = React.useReducer(wizardReducer, initialState, () => {
    try {
      const saved = localStorage.getItem(storageKey);
      if (!saved) return initialState;
      const parsed = JSON.parse(saved) as WizardState & {
        schemaVersion?: number;
      };
      if (parsed.schemaVersion !== schema.version) {
        // Schema changed; stale draft is potentially invalid
        localStorage.removeItem(storageKey);
        return initialState;
      }
      return parsed;
    } catch {
      return initialState;
    }
  });

  React.useEffect(() => {
    if (state.completed) {
      localStorage.removeItem(storageKey);
      return;
    }
    localStorage.setItem(
      storageKey,
      JSON.stringify({ ...state, schemaVersion: schema.version })
    );
  }, [state, storageKey, schema.version]);

  const clearDraft = React.useCallback(() => {
    localStorage.removeItem(storageKey);
  }, [storageKey]);

  return { state, dispatch, clearDraft };
}

Notice the schema version check. When you deploy a new form schema, old drafts may reference fields that no longer exist or miss new required fields. Version-checking on restore and discarding stale drafts is safer than trying to migrate them. Show the user a message explaining why their draft was cleared.

For higher-stakes flows (financial applications, insurance forms), localStorage isn’t enough. You want server-side draft persistence, where the draft is saved to your database and associated with the user account. The shape of the data is the same; only the storage and retrieval mechanism changes.

Tradeoffs: Build vs. Buy

ConcernBuild your ownUse a library (e.g., react-jsonschema-form, Formily)
Schema ownershipFull control over shapeConstrained to library’s schema spec
Bundle sizeOnly what you useOften large; react-jsonschema-form bundles ~80KB
CustomizationUnlimitedLimited to exposed extension points
MaintenanceYou own bugsLibrary updates may break behavior
Time to first formDaysHours
Complex conditional logicStraightforwardOften requires workarounds or plugins
i18n / multi-localeEasy to addDepends on library support

Libraries like react-jsonschema-form are the right call when your form requirements are simple and stable. If you’re building one or two forms that need to be data-driven, use the library. If you’re building a platform where dozens of teams contribute form definitions and form behavior is a core product surface, own the engine.

The hidden cost of libraries is that their extension points are designed around use cases the library author anticipated. When your requirements diverge from those, you end up fighting the library rather than building your product. The schema you saw above is 40 lines of TypeScript. The renderer is another 80. The validation pipeline is 50 lines of Zod. The whole thing fits in a single file until you need to split it.

Production Considerations

Schema versioning. Once forms are live and users have submitted data, the schema is a contract. Add fields freely, but don’t rename or remove fields that are referenced in stored submissions. Use a version field in the schema and migrate old submissions with a data migration script when the shape changes.

Conditional validation edge cases. A field can be hidden in step 2 based on a value in step 1, but what if the user goes back to step 1 and changes that value? Make sure your validation runs against the current state of values, including any changes made by navigating back. The reducer handles this correctly because SET_VALUE always updates the canonical values map.

Accessibility. Form engines are a common place where aria attributes get dropped. Each FieldRenderer should propagate aria-required, aria-invalid, and aria-describedby (pointing at the error element). The role="alert" on the error span is necessary for screen readers to announce errors.

Render performance. On large forms (50+ fields), running isFieldVisible for every field on every keystroke is fast enough that you won’t notice it. But if you ever hit performance issues, memoize the visible field list with useMemo keyed on the fields that the showWhen rules reference, not the entire values map.

Schema storage. Storing schemas in a database alongside a content management interface gives non-engineers the ability to modify forms without a deployment. This is the main payoff of the schema-driven model. If you go this route, add a preview mode where schema authors can test a form before publishing it.

Putting It Together

The full engine is a useFormEngine hook that composes everything:

function useFormEngine(schema: FormSchema, userId: string) {
  const { state, dispatch, clearDraft } = usePersistentWizard(schema, userId);
  const currentStep = schema.steps[state.currentStepIndex];
  const currentErrors = state.stepErrors[state.currentStepIndex] ?? {};

  const handleChange = (fieldId: string, value: unknown) => {
    dispatch({ type: "SET_VALUE", fieldId, value });
  };

  const handleNext = () => {
    const errors = validateStep(currentStep, state.values);
    dispatch({ type: "NEXT_STEP", errors });
  };

  const handlePrev = () => {
    dispatch({ type: "PREV_STEP" });
  };

  const handleSubmit = async (onSubmit: (values: Record<string, unknown>) => Promise<void>) => {
    const errors = validateStep(currentStep, state.values);
    if (Object.keys(errors).length > 0) {
      dispatch({ type: "NEXT_STEP", errors });
      return;
    }
    await onSubmit(state.values);
    dispatch({ type: "COMPLETE" });
    clearDraft();
  };

  return {
    currentStep,
    currentStepIndex: state.currentStepIndex,
    totalSteps: schema.steps.length,
    values: state.values,
    errors: currentErrors,
    completed: state.completed,
    handleChange,
    handleNext,
    handlePrev,
    handleSubmit,
  };
}

The consumer of this hook gets a clean API: current step data, current errors, navigation handlers, and a submit handler. The rendering layer stays in the component tree; the state logic stays in the hook.

The Insight

A form engine is not a clever abstraction. It’s a boundary between things that change at different rates. Form structure changes when product requirements change. Form rendering changes when the design system changes. Validation rules change when business rules change. Keeping those concerns separate means changing one doesn’t force you to touch the others.

The schema-driven model pays for itself the third time you build the same form in a slightly different shape. At that point, you’re not building a form renderer, you’re building a platform, and the engine is what makes it possible.

More in Web Engineering

How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement
Web Engineering ·

How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement

A deep dive into Rspack's Rust-based architecture, module graph construction, SWC transformation pipeline, webpack compatibility layer, incremental compilation, and what the tradeoffs look like for teams migrating from webpack.

How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js
Web Engineering ·

How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js

A deep dive into Turbopack's architecture covering the Turbo engine's incremental computation model, function-level caching, Rust-based module resolution, granular HMR invalidation, SWC integration, persistent caching, and how it compares to webpack, Vite, esbuild, and Rspack.

How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format
Web Engineering ·

How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format

A deep dive into gRPC internals: Protocol Buffer IDL and the buf codegen pipeline, HTTP/2 stream multiplexing and HPACK header compression, all four RPC patterns with TypeScript, channel management, deadline propagation, interceptors, load balancing from pick-first to xDS, the health checking protocol, and production tradeoffs vs REST and GraphQL.

How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit
Web Engineering ·

How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit

A deep dive into Node.js internals covering V8 JIT compilation, the six-phase libuv event loop, thread pool mechanics, microtask queue priority, async/await desugaring, CommonJS versus ESM module resolution, and production tuning considerations with TypeScript examples.