useComputedFields
Keeps computed field values in sync with the fields they depend on. Any field whose config has a computed property gets its value recalculated automatically — once on mount, and again whenever one of its dependencies changes.
You probably don’t need this directly. It runs automatically inside
useFormState(and therefore inside<Form />). Call it yourself only when building a custom form renderer with your ownreact-hook-forminstance.
Signature
import { useComputedFields } from '@saastro/forms';
useComputedFields(methods, fields);
Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
methods | UseFormReturn<Record<string, unknown>> | Yes | react-hook-form instance from useForm() |
fields | Fields | Yes | Form field configurations from config.fields |
Return Value
This hook returns void. It operates as a side-effect only — writing computed values via methods.setValue().
The computed Field Prop
Any field can become computed by adding a computed config:
computed?: {
/** Field names this computation depends on */
dependsOn: string[];
/** Pure function that computes the value from all form values */
compute: (values: Record<string, unknown>) => unknown;
};
- The field is read-only while
computedis active — enforced by the renderer since 0.20.0; before that the prop was documented but not applied. computereceives all current form values, but recomputation is only triggered by changes to the fields listed independsOn. Ifcomputereads a field, list it independsOn.- Two shapes.
{ dependsOn, compute }is the function form — code-defined configs only, since a function cannot survive JSON.{ $expr }is the serializable form: it round-trips through the Hub and the server recomputes it (see below). FieldBuilder.computed(config)takes either shape. With a raw config, set the prop directly and merge via.addFields().
How It Works
- Collects all fields whose config has
computed— if there are none, it does nothing - On mount, runs an initial computation for every computed field (so
computemust tolerate default/empty values) - Subscribes to form changes via
methods.watch(); when the changed field is in adependsOnlist, recomputes the affected fields frommethods.getValues() - Writes each result with
setValue(name, value, { shouldDirty: false })— computed updates never mark the form dirty - Skips a dependency-triggered write when the new value is strictly equal (
===) to the current one — note that acomputereturning a fresh object or array every time will always write. The mount-time computation (step 2) always writes, so it overrides anydefaultValueon the computed field - Unsubscribes on unmount
Computed values live in form state like any other field value: they pass through validation, transforms, and the submitted payload normally.
Example: Declaring a Computed Field
Since the hook runs automatically inside <Form />, the typical “usage” is just the field config. FieldBuilder has no computed() method, so pass the computed field as a raw config through addFields():
import { FormBuilder } from '@saastro/forms';
import type { Fields } from '@saastro/forms';
// Raw config — the only way to declare a computed field
const computedFields: Fields = {
total: {
type: 'text',
label: 'Total (€)',
computed: {
dependsOn: ['quantity', 'unitPrice'],
compute: (values) =>
String((Number(values.quantity) || 0) * (Number(values.unitPrice) || 0)),
},
},
};
const config = FormBuilder.create('order')
.addField('quantity', (f) => f.type('number').label('Quantity').required())
.addField('unitPrice', (f) => f.type('number').label('Unit price (€)').required())
.addFields(computedFields)
.addStep('main', ['quantity', 'unitPrice', 'total'])
.build();
Native number inputs produce string values, so coerce with
Number(...)insidecompute(with a fallback for empty values — the initial computation runs on mount, before the user types anything).
Example: Custom Form Renderer
When driving react-hook-form yourself instead of using <Form />, wire the hook manually:
import { useComputedFields } from '@saastro/forms';
import type { FormConfig } from '@saastro/forms';
import { useForm } from 'react-hook-form';
function CustomFormRenderer({ config }: { config: FormConfig }) {
const methods = useForm<Record<string, unknown>>({ defaultValues: {} });
// Keeps computed field values in sync with their dependencies
useComputedFields(methods, config.fields);
const onSubmit = (values: Record<string, unknown>) => {
console.log(values); // includes the computed values
};
return (
<form onSubmit={methods.handleSubmit(onSubmit)}>
{/* Render fields... computed fields update as dependencies change */}
</form>
);
}
No provider is required — the hook takes methods explicitly.
Related
- Conditional Logic — Show, hide, or disable fields based on other values (vs. computing a value)
- Hidden Fields — Values resolved once at mount via JSON-serializable resolvers
- useFormState — Runs this hook automatically as part of the form engine
- FormBuilder —
addFields()for raw configs, and what keeps a config JSON-serializable
Serializable expressions ($expr) — and why the server recomputes
The function form cannot travel. A form hosted in the Hub is JSON, so a derived
value used to end up as a plain hidden field filled in by the browser —
which means anyone could POST precio_mes: 0.01 and be believed.
computed: { $expr: { $op: '+', args: [
5,
{ $if: { operator: 'AND', conditions: [{ field: 'conductor', operator: 'isTrue' }] },
then: 1.5, else: 0 },
] } }
Nodes: a bare number is the literal, $field reads another field, $op does
+ - * /, $round rounds, and $if branches. The $if test takes a normal
ConditionGroup — or a $cmp when you need to compare numbers.
Use $cmp, not greaterThan, against text fields
ConditionGroup’s numeric operators compare the raw value. Against a text
field holding "1.234,56" that is NaN > 0 → false, and your guard silently
never fires. $cmp runs both sides through the same coercion as the rest of the
expression:
{ $if: { $cmp: '>', left: { $field: 'prima' }, right: 0 }, then: /* … */, else: 0 }
Number coercion
Text fields arrive as strings. "1.234,56" and "1,234.56" both mean 1234.56;
a lone separator is always the decimal separator, so "1.234" is 1.234.
Anything that doesn’t coerce falls back to the node’s fallback (default 0).
Dividing by zero yields 0. evaluateExpression never returns NaN/Infinity.
Dependencies are derived, and chains resolve in one pass
You don’t declare dependsOn for $expr — it’s read off the expression’s
leaves, including the fields inside a ConditionGroup. Computed fields may
reference other computed fields; evaluation is topologically ordered, so
ahorro = mes + dto sees this pass’s mes, not the previous one’s.
The server wins
import { applyComputedFields, validateFormData } from '@saastro/forms/validation';
const raw = await request.json();
const data = applyComputedFields(form, raw); // server-authoritative
const result = validateFormData(form, data);
if (!result.success) return Response.json({ errors: result.errors }, { status: 422 });
await persist(data); // `data`, never `raw`
applyComputedFields is sync, never throws, doesn’t mutate its input, and lives
in the lean @saastro/forms/validation subpath so a Worker can import it without
pulling React. It is deliberately separate from validateFormData: validation
returns a verdict, recomputation returns a corrected payload — and a worker that
only observes validation without blocking would otherwise keep persisting the
original body while its logs said “valid”.