How do I use Formhaus with Svelte 5?
There is no Svelte adapter. A small runes component renders FormEngine from @formhaus/core.
bash
npm install @formhaus/coreDefinition
json
{
"$schema": "https://formhaus.dev/schema/form-definition.json",
"id": "project-inquiry",
"title": "Start a project",
"submit": { "label": "Send inquiry" },
"steps": [
{
"id": "service",
"title": "What do you need?",
"fields": [
{
"key": "service",
"type": "radio",
"label": "Service",
"validation": { "required": true },
"options": [
{ "value": "design", "label": "Design" },
{ "value": "development", "label": "Development" }
]
}
],
"routes": [
{ "to": "design", "show": [{ "field": "service", "eq": "design" }] },
{ "to": "development", "show": [{ "field": "service", "eq": "development" }] }
]
},
{
"id": "design",
"title": "Design scope",
"fields": [
{ "key": "pages", "type": "number", "label": "Number of screens", "validation": { "required": true, "min": 1 } },
{ "key": "hasBrand", "type": "switch", "label": "We have brand guidelines" }
],
"routes": [{ "to": "budget" }]
},
{
"id": "development",
"title": "Development scope",
"fields": [
{
"key": "platform",
"type": "select",
"label": "Platform",
"validation": { "required": true },
"options": [
{ "value": "web", "label": "Web" },
{ "value": "ios", "label": "iOS" },
{ "value": "android", "label": "Android" }
]
},
{ "key": "repoUrl", "type": "text", "label": "Existing repository URL" }
],
"routes": [{ "to": "budget" }]
},
{
"id": "budget",
"title": "Budget",
"skip": { "label": "Not sure yet" },
"fields": [
{
"key": "budget",
"type": "radio",
"label": "Budget range",
"validation": { "required": true },
"options": [
{ "value": "small", "label": "Under $10k" },
{ "value": "medium", "label": "$10k to $50k" },
{ "value": "large", "label": "Over $50k" }
]
}
]
},
{
"id": "contact",
"title": "Contact details",
"back": { "label": "Edit answers" },
"fields": [
{ "key": "name", "type": "text", "label": "Name", "validation": { "required": true } },
{ "key": "email", "type": "email", "label": "Work email", "validation": { "required": true } }
]
}
]
}Component
FormhausForm.svelte bumps a $state counter on every engine change, and $derived reads the engine again:
svelte
<script lang="ts">
import { FormEngine, type FormDefinition, type SubmitFn } from '@formhaus/core';
let { definition, onsubmit }: { definition: FormDefinition; onsubmit: SubmitFn } = $props();
const engine = $derived(new FormEngine(definition));
let version = $state(0);
$effect(() => engine.subscribe(() => version++));
const view = $derived.by(() => {
version;
return {
step: engine.currentStep,
fields: engine.visibleFields,
values: { ...engine.values },
errors: { ...engine.errors },
busy: engine.stepValidating || engine.submitting,
first: engine.isFirstStep,
last: engine.isLastStep,
};
});
const value = (key: string) => (view.values[key] ?? '') as string;
function primary(event: SubmitEvent) {
event.preventDefault();
if (view.last) engine.submit(onsubmit);
else engine.next();
}
</script>
<form onsubmit={primary} novalidate>
{#if view.step}<h2>{view.step.title}</h2>{/if}
{#each view.fields as field (field.key)}
<div>
{#if field.type === 'radio'}
<fieldset>
<legend>{field.label}</legend>
{#each field.options ?? [] as option}
<label>
<input type="radio" name={field.key} value={option.value} checked={view.values[field.key] === option.value}
onchange={() => engine.setValue(field.key, option.value)} />
{option.label}
</label>
{/each}
</fieldset>
{:else if field.type === 'checkbox' || field.type === 'switch'}
<label>
<input type="checkbox" role={field.type === 'switch' ? 'switch' : undefined} checked={view.values[field.key] === true}
onchange={(e) => engine.setValue(field.key, e.currentTarget.checked)} />
{field.label}
</label>
{:else}
<label for={field.key}>{field.label}</label>
{#if field.type === 'select'}
<select id={field.key} value={value(field.key)} onchange={(e) => engine.setValue(field.key, e.currentTarget.value)}>
<option value="" disabled>{field.placeholder ?? 'Select…'}</option>
{#each field.options ?? [] as option}<option value={option.value}>{option.label}</option>{/each}
</select>
{:else if field.type === 'textarea'}
<textarea id={field.key} value={value(field.key)} oninput={(e) => engine.setValue(field.key, e.currentTarget.value)}></textarea>
{:else if field.type === 'number'}
<input id={field.key} type="number" value={value(field.key)}
oninput={(e) => engine.setValue(field.key, e.currentTarget.value === '' ? '' : e.currentTarget.valueAsNumber)} />
{:else}
<input id={field.key} type={field.type === 'email' ? 'email' : 'text'} value={value(field.key)}
oninput={(e) => engine.setValue(field.key, e.currentTarget.value)} />
{/if}
{/if}
{#if view.errors[field.key]}<p role="alert">{view.errors[field.key]}</p>{/if}
</div>
{/each}
{#if !view.first && view.step?.back !== false}
<button type="button" disabled={view.busy} onclick={() => engine.back()}>{view.step?.back?.label ?? 'Back'}</button>
{/if}
{#if view.step?.skip}<button type="button" disabled={view.busy} onclick={() => engine.skip(onsubmit)}>{view.step.skip.label}</button>{/if}
<button type="submit" disabled={view.busy}>{view.last ? definition.submit.label : 'Continue'}</button>
</form>Usage
svelte
<script lang="ts">
import { parseDefinition } from '@formhaus/core';
import FormhausForm from './FormhausForm.svelte';
import json from './project-inquiry.json';
const definition = parseDefinition(json);
let sent = $state<Record<string, unknown> | null>(null);
async function submit(values: Record<string, unknown>, skippedSteps?: string[]) {
await fetch('/api/inquiry', { method: 'POST', body: JSON.stringify({ values, skippedSteps }) });
sent = values;
}
</script>
{#if sent}
<pre>{JSON.stringify(sent, null, 2)}</pre>
{:else}
<FormhausForm {definition} onsubmit={submit} />
{/if}How it works
engine.subscribe()returns the unsubscribe function, so$effectcleans it up.next()validates the current step and followsroutes.submit(onsubmit)validates every visible step and callsonsubmit(values, skippedSteps).skip(onsubmit)skips a step withskip, and submits when it is the last step.stepValidatingandsubmittingaretruewhile navigation or the submit handler runs; the buttons are disabled then.numberfields store a number, or''when empty. Checkbox and switch store booleans.- Add
multiselect,fileand custom types the same way: callengine.setValue(field.key, value)with the value shape from Field Types.
Related
- Headless engine: the same engine in plain JavaScript
- FormEngine reference: every property and method
- Multi-step with branching: the same definition in React and Vue