Hooks

useDynamicFormView

Backend-agnostic dynamic form renderer — the sibling of useDocyrusFormView with no Docyrus wiring. Supply fields, values, options, and an onSubmit sink to render create/edit/view forms against any backend.

Installation

pnpm dlx @docyrus/cli add @docyrus/hooks-use-dynamic-form-view
Required Packages(2 packages)
pnpm add @tanstack/react-query jsonata

This hook is distributed as source. It shares the same rendering engine as useDocyrusFormView but performs no fetching — you inject the schema, values, options, and submit handler yourself, so it works against any backend (or none).

Overview

useDynamicFormView is the backend-agnostic counterpart of useDocyrusFormView. Both drive the exact same internal engine, so this hook supports every rendering feature of useDocyrusFormView — it just drops the Docyrus-specific data layer:

  • local form state compatible with the Docyrus form-field components
  • shared field-component resolution via useDocyrusFieldComponent
  • layout helpers (renderField, renderLayout) including nested fieldset / tabpanel / tab sections
  • computed fields (computedHidden / computedRequired / computedLabel / computedDescription / computedFormula)
  • imperative field actions and form-level actions (onFormLoad / onFormBeforeSubmit / onFormAfterSubmit)
  • field-level and form-level validations — required, plus the minLength: / maxLength: / pattern: / min: / max: tokens when validationTokens is enabled, then JSONata customValidations, then form-level rules
  • companion columns / submit keys for composite fields (money, phone, status, avatar)
  • click-to-edit detail mode via EditableRecordDetail

What it does not do (and what you provide instead):

useDocyrusFormView does…In useDynamicFormView you supply…
fetches the data-source schema (getBySlug)fields: IField[]
loads the record (GET …/items/:id / collection.get)initialValues / values
loads enum / user / relation options (/v1/apps/enums, /v1/users, relation /items)enumOptions (static, per slug)
uploads files (POST …/files/upload)onImageUpload / onFileUpload
persists the record (POST / PATCH / collection)onSubmit

For a Docyrus-backed form, use useDocyrusFormView instead.

Usage

Create form (static options + onSubmit)

'use client';

import { useDynamicFormView } from '@docyrus/ui/library/hooks/use-dynamic-form-view';
import type { IField, EnumOption } from '@docyrus/ui/components/form-fields';
import { Button } from '@docyrus/ui/primitives/ui/button';

const fields: IField[] = [
  { id: '1', name: 'Full Name', slug: 'full_name', type: 'field-text', validations: ['required'] },
  { id: '2', name: 'Email', slug: 'email', type: 'field-email', validations: ['required'] },
  { id: '3', name: 'Status', slug: 'status', type: 'field-select' },
  { id: '4', name: 'Notes', slug: 'notes', type: 'field-textarea' }
];

const enumOptions: Record<string, EnumOption[]> = {
  status: [
    { id: 'lead', name: 'Lead', color: 'sky-500' },
    { id: 'active', name: 'Active', color: 'emerald-500' }
  ]
};

export function CreateContactForm() {
  const form = useDynamicFormView({
    fields,
    mode: 'create',
    gridColumns: 2,
    enumOptions,
    fieldOrder: ['full_name', 'email', 'status', 'notes'],
    fieldLayout: { notes: { colSpan: 'full' } },
    onSubmit: async (payload) => {
      // Persist to any backend of your choice.
      await fetch('/api/contacts', { method: 'POST', body: JSON.stringify(payload) });
    }
  });

  return (
    <form
      onSubmit={async (event) => {
        event.preventDefault();
        await form.submit();
      }}
      className="space-y-4">
      {form.renderLayout()}

      <div className="flex items-center gap-2">
        <Button type="submit" disabled={form.isSubmitting}>Create Contact</Button>
        <Button type="button" variant="outline" onClick={form.reset}>Reset</Button>
      </div>
    </form>
  );
}

Edit form (uncontrolled seed)

Pass initialValues to seed an edit form. Values are owned internally; read the result via form.values or the onSubmit payload.

const form = useDynamicFormView({
  fields,
  mode: 'edit',
  initialValues: record,          // seeds the form once (and re-seeds if its content changes)
  enumOptions,
  onSubmit: (payload) => saveContact(record.id, payload)
});

Controlled values

Provide values to fully control the form from the outside. The form re-seeds whenever the object's content changes, and onValuesChange fires on every field edit.

const [values, setValues] = useState(record);

const form = useDynamicFormView({
  fields,
  mode: 'edit',
  values,                         // controlled
  onValuesChange: setValues,      // fires on every field change
  enumOptions
});

Read-only view + click-to-edit

const form = useDynamicFormView({
  fields,
  mode: 'view',
  initialValues: record,
  clickToEdit: true,              // rows become inline-editable on click
  enumOptions,
  onSubmit: (payload) => saveContact(record.id, payload)
});

return form.renderLayout();

Nested layout, computed fields, actions & validations

Everything from useDocyrusFormView works identically — layout, fieldLayout (including computedHidden / computedFormula / fieldActions), formActions, and formCustomValidations.

const form = useDynamicFormView({
  fields,
  mode: 'create',
  gridColumns: 4,
  layout: [
    { id: 'identity', variant: 'fieldset', title: 'Identity', colSpan: 2, columns: 1, collapsible: true, items: ['full_name', 'email'] }
  ],
  fieldLayout: {
    vat_number: { computedHidden: 'is_company != true' },
    total_price: { computedFormula: '$number(qty) * $number(unit_price)', readOnly: true }
  },
  formCustomValidations: [
    { id: 'v1', expression: 'close_date >= open_date', message: 'Close date cannot precede the open date.' }
  ]
});

Render form.formValidationErrors as a destructive banner above the form; per-field errors live in form.validationErrors (keyed by slug). See the useDocyrusFormView docs for the full computed-field, field-action, form-action, and validation reference — the behavior is shared.

Uploads

Wire onImageUpload / onFileUpload to persist a picked File to your storage and return a stored-value object; the hook injects them into field-image / field-file fields.

const form = useDynamicFormView({
  fields,
  onImageUpload: async (file) => uploadToStorage(file),   // → StoredFileValue | null
  onFileUpload: async (file) => uploadToStorage(file)
});

Dynamic option loading (per field)

enumOptions is static. For typeahead / lazy loading on a single select or relation field, hand-wire that field's loader through fieldLayout[slug].fieldProps — the engine threads onSearch / searching / onLoadMore / hasMore / onExpand straight to the field component.

fieldLayout={{
  owner: {
    fieldProps: {
      onSearch: (term) => loadUsers(term),
      searching: isLoading,
      onLoadMore: () => loadNextPage(),
      hasMore
    }
  }
}}

API Reference

Parameters

OptionTypeDefaultDescription
fieldsIField[]—Required. Field schema, already normalized to IField. Replaces the Docyrus schema fetch.
mode'create' | 'edit' | 'view''create' (no values) / 'edit' (with values)Explicit form mode.
initialValuesRecord<string, unknown>—Uncontrolled seed, merged over field defaults. Re-seeds when its content changes.
valuesRecord<string, unknown>—Controlled values. When set, the form is controlled and re-seeds on content change.
onValuesChange(values) => void—Fires on every field change (both controlled and uncontrolled).
disabledbooleanfalseDisable editable fields globally.
clickToEditbooleanfalseIn view mode, route renderLayout() through EditableRecordDetail for inline editing.
includeReadOnlyFieldsbooleanmode !== 'create'Include fields that resolve to read-only display rows.
validationTokens'off' | 'form' | 'all''off'Enforce validations token constraints beyond required on submit. 'form' enforces only fieldLayout[slug].validations; 'all' also enforces IField.validations. required is always enforced.
unsupportedFieldBehavior'skip' | 'value''skip' in create, 'value' otherwiseWhether unsupported field types disappear or fall back to value-render mode.
gridColumns1 | 2 | 3 | 42Default responsive column count used by renderLayout().
labelAlign'top' | 'left''top'Form-level label placement. 'left' renders a horizontal label column.
labelWidth'sm' | 'md' | 'lg''md'Width of the label column. Only meaningful with labelAlign: 'left'.
fieldSize'sm' | 'md' | 'lg''md'Field density applied to every input.
fieldVariant'outline' | 'filled''outline'Input style applied to every input.
fieldSlugsstring[]—Whitelist fields by slug.
fieldOrderstring[]—Explicit field ordering.
hiddenFieldSlugsstring[]—Hard-hide fields by slug.
fieldLayoutRecord<string, DynamicFormViewFieldLayout>—Per-field UI overrides, computed props, field actions, validation tokens (validations), and custom validations. Same shape as DocyrusFormViewFieldLayout.
layoutDynamicFormViewLayoutItem[]—Nested layout tree (fieldset / tabpanel / tab). A fieldset accepts columns (own inner grid), collapsible and defaultCollapsed in addition to id / title / description / colSpan / items.
enumOptionsRecord<string, EnumOption[]>—Static option lists keyed by field slug. Absent slugs fall back to the field's inline enums / options.
onImageUpload(file) => Promise<StoredFileValue | null>—Upload handler for field-image.
onFileUpload(file) => Promise<StoredFileValue | null>—Upload handler for field-file.
onSubmit(payload, context) => Promise<unknown> | unknown—Persistence handler. When omitted, submit() just returns the built payload.
transformSubmit(payload, context) => payload—Final payload transform before onSubmit.
onSubmitSuccess(result, payload) => void—Called after a successful submit.
onSubmitError(error, payload) => void—Called after a failed submit.
formActionsFormAction[] | null—Form-level lifecycle actions.
formCustomValidationsFormCustomValidationRule[] | null—Form-level validation rules evaluated on submit.
clientRestApiClient—Optional passthrough forwarded to field / value components (e.g. inline email compose). Never used for fetching.

Return Value

PropertyTypeDescription
modeDynamicFormViewModeResolved mode.
itemRecord<string, unknown>Current values (alias of values).
form{ Field(...) }Internal form object compatible with the Docyrus form-field components.
valuesRecord<string, unknown>Current live values snapshot.
defaultValuesRecord<string, unknown>Resolved initial values after schema defaults + seed merge.
fieldsDynamicFormViewField[]Resolved visible field descriptors.
allFieldsDynamicFormViewField[]Same resolved field list as fields.
unsupportedFieldsDynamicFormViewField[]Fields shown as value-render fallbacks.
validationErrorsMap<string, string>Per-field validation errors keyed by slug.
formValidationErrorsstring[]Form-level validation messages from formCustomValidations.
isDirtybooleanWhether current values differ from the committed baseline.
isLoadingbooleanAlways false — this hook does no fetching.
isSubmittingbooleantrue while submit() is running.
errorError | nullLast submit error, if any.
setValue(slug, value) => voidImperatively update one field value.
validate() => Promise<boolean>Run validation without submitting.
reset() => voidReset values to the committed baseline.
submit() => Promise<unknown>Validate and run the submit pipeline.
resetActionOverrides() => voidClear accumulated field-action / form-action property overrides.
renderField(slug, options?) => ReactNodeRender a single resolved field by slug.
renderLayout(options?) => ReactNodeRender the visible field list using the responsive grid / layout tree.

Type Exports

TypeDescription
UseDynamicFormViewOptionsOptions for the hook.
UseDynamicFormViewResultReturn value of the hook.
DynamicFormViewMode'create' | 'edit' | 'view'.
DynamicFormViewFieldResolved per-field descriptor.
DynamicFormViewFieldLayoutPer-field override shape (same as DocyrusFormViewFieldLayout).
DynamicFormViewLayoutItemLayout tree node (fieldset / tabpanel / tab / field).
DynamicFormViewSubmitContextContext passed to onSubmit / transformSubmit.

Out of scope

  • data fetching, option loading, uploads, and persistence — you provide these
  • for a batteries-included Docyrus-backed form, use useDocyrusFormView

On this page