useDynamicFormView
Backend-agnostic form engine for React Native. Renders an IField schema with sections, tabs, collapsible panels, computed fields, field and form actions, validation tokens and a submit payload builder, with no network I/O.
Installation
pnpm dlx @docyrus/cli add @docyrus/rn-hooks-use-dynamic-form-viewpnpm add jsonata @react-querybuilder/coreThe native port of the web hook with the same signature. It drives the shared form-view engine (the same one behind useDocyrusFormView) but fetches nothing: you pass the IField[] schema, the values, static option lists, upload handlers and an onSubmit sink. Fields render through the native DocyFieldDynamic controls; view mode renders the value renderers, or EditableRecordDetail with clickToEdit.
Usage
import { ScrollView } from 'react-native';
import { Alert } from '@/components/docyrus-native/alert';
import { Button } from '@/components/docyrus-native/button';
import { type IField } from '@/components/docyrus-native/form-fields';
import { useDynamicFormView } from '@/hooks/docyrus-native/use-dynamic-form-view';
const fields: IField[] = [
{ id: '1', name: 'Title', slug: 'title', type: 'field-text', validations: ['required', 'minLength:3'] },
{ id: '2', name: 'Quantity', slug: 'quantity', type: 'field-number' },
{ id: '3', name: 'Unit price', slug: 'unit_price', type: 'field-money' },
{ id: '4', name: 'Total', slug: 'total', type: 'field-number', readOnly: true }
];
export function OrderForm() {
const view = useDynamicFormView({
fields,
validationTokens: 'all',
layout: [
{ id: 'main', variant: 'fieldset', title: 'Order', items: [{ type: 'field', slug: 'title', colSpan: 'full' }, 'quantity', 'unit_price', 'total'] }
],
fieldLayout: {
total: { computedFormula: '$number(quantity) * $number(unit_price)' }
},
onSubmit: payload => saveOrder(payload)
});
return (
<ScrollView contentContainerClassName="gap-4 p-4">
{view.formValidationErrors.length > 0 && (
<Alert variant="destructive" title="Please fix the form" description={view.formValidationErrors.join('\n')} />
)}
{view.renderLayout()}
<Button onPress={() => view.submit()} loading={view.isSubmitting}>Save</Button>
</ScrollView>
);
}Uploads
onImageUpload / onFileUpload receive the picked asset as a NativeFile ({ uri, name, type, size? } from expo-image-picker / expo-document-picker) instead of a DOM File. Upload it and resolve to a StoredFileValue:
onImageUpload: async (file) => {
const formData = new FormData();
formData.append('file', { uri: file.uri, name: file.name, type: file.type } as never);
return uploadToStorage(formData); // → { file_name, source, signed_url, file_type, file_size }
}Per-field async option loaders are wired through fieldLayout[slug].fieldProps (onSearch / searching / onLoadMore / hasMore / onExpand).
Layout
layout is a tree of field slugs, { type: 'field', slug, colSpan } items and sections:
| Section | Native rendering | Keys |
|---|---|---|
fieldset | Bordered panel; with collapsible the title toggles a native Collapsible | id, title, description, items, columns, collapsible, defaultCollapsed, colSpan, className, contentClassName |
tabpanel | Bordered panel with scrollable underline Tabs | id, title, description, items: tab[], defaultTabId, colSpan |
tab | One tab page (inside a tabpanel) | id, title, description, items, className, contentClassName |
Fields not placed in the layout are appended after it. The grid is a flex-wrap grid: phones render one column, tablets honor gridColumns / columns (capped at 2 below 900pt) and colSpan (1–4 or 'full'). Sections span the full row unless they carry a colSpan.
Form-level styling
labelAlign / labelWidth / fieldSize / fieldVariant are resolved into field props (getFormLayoutFieldProps in form-fields/lib/form-layout) instead of the web descendant CSS selectors (Uniwind has none): labelAlign: 'left' → labelAlignment: 'left' + a fixed label column (sm w-24 / md w-40 / lg w-56), fieldSize → field size, fieldVariant: 'outline' | 'filled' → 'outlined' | 'filled'. Explicit fieldLayout[slug].fieldProps win.
Computed fields, actions and validation
- Computed (
fieldLayout[slug]orIField):computedHidden/computedRequired(JSONata string or query-builder JSON),computedLabel/computedDescription(JSONata → string),computedFormula(JSONata → value written back into the field). Expressions evaluate against the live values (quantity * unit_price). - Field actions (
fieldActions, triggeronFieldChange): blocks of IF / ELSE IF (conditionalItems) / ELSE (elseActions) / ALWAYS (unconditionalActions) with the 8 step methodssetFieldValue,setFieldValues,clearFieldValue,showField,hideField,setFieldRequired,setFieldDisabled,setFieldReadOnly. - Form actions (
formActions): the same blocks ononFormLoad,onFormBeforeSubmitandonFormAfterSubmit($resultbound). - Priority: computed formulas > field-action overrides > form-action overrides >
fieldLayoutcallbacks >IFielddefaults. - Validation order per field:
required(always) → tokens (validationTokens:'off'advisory,'form'onlyfieldLayout.validations,'all'also the field's own tokens; resolved list onfield.enforcedValidations, also used for the fields' live hints) → JSONatacustomValidations(value+valuesbound). Form-levelformCustomValidationsrun once every field passes. - Payload: read-only and disabled fields are excluded; companion columns (
__<slug>_currency,__<slug>_country, status sub-fields, avatar mapping) are submitted with their field.
API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
fields | IField[] | required | Field schema (already normalized to IField) |
mode | 'create' | 'edit' | 'view' | 'create' (no values) / 'edit' (with values) | Form mode |
initialValues | Record<string, unknown> | — | Uncontrolled seed — merged over field defaults; re-seeds when its content changes |
values | Record<string, unknown> | — | Controlled values — the form re-seeds whenever its content changes |
onValuesChange | (values: Record<string, unknown>) => void | — | Fires on every field change (controlled and uncontrolled) |
disabled | boolean | false | Disable every field |
clickToEdit | boolean | false | In view mode, render through EditableRecordDetail (inline edit + save) |
includeReadOnlyFields | boolean | mode !== 'create' | Render read-only fields as values |
unsupportedFieldBehavior | 'skip' | 'value' | create ? 'skip' : 'value' | Field types without a native editor: skip or render as a value |
gridColumns | 1 | 2 | 3 | 4 | 2 | Form grid columns (phones render 1 column; tablets up to 2 below 900pt) |
validationTokens | 'off' | 'form' | 'all' | 'off' | Enforce minLength / maxLength / pattern / min / max tokens on submit (required is always enforced) |
labelAlign | 'top' | 'left' | 'top' | Form-level label placement |
labelWidth | 'sm' | 'md' | 'lg' | 'md' | Label column width when labelAlign is left |
fieldSize | 'sm' | 'md' | 'lg' | — | Form-level field density |
fieldVariant | 'outline' | 'filled' | — | Form-level input style |
fieldSlugs | string[] | — | Whitelist of field slugs to render |
fieldOrder | string[] | — | Explicit field order (unlisted fields sort by name) |
hiddenFieldSlugs | string[] | — | Field slugs to hide |
fieldLayout | Record<string, DocyrusFormViewFieldLayout> | — | Per-field overrides: hidden / required fns, readOnly, disabled, colSpan, label, description, fieldProps, valueProps, computed*, fieldActions, customValidations, validations |
layout | DocyrusFormViewLayoutItem[] | — | Section tree: fieldset (columns, collapsible, defaultCollapsed) / tabpanel / tab, field items with colSpan |
enumOptions | Record<string, EnumOption[]> | — | Option lists keyed by field slug (overrides inline enums) |
onImageUpload | (file: NativeFile) => Promise<StoredFileValue | null> | — | Image field upload handler (asset from expo-image-picker) |
onFileUpload | (file: NativeFile) => Promise<StoredFileValue | null> | — | File field upload handler (asset from expo-document-picker) |
onSubmit | (payload, context) => unknown | — | Persistence handler — omitted: submit() returns the built payload |
transformSubmit | (payload, context) => Record<string, unknown> | — | Rewrite the built payload before persistence |
onSubmitSuccess | (result, payload) => void | — | Called after a successful submit |
onSubmitError | (error, payload) => void | — | Called when validation or persistence fails |
formActions | FormAction[] | null | — | Form lifecycle actions (onFormLoad / onFormBeforeSubmit / onFormAfterSubmit) |
formCustomValidations | FormCustomValidationRule[] | null | — | Form-level JSONata validations — errors land in formValidationErrors |
client | RestApiClient | — | Optional passthrough to field / value components (never used for fetching) |
Return Value
| Key | Type | Description |
|---|---|---|
mode | DocyrusFormViewMode | Resolved mode |
item / values | Record<string, unknown> | Live form values |
defaultValues | Record<string, unknown> | Initial values (field defaults + record) |
form | { Field } | Render-prop form.Field for custom field UI ({ name, state: { value, meta }, handleChange, handleBlur }) |
fields / allFields | DocyrusFormViewField[] | Resolved, visible fields (required / hidden / readOnly / disabled / renderMode / enforcedValidations / submitKeys …) |
unsupportedFields | DocyrusFormViewField[] | Fields rendered as values because no native editor exists |
validationErrors | Map<string, string> | Per-field errors from the last validate() |
formValidationErrors | string[] | Form-level formCustomValidations errors — render them as an Alert banner |
isDirty | boolean | Values differ from the baseline (empty values normalized) |
isLoading | boolean | Data still loading |
isSubmitting | boolean | A submit is in flight |
error | Error | null | Load / submit error |
setValue | (slug, value) => void | Programmatic write |
validate | () => Promise<boolean> | required → tokens → JSONata customValidations, then form-level validations |
reset | () => void | Restore the baseline |
submit | () => Promise<unknown> | Validate → onFormBeforeSubmit → build payload → persist → onFormAfterSubmit |
resetActionOverrides | () => void | Clear field-action and form-action property overrides |
renderField | (slug, options?) => ReactNode | Render one field (form or value mode) |
renderLayout | (options?) => ReactNode | Render the whole layout (sections / tabs / grid, or EditableRecordDetail with clickToEdit) |
Type Exports
| Type | Description |
|---|---|
UseDynamicFormViewOptions | Hook options |
UseDynamicFormViewResult | Hook result |
DynamicFormViewCallbackContext | onSubmit / transformSubmit context (mode, itemId, values, fields) |
DynamicFormViewMode / DynamicFormViewRenderMode / DynamicFormViewValidationTokenMode | Mode unions |
DynamicFormViewLayoutItem / DynamicFormViewSection / DynamicFormViewFieldsetSection / DynamicFormViewTabPanelSection / DynamicFormViewTabSection / DynamicFormViewLayoutFieldItem / DynamicFormViewColSpan | Layout tree types |
DynamicFormViewFieldLayout | Per-field overrides |
DynamicFormViewField | Resolved field |
DynamicFormViewRenderFieldOptions / DynamicFormViewRenderLayoutOptions | renderField / renderLayout options |
DynamicFormViewSubmitContext | Engine submit context |
useDocyrusTenant
Tenant integration for Docyrus native apps. Fetches tenant preferences, builds dateUtils and numberUtils, and wires DateFormatProvider and NumberFormatProvider so every native component picks up the tenant's date and number formats.
useLocalDataSource
Create an in-memory Docyrus-compatible data source from a JSON array, with local CRUD and Docyrus-style query parameters.