useComputedFields

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

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 — its value is owned by the computation, not by user input.
  • 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.
  • Not JSON-serializable. compute is a function, so a config containing computed fields cannot be stored as JSON. If you need a fully serializable config, derive the value server-side after submission, or — for values resolved once at mount — use hidden fields with their serializable resolvers.
  • No FieldBuilder method. FieldBuilder has no .computed() — set the prop on a raw field config. With FormBuilder, merge raw configs via .addFields() (see the example below).

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