How do I derive values from what the user enters?
Read onValuesChange (React), @values-change or the handle's values (Vue) for anything computed from the form, such as a total. Use onFieldChange (React) or fieldChange (Vue) with setValue to fill one field from another until the user edits it. Requires @formhaus/react or @formhaus/vue 0.16.
Definition
An invoice with line items and a due date that follows the issue date by 14 days.
{
"$schema": "https://formhaus.dev/schema/form-definition.json",
"formatVersion": "1.1",
"id": "invoice",
"title": "New invoice",
"submit": { "label": "Create invoice" },
"fields": [
{ "key": "client", "type": "text", "label": "Client", "validation": { "required": true } },
{ "key": "issueDate", "type": "date", "label": "Issue date", "validation": { "required": true } },
{
"key": "dueDate",
"type": "date",
"label": "Due date",
"helperText": "14 days after the issue date until you change it",
"validation": { "required": true }
},
{
"key": "lines",
"type": "group",
"label": "Lines",
"itemLabel": "Line {index}",
"addLabel": "Add line",
"defaultItems": 1,
"validation": { "required": true },
"fields": [
{ "key": "description", "type": "text", "label": "Description", "validation": { "required": true } },
{ "key": "quantity", "type": "number", "label": "Quantity", "defaultValue": 1, "validation": { "required": true, "min": 1 } },
{ "key": "price", "type": "number", "label": "Unit price", "validation": { "required": true, "min": 0 } }
]
}
]
}React
import { useRef, useState } from 'react';
import { FormRenderer, type FormRendererHandle } from '@formhaus/react';
import { parseDefinition } from '@formhaus/core';
import invoice from './invoice.json';
const definition = parseDefinition(invoice);
type Line = { quantity?: number | null; price?: number | null };
function total(lines: unknown) {
return ((lines as Line[] | undefined) ?? []).reduce((sum, line) => sum + (line.quantity ?? 0) * (line.price ?? 0), 0);
}
function addDays(date: string, days: number) {
const next = new Date(`${date}T00:00:00Z`);
next.setUTCDate(next.getUTCDate() + days);
return next.toISOString().slice(0, 10);
}
export function InvoiceForm({ onDone }: { onDone: (values: Record<string, unknown>) => void }) {
const form = useRef<FormRendererHandle>(null);
const dueDateEdited = useRef(false);
const [amount, setAmount] = useState(0);
return (
<>
<FormRenderer
ref={form}
definition={definition}
onSubmit={onDone}
onValuesChange={(values) => setAmount(total(values.lines))}
onFieldChange={(key, value) => {
if (key === 'dueDate') dueDateEdited.current = true;
if (key === 'issueDate' && !dueDateEdited.current && typeof value === 'string' && value)
form.current?.setValue('dueDate', addDays(value, 14));
}}
/>
<p>Total: {amount.toFixed(2)}</p>
</>
);
}Vue
<script setup lang="ts">
import { computed, ref } from 'vue';
import { FormRenderer, type FormRendererHandle } from '@formhaus/vue';
import { parseDefinition } from '@formhaus/core';
import invoice from './invoice.json';
const definition = parseDefinition(invoice);
const emit = defineEmits<{ done: [values: Record<string, unknown>] }>();
const form = ref<FormRendererHandle>();
let dueDateEdited = false;
type Line = { quantity?: number | null; price?: number | null };
const amount = computed(() =>
((form.value?.values.lines as Line[] | undefined) ?? []).reduce((sum, line) => sum + (line.quantity ?? 0) * (line.price ?? 0), 0),
);
function addDays(date: string, days: number) {
const next = new Date(`${date}T00:00:00Z`);
next.setUTCDate(next.getUTCDate() + days);
return next.toISOString().slice(0, 10);
}
function onFieldChange(key: string, value: unknown) {
if (key === 'dueDate') dueDateEdited = true;
if (key === 'issueDate' && !dueDateEdited && typeof value === 'string' && value)
form.value?.setValue('dueDate', addDays(value, 14));
}
</script>
<template>
<FormRenderer ref="form" :definition="definition" @field-change="onFieldChange" @submit="(values) => emit('done', values)" />
<p>Total: {{ amount.toFixed(2) }}</p>
</template>@values-change="(values) => …" works too, the same way as onValuesChange in React.
Use a computed value later in the form
Write the computed value to a hidden field with setValue, then recall it in a text with {{total}} and branch on it with a gte condition.
A booking that adds two seat counts into total. The next step shows it with {{total}} and asks for a group request from 10 seats. Requires @formhaus/core 0.21 and formatVersion "1.5".
{
"$schema": "https://formhaus.dev/schema/form-definition.json",
"formatVersion": "1.5",
"id": "booking-total",
"title": "Book seats",
"submit": { "label": "Book" },
"steps": [
{
"id": "seats",
"title": "Seats",
"fields": [
{ "key": "adults", "type": "number", "label": "Adults", "defaultValue": 1, "validation": { "required": true, "min": 0 } },
{ "key": "children", "type": "number", "label": "Children", "defaultValue": 0, "validation": { "required": true, "min": 0 } },
{ "key": "total", "type": "hidden" }
]
},
{
"id": "summary",
"title": "Booking for {{total}}",
"description": "{{adults}} adults and {{children}} children.",
"fields": [
{
"key": "groupNote",
"type": "textarea",
"label": "Group request",
"show": [{ "field": "total", "gte": 10 }]
}
]
}
]
}A hidden field holds text, so write the sum as a string. gte compares it as a number.
React
import { useRef } from 'react';
import { FormRenderer, type FormRendererHandle } from '@formhaus/react';
import { parseDefinition } from '@formhaus/core';
import booking from './booking-total.json';
const definition = parseDefinition(booking);
export function BookingForm({ onDone }: { onDone: (values: Record<string, unknown>) => void }) {
const form = useRef<FormRendererHandle>(null);
return (
<FormRenderer
ref={form}
definition={definition}
onSubmit={onDone}
onValuesChange={(values) => {
const total = String(Number(values.adults ?? 0) + Number(values.children ?? 0));
if (values.total !== total) form.current?.setValue('total', total);
}}
/>
);
}Vue
<script setup lang="ts">
import { ref } from 'vue';
import { FormRenderer, type FormRendererHandle } from '@formhaus/vue';
import { parseDefinition } from '@formhaus/core';
import booking from './booking-total.json';
const definition = parseDefinition(booking);
const emit = defineEmits<{ done: [values: Record<string, unknown>] }>();
const form = ref<FormRendererHandle>();
function onValuesChange(values: Record<string, unknown>) {
const total = String(Number(values.adults ?? 0) + Number(values.children ?? 0));
if (values.total !== total) form.value?.setValue('total', total);
}
</script>
<template>
<FormRenderer ref="form" :definition="definition" @values-change="onValuesChange" @submit="(values) => emit('done', values)" />
</template>- The computation needs app code. A form published as JSON only, such as a hosted form, cannot compute values.
- Write only when the value differs, so the callback does not trigger itself.
- See Answer recall and Conditions.
How it works
onValuesChangeandvaluesChangefire once on mount with the initial values, then once for every value-changing operation: user edits,setValuefrom the handle, adding or removing group items, values cleared or restored by conditions, andreset. A change that clears several hidden fields fires once. Errors, loading and step navigation do not fire them, and neither does writing a value equal to the current one.- They may fire when the values end up unchanged: a write to a hidden field, a
resetto equal content, a skipped step and undoing that skip when the submit fails. - They fire again when a new
definitioncreates a new form. They never fire during server rendering. - The payload is a frozen shallow copy of the values. Group items are shared with the form, so treat them as read-only and change them with
setValue. - In Vue, the handle's
valuesis reactive: read it in acomputedor a template.useFormEngine().valuesis reactive the same way. onFieldChangefires for user edits only.setValuefrom the handle does not fire it, so the derived write never marks the due date as edited.- The first edit of
dueDatesetsdueDateEdited, and later issue dates leave the due date alone. setValueruns the same visibility and validation updates as typing. A value for a field hidden by its conditions is cleared at once. AsetValueinsideonValuesChangefires a nestedonValuesChangeright away, including on the first call at mount. The nested call gets the new values before the outer call returns.- Write a derived value only when it differs:
if (values.total !== next) form.setValue('total', next). An equal value fires nothing, so the chain stops. Two callbacks that keep changing each other's fields never stop. getValues()returns a shallow copy of the current values.- The handle and events work the same on
HeadlessFormRenderer. - To restore derived values with a draft, save
dueDateEditednext to the draft.
Related
- Getting started:
initialValuesand the renderer handle - Repeatable groups: line items as group values
- FormEngine:
subscribeValuesandgetValuesSnapshoton the engine