useComputedFields

Reactively derives field values from other fields — recomputes whenever a dependency changes.

View as Markdown

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 own react-hook-form instance.


Signature

import { useComputedFields } from '@saastro/forms';

useComputedFields(methods, fields);

Parameters

ParameterTypeRequiredDescription
methodsUseFormReturn<Record<string, unknown>>Yesreact-hook-form instance from useForm()
fieldsFieldsYesForm 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 computed is active — enforced by the renderer since 0.20.0; before that the prop was documented but not applied.
  • compute receives all current form values, but recomputation is only triggered by changes to the fields listed in dependsOn. If compute reads a field, list it in dependsOn.
  • 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

  1. Collects all fields whose config has computed — if there are none, it does nothing
  2. On mount, runs an initial computation for every computed field (so compute must tolerate default/empty values)
  3. Subscribes to form changes via methods.watch(); when the changed field is in a dependsOn list, recomputes the affected fields from methods.getValues()
  4. Writes each result with setValue(name, value, { shouldDirty: false }) — computed updates never mark the form dirty
  5. Skips a dependency-triggered write when the new value is strictly equal (===) to the current one — note that a compute returning a fresh object or array every time will always write. The mount-time computation (step 2) always writes, so it overrides any defaultValue on the computed field
  6. 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(...) inside compute (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.


  • 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
  • FormBuilderaddFields() 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 > 0false, 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”.