Skip to content

How do I use the form engine without React or Vue? ​

Install @formhaus/core and create a FormEngine from the JSON definition. The engine has no dependencies and no UI: it holds values and errors, evaluates show conditions, validates, and moves between steps. Your code renders engine.visibleFields, calls engine.setValue() on input, and re-renders when the engine notifies a subscriber. The same definition works with the React and Vue renderers.

bash
npm install @formhaus/core

Definition ​

json
{
  "$schema": "https://formhaus.dev/schema/form-definition.json",
  "id": "newsletter",
  "title": "Newsletter",
  "submit": { "label": "Subscribe" },
  "fields": [
    { "key": "email", "type": "email", "label": "Email", "validation": { "required": true } },
    {
      "key": "frequency",
      "type": "select",
      "label": "How often",
      "defaultValue": "weekly",
      "options": [
        { "value": "weekly", "label": "Weekly" },
        { "value": "monthly", "label": "Monthly" }
      ]
    },
    { "key": "consent", "type": "checkbox", "label": "I agree to receive emails", "validation": { "required": "Please confirm to subscribe" } }
  ]
}

Vanilla JavaScript ​

html
<form id="newsletter" novalidate></form>
<script type="module" src="./main.js"></script>
js
import { FormEngine } from '@formhaus/core';
import definition from './newsletter.json';

const engine = new FormEngine(definition);
const form = document.getElementById('newsletter');
const errorNodes = new Map();

function control(field) {
  if (field.type === 'select') {
    const select = document.createElement('select');
    for (const option of field.options) select.add(new Option(option.label, option.value));
    select.value = engine.values[field.key] ?? '';
    select.onchange = () => engine.setValue(field.key, select.value);
    return select;
  }
  const input = document.createElement('input');
  input.type = field.type;
  if (field.type === 'checkbox') {
    input.checked = engine.values[field.key] === true;
    input.onchange = () => engine.setValue(field.key, input.checked);
  } else {
    input.value = engine.values[field.key] ?? '';
    input.oninput = () => engine.setValue(field.key, input.value);
  }
  return input;
}

function row(field) {
  const label = document.createElement('label');
  const error = document.createElement('small');
  errorNodes.set(field.key, error);
  label.append(field.label, control(field), error);
  return label;
}

function showErrors() {
  for (const [key, node] of errorNodes) node.textContent = engine.errors[key] ?? '';
}

function render() {
  errorNodes.clear();
  const submit = document.createElement('button');
  submit.textContent = definition.submit.label;
  form.replaceChildren(...engine.visibleFields.map(row), submit);
  showErrors();
}

engine.subscribeStructure(render);
engine.subscribe(showErrors);
render();

form.addEventListener('submit', async (event) => {
  event.preventDefault();
  await engine.submitAsync(async (values) => {
    await fetch('/api/subscribe', { method: 'POST', body: JSON.stringify(values) });
  });
});

Multi-step forms ​

For a definition with steps, render engine.currentStep and call the navigation methods from your buttons:

js
const engine = new FormEngine(definition, {}, {
  onAfterStepChange: ({ toStepId }) => console.log('step', toStepId),
});

await engine.nextStepAsync();
await engine.prevStepAsync();
await engine.skipStepAsync();

const { current, total } = engine.progress;
const label = engine.isLastStep ? definition.submit.label : 'Continue';

How it works ​

  • new FormEngine(definition, initialValues, options) builds the state. options takes validators, onStepValidate and the before and after lifecycle hooks.
  • subscribeStructure() fires when visible fields, visible steps or the current step change. Rebuilding the DOM only then keeps focus in text inputs while the user types.
  • subscribe() fires on every change, including errors. showErrors() updates the error text in place.
  • setValue() clears the field's error and re-evaluates show conditions, removing values of fields that become hidden.
  • submitAsync(handler) validates the visible fields, runs onBeforeSubmit, awaits the handler, then runs onAfterSubmit. It resolves to false and fills engine.errors when validation fails.
  • getSubmitValues() returns the visible values on the active path, the same payload the handler receives.
  • subscribeField(key, listener) and the snapshot methods let a renderer update one field at a time.

Svelte ​

The Svelte example renders a definition with @formhaus/core and no adapter. Open it in the playground to edit the definition and see the result.