useFormState

Main orchestrator hook — builds Zod schemas, sets up React Hook Form, coordinates plugins, steps, and submission.

useFormState

The central hook that wires everything together. It builds the Zod schema from your field configs, initializes React Hook Form, runs plugin lifecycle hooks, and coordinates step navigation with submission.

You probably don’t need this directly. The <Form /> component calls useFormState internally. Use it only when building a fully custom form UI from scratch.


Signature

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

const {
  config: resolvedConfig, // render from this, not your input config
  currentStepId,
  loading,
  submitted,
  error,
  methods,
  nextStep,
  prevStep,
  handleSubmit,
  validateAndNext,
  isLast,
  getSuccessMessage,
  getErrorMessage,
  resetError,
  stepHistory,
} = useFormState({ config, fields: config.fields, steps: config.steps });

Parameters

ParameterTypeRequiredDescription
configFormConfigYesThe form configuration (from FormBuilder.build()). Fields, steps, plugins, and submit config are all read here
fieldsFieldsYesAccepted by the signature but currently ignored — the hook reads config.fields. Pass config.fields
stepsStepsNoAlso currently ignored — the hook reads config.steps. Pass config.steps

The fields and steps parameters exist for the type signature but their values are never consumed. Everything is read from config after plugin transforms. If config.steps is missing or empty, a single synthetic default step containing all fields is created.


Return Value

PropertyTypeDescription
configFormConfigThe plugin-transformed and locale-resolved config. Always render fields, steps, labels, and buttons from this — not your input
currentStepIdstringActive step ID
loadingbooleantrue while submitting
submittedbooleantrue after successful submission
errorError | nullLast submission error
methodsUseFormReturnReact Hook Form instance (register, control, watch, etc.)
nextStep(values) => booleanRaw advance to the next step — runs no validation and fires no onStepChange callbacks. Prefer validateAndNext
prevStep() => booleanNavigate to the previous step (fires plugin and config onStepChange callbacks on success)
handleSubmit(data) => Promise<void>Submit handler (runs plugin hooks + submit actions)
validateAndNext(e?) => Promise<void>Validate current step, then navigate — or submit on the last step. This is the supported navigation path
isLastbooleanWhether the current step is the last one
getSuccessMessage() => stringResolve the success message (default: '✅ Thank you!' — override via successMessage or i18n)
getErrorMessage() => stringResolve the error message
resetError() => voidClear the error state
stepHistorystring[]Stack of visited step IDs

Render from the returned config. It is your input config after running plugin transformConfig() and applying the locale/i18n overlay. Reading labels, placeholders, options, or steps from the config you passed in silently drops plugin transforms and translations.


What It Does Internally

  1. Config resolution — If a pluginManager exists, runs transformConfig(); then, if config.locale is set, applies the i18n overlay (see Internationalization). The result is the returned config
  2. Schema compilation — For each field, converts ValidationRules or raw Zod schemas into a unified z.object(). Chains any customValidators as superRefine calls — but only when the field has an explicit schema and a plugin registers the validator; otherwise they are skipped. Fields with required: true and no explicit schema get a presence-only schema (also exported as buildRequiredOnlySchema)
  3. Default values — Uses field.value when set; otherwise per-type defaults: false for checkbox/switch, [] for checkbox-group/switch-group/button-checkbox, { from: '', to: '' } for daterange, [] for repeater, and '' for everything else. Exceptions: file always defaults to null and hidden to ''field.value is ignored for these two (hidden values come from resolvers)
  4. React Hook Form — Creates its own useForm instance with zodResolver(schema) — no provider needed
  5. Hidden + computed fields — Runs useHiddenFieldResolvers and useComputedFields automatically
  6. Plugin lifecycle — Fires onFormInit on mount, subscribes onFieldChange via watch()
  7. validateAndNext — Validates the current step’s fields (focusing the first invalid one), then advances with conditional step routing and fires onStepChange hooks. On the last step, it submits instead

Throws at render time if the configured initialStep doesn’t exist, if no steps can be resolved, or if a step references a field name missing from config.fields.


Example: Custom Form UI

import { useFormState } from '@saastro/forms';
import { FormProvider } from 'react-hook-form';

function CustomForm({ config }) {
  const {
    config: resolvedConfig,
    methods,
    currentStepId,
    loading,
    submitted,
    error,
    validateAndNext,
    prevStep,
    isLast,
    stepHistory,
    getSuccessMessage,
  } = useFormState({
    config,
    fields: config.fields,
    steps: config.steps,
  });

  if (submitted) return <p>{getSuccessMessage()}</p>;

  // Render from the RETURNED config so plugin transforms and
  // i18n overlays reach the UI. Fall back to all fields when
  // the config defines no steps (a synthetic step is used).
  const stepFields =
    resolvedConfig.steps?.[currentStepId]?.fields ??
    Object.keys(resolvedConfig.fields);

  return (
    <FormProvider {...methods}>
      <form
        onSubmit={(e) => {
          e.preventDefault();
          validateAndNext();
        }}
      >
        <p>Step: {currentStepId}</p>

        {stepFields.map((name) => (
          <input
            key={name}
            {...methods.register(name)}
            placeholder={resolvedConfig.fields[name]?.label ?? name}
          />
        ))}

        {stepHistory.length > 0 && (
          <button type="button" onClick={prevStep}>
            Back
          </button>
        )}
        <button type="submit" disabled={loading}>
          {isLast ? 'Submit' : 'Next'}
        </button>

        {error && <p>{error.message}</p>}
      </form>
    </FormProvider>
  );
}

The form’s onSubmit calls validateAndNext() instead of methods.handleSubmit(...): RHF’s handleSubmit validates the entire schema, which would block intermediate steps whose later fields are still empty. validateAndNext validates only the current step’s fields and submits automatically on the last step.


  • Submitting Forms — The submit pipeline handleSubmit runs (transforms, plugins, submit actions)
  • Multi-Step Forms — Step definitions and conditional routing
  • Validation — Schemas, validation rules, and per-step validation behavior
  • useFormStepsInfo — Step progress/status info (requires the FormProvider shown above)