@formality-ui/react
v0.4.0
Published
React implementation of the Formality form framework. Build powerful, dynamic forms with conditional logic, field dependencies, and auto-save support.
Readme
@formality-ui/react
React implementation of the Formality form framework. Build powerful, dynamic forms with conditional logic, field dependencies, and auto-save support.
Installation
npm install @formality-ui/react react-hook-form
# or
pnpm add @formality-ui/react react-hook-form
# or
yarn add @formality-ui/react react-hook-formPeer Dependencies:
react>= 18.0.0react-dom>= 18.0.0react-hook-form>= 7.0.0
Quick Start
import { FormalityProvider, Form, Field } from "@formality-ui/react";
import type { InputConfig, FormFieldsConfig } from "@formality-ui/react";
// Define your input types
const inputs: Record<string, InputConfig> = {
textField: {
component: ({ value, onChange, label, error, ...props }) => (
<div>
<label>{label}</label>
<input
value={value ?? ""}
onChange={(e) => onChange(e.target.value)}
{...props}
/>
{error && <span>{error}</span>}
</div>
),
defaultValue: "",
},
switch: {
component: ({ value, onChange, label }) => (
<label>
<input
type="checkbox"
checked={value ?? false}
onChange={(e) => onChange(e.target.checked)}
/>
{label}
</label>
),
defaultValue: false,
},
};
// Define your form fields
const config: FormFieldsConfig = {
name: { type: "textField", label: "Full Name" },
email: { type: "textField", label: "Email Address" },
subscribed: { type: "switch", label: "Subscribe to newsletter" },
};
// Use in your app
function App() {
return (
<FormalityProvider inputs={inputs}>
<Form config={config} onSubmit={(values) => console.log(values)}>
{({ handleSubmit }) => (
<form onSubmit={handleSubmit(console.log)}>
<Field name="name" />
<Field name="email" />
<Field name="subscribed" />
<button type="submit">Submit</button>
</form>
)}
</Form>
</FormalityProvider>
);
}Components
FormalityProvider
Global configuration provider. Wrap your app or form section.
<FormalityProvider
inputs={inputConfigs}
validators={validatorConfigs}
formatters={formatterConfigs}
parsers={parserConfigs}
errorMessages={errorMessageConfigs}
>
{children}
</FormalityProvider>Props:
| Prop | Type | Description |
|------|------|-------------|
| inputs | Record<string, InputConfig> | Input component configurations |
| validators | ValidatorsConfig | Custom validators |
| formatters | FormattersConfig | Custom formatters |
| parsers | ParsersConfig | Custom parsers |
| errorMessages | ErrorMessagesConfig | Custom error messages |
Form
Form container with React Hook Form integration.
<Form
config={fieldConfigs}
formConfig={formLevelConfig}
record={initialValues}
onSubmit={handleSubmit}
autoSave={false}
debounce={1000}
>
{({ methods, formState, unusedFields, resolvedTitle }) => (
// Render your form
)}
</Form>Props:
| Prop | Type | Description |
|------|------|-------------|
| config | FormFieldsConfig | Field configurations |
| formConfig | FormConfig | Form-level configuration |
| record | Record<string, any> | Initial values |
| onSubmit | (values) => void | Submit handler |
| autoSave | boolean | Enable auto-save |
| debounce | number \| false | Auto-save debounce delay (ms); false submits immediately (no timer). Defaults to 1000 |
Render API:
| Property | Type | Description |
|----------|------|-------------|
| methods | UseFormReturn | React Hook Form methods |
| formState | FormState | Form state |
| unusedFields | string[] | Fields not yet rendered |
| resolvedTitle | string | Resolved form title |
Field
Individual field with automatic configuration resolution.
<Field
name="fieldName"
type="textField"
disabled={false}
hidden={false}
label="Custom Label"
shouldRegister={true}
>
{({ fieldState, renderedField, fieldProps, watchers }) => (
// Custom render
)}
</Field>Props:
| Prop | Type | Description |
|------|------|-------------|
| name | string | Field name (required) |
| type | string | Override input type |
| disabled | boolean | Override disabled state |
| hidden | boolean | Hide field |
| label | string | Override label |
| shouldRegister | boolean | Register as used field |
Render API:
| Property | Type | Description |
|----------|------|-------------|
| fieldState | FieldState | Field state |
| renderedField | ReactNode | Rendered input component |
| fieldProps | object | Resolved field props |
| watchers | object | Watched field values |
FieldGroup
Apply conditions to multiple fields.
const formConfig = {
groups: {
signedFields: {
conditions: [{ when: "signed", is: true, disabled: false }],
},
},
};
<FieldGroup name="signedFields">
<Field name="creditApp" />
<Field name="inCarvin" />
</FieldGroup>;Props:
| Prop | Type | Description |
|------|------|-------------|
| name | string | Group name (must match formConfig.groups key) |
| children | ReactNode | Child fields/content |
UnusedFields
Render fields from config not explicitly placed.
<Form config={config}>
<Field name="name" />
{/* Other fields from config rendered automatically */}
<UnusedFields />
</Form>Props:
| Prop | Type | Description |
|------|------|-------------|
| exclude | string[] | Field names to exclude |
Conditions
Add conditional logic to fields:
const config: FormFieldsConfig = {
signed: { type: "switch" },
creditApp: {
type: "switch",
conditions: [
{ when: "signed", is: false, disabled: true },
{ when: "signed", is: true, visible: true },
],
},
};Condition Properties:
| Property | Description |
|----------|-------------|
| when | Field name to watch |
| selectWhen | Expression to evaluate |
| is | Exact value to match |
| truthy | Truthy/falsy match |
| disabled | Set disabled state when matched |
| visible | Set visibility when matched |
| set | Value to set when matched |
| selectSet | Expression for value to set |
Condition Merging Logic
- disabled: OR logic (disabled if ANY group/field is disabled)
- visible: AND logic (visible only if ALL groups/fields are visible)
Dynamic Props (selectProps)
Evaluate props dynamically based on form state:
const config: FormFieldsConfig = {
client: { type: "autocomplete" },
clientContact: {
type: "autocomplete",
selectProps: {
queryParams: "client.id",
disabled: "!client",
placeholder: "client.name",
},
},
};Auto-Save
Enable automatic form submission on changes:
<Form
config={config}
autoSave
debounce={2000}
onSubmit={async (values) => {
await saveToServer(values);
}}
>
{/* Fields */}
</Form>The Form-level debounce prop (number | false, default 1000) sets the
default cadence for every field. false submits immediately on every change;
a number delays the save until that many milliseconds elapse without a change.
Per-field debounce overrides
Each input type can override the auto-save cadence via
InputConfig.debounce: number | false | undefined. When unset, the field falls
back to the Form-level debounce prop.
const inputs: Record<string, InputConfig> = {
// switch saves IMMEDIATELY on toggle (no timer):
switch: { component: Switch, defaultValue: false, debounce: false },
// textField waits 2s after typing stops:
textField: { component: TextField, defaultValue: "", debounce: 2000 },
// select waits 0.5s:
select: { component: Select, defaultValue: "", debounce: 500 },
// numberField is unset → falls back to the Form-level `debounce` prop:
numberField: { component: NumberField, defaultValue: 0 },
};The routing, transcribed from Form.tsx (changeField):
| InputConfig.debounce | Behavior |
| ---------------------- | -------------------------------------------- |
| false | Submit immediately (no debounce timer). |
| <number> | A per-field timer at that ms interval. |
| undefined | Fall back to the Form-level debounce prop. |
Coalescing by interval. Per-field numeric timers are keyed by their ms interval, not by field name. Fields that share the same numeric debounce coalesce into a single timer; all of their pending changes accumulate in a shared set and are captured together when that timer fires. Fields with different numeric debounces each get their own timer and fire on their own cadence.
Flushing pending saves: submitImmediate()
submitImmediate() flushes any pending auto-save immediately — both the
per-field numeric timers and the Form-level timer. It is a no-op when
nothing is pending (no spurious empty save), cancels any trailing timers so
they cannot race this flush, and runs the save pipeline exactly once.
submitImmediate lives on the form's context value — access it via
useFormContext(), not the <Form> render-prop API:
function SaveNowButton() {
const { submitImmediate } = useFormContext();
return <button onClick={() => submitImmediate()}>Save Now</button>;
}The debounced submit handle (cancel / flush / pending)
useFormContext() also exposes debouncedSubmit, a DebouncedFunction with
the standard debounced-handle contract:
| Member | Behavior |
| ------------------- | ----------------------------------------------- |
| debouncedSubmit() | Schedule the debounced invocation. |
| .cancel() | Cancel any pending invocation. |
| .flush() | Immediately execute any pending invocation. |
| .pending() | true if an invocation is currently scheduled. |
.pending() is reliable — it tracks the real scheduled state on both the
Form-level and per-field debouncers (the earlier "always returns false" bug
has been fixed). For most UI needs prefer submitImmediate(): it covers both
timer sources and the cancel-race, so you do not have to manage them yourself.
Hooks
useFormContext
Access form state and methods from any child component:
import { useFormContext } from "@formality-ui/react";
function CustomComponent() {
const { config, methods, record, unusedFields, submitImmediate } =
useFormContext();
// ...
}Two auto-save handles are available on the context value (see Auto-Save for the full semantics):
| Member | Description |
| ----------------- | ---------------------------------------------------------------- |
| submitImmediate | Flush pending auto-save immediately (both timer sources). |
| debouncedSubmit | The DebouncedFunction handle (cancel / flush / pending). |
useConditions
Evaluate conditions manually:
import { useConditions } from "@formality-ui/react";
const { disabled, visible, setValue } = useConditions({
conditions: fieldConfig.conditions,
});usePropsEvaluation
Evaluate dynamic props:
import { usePropsEvaluation } from "@formality-ui/react";
const evaluatedProps = usePropsEvaluation(selectProps, watchedValues);useFormState
Subscribe to form state changes:
import { useFormState } from "@formality-ui/react";
const { methods, formState } = useFormState(options);useSubscriptions
Subscribe to field value changes:
import { useSubscriptions } from "@formality-ui/react";
const watchedValues = useSubscriptions(fieldNames);useInferredInputs
Infer input configurations:
import { useInferredInputs } from "@formality-ui/react";
const inputs = useInferredInputs(config);Contexts
ConfigContext
Global configuration context:
import { useConfigContext } from "@formality-ui/react";
const { inputs, validators, formatters, parsers, errorMessages } =
useConfigContext();FormContext
Form-level context:
import { useFormContext } from "@formality-ui/react";
const { config, methods, record, formConfig, unusedFields } = useFormContext();GroupContext
Group-level context for nested conditions:
import { useGroupContext } from "@formality-ui/react";
const groupState = useGroupContext();TypeScript Support
All types are exported for full TypeScript support:
import type {
// Components
FormalityProviderProps,
FormProps,
FormRenderAPI,
FieldProps,
FieldRenderAPI,
FieldGroupProps,
UnusedFieldsProps,
// Contexts
ConfigContextValue,
FormContextValue,
GroupContextValue,
GroupState,
// Core types (re-exported)
InputConfig,
FieldConfig,
FormFieldsConfig,
FormConfig,
ConditionDescriptor,
ValidationResult,
ValidatorSpec,
// React-specific types
InputTemplateProps,
CustomFieldState,
ExtendedFormState,
UseFormStateOptions,
WatcherSetterFn,
DebouncedFunction,
// React type overlays — precise React/RHF types layered over core's loose
// `unknown` types. Prefer these in React code (see Type Safety below).
ReactInputConfig,
ReactFieldConfig,
ReactFormFieldsConfig,
FormalityFieldComponentProps,
// Re-exported react-hook-form types — so consumers need no direct RHF import.
RefCallBack,
UseFormStateReturn,
FieldValues,
} from "@formality-ui/react";
// `defineInputs` is a VALUE export (an identity helper), not a type — import
// it separately, not inside an `import type { ... }` block.
import { defineInputs } from "@formality-ui/react";Type Safety
Formality ships opt-in, compile-time checking for the three places typos hurt
most: Form config keys, Field names, and input type strings. It
also ships a precise type for the props Formality injects onto your field
components, so you can stop hand-rolling a lossy WithFormality<P> helper.
All of the checks below are opt-in and non-breaking — the non-generic
<Form>, <Field>, and InputConfig/FormFieldsConfig patterns shown in
Quick Start keep working byte-for-byte. The overlays below are
the recommended pattern for new React code.
Checked Form config keys (<Form<TFieldValues>>)
<Form> is generic over your form's field-values type. With the default
generic, any string key is accepted (unchanged behavior). Narrow the generic to
your values type and unknown config keys become a compile error —
catching typos like ofice at compile time instead of silently rendering
nothing.
import { Form } from "@formality-ui/react";
import type { ReactFormFieldsConfig } from "@formality-ui/react";
type ClientValues = { name: string; email: string; subscribed: boolean };
// ✅ Narrowed — only known field names are accepted.
const config: ReactFormFieldsConfig<ClientValues> = {
name: { type: "textField", label: "Full Name" },
email: { type: "textField", label: "Email" },
subscribed: { type: "switch", label: "Subscribe" },
};
// @ts-expect-error — typo `ofice` is rejected when the generic is narrowed.
const bad: ReactFormFieldsConfig<ClientValues> = {
ofice: { type: "textField" },
};
<Form<ClientValues> config={config}>{/* ... */}</Form>;The default <Form> (no generic) still accepts any string key, so existing
consumers migrate at their own pace.
Checked Field names (FieldProps<TName>)
By default <Field name="..." /> accepts any string — this is backwards
compatible and matches the Quick Start. Name-checking engages only when
FieldProps is explicitly narrowed.
React generics do not thread from
<Form<T>>into its children, so a<Form<ClientValues>>does not automatically narrow thenameon a child<Field>. To check field names you narrowFieldPropsexplicitly (the honest pattern below), typically via a thin typed wrapper.
import { Field } from "@formality-ui/react";
import type { FieldProps } from "@formality-ui/react";
type ClientValues = { name: string; email: string; subscribed: boolean };
type Names = keyof ClientValues; // "name" | "email" | "subscribed"
// Default usage — any string name compiles (unchanged):
<Field name="anything" />;
// Opt-in strict usage — a typed wrapper that narrows FieldProps:
function TypedField(props: FieldProps<Names>) {
return <Field {...props} />;
}
<TypedField name="email" />; // ✅
// @ts-expect-error — typo `ofice` is rejected once FieldProps is narrowed.
const _bad: FieldProps<Names> = { name: "ofice" };Automatic per-form narrowing — where a <Field> auto-narrows against the
enclosing <Form<TFieldValues>>'s key set — is a planned follow-up.
Checking input types with defineInputs (opt-in)
type: "textField" typos (e.g. type: "texField") are invisible by default
because FieldConfig.type / FieldProps.type default to string.
defineInputs is an identity helper that lets you derive a checked union
of your input-type keys, which you can then thread into type.
defineInputs is a value export (it returns inputs unchanged with zero
runtime effect — bundlers tree-shake it to nothing). Import it as a value, not
import type:
import { defineInputs } from "@formality-ui/react";
const inputs = defineInputs({
textField: { component: TextField, defaultValue: "" },
switch: { component: Switch, defaultValue: false },
});
// "textField" | "switch" — a checked union of your input-type keys.
export type InputType = keyof typeof inputs;This is purely additive — the existing non-generic Field and
FieldConfig.type still work unchanged. End-to-end wiring of InputType into
those types is a follow-up; defineInputs is the opt-in entry point.
Field component props: FormalityFieldComponentProps
<Field> renders your input component via React Hook Form's <Controller> and
injects a bundle of props onto it. FormalityFieldComponentProps<P> is the
precise type for that contract — replacing the lossy WithFormality<P>
helper consumers (e.g. sellario-ui) hand-roll today.
Before — the lossy hand-rolled helper:
// ❌ Lossy: state/formState are `unknown`, and forwardRef is the wrong type.
type WithFormality<P> = P & {
state?: unknown;
formState?: unknown;
forwardRef?: React.Ref<HTMLInputElement>; // wrong: RHF hands a RefCallBack
};After — the shipped precise type:
import type { FormalityFieldComponentProps } from "@formality-ui/react";
// FormalityFieldComponentProps<P = unknown> = P & {
// state?: CustomFieldState | Record<string, CustomFieldState>;
// formState?: UseFormStateReturn<FieldValues>;
// forwardRef?: RefCallBack;
// }
type TextFieldProps = { label?: string };
const TextField: React.ComponentType<
FormalityFieldComponentProps<TextFieldProps>
> = ({ state, formState, forwardRef, ...domProps }) => (
<input ref={forwardRef} {...domProps} />
);Destructure before forwarding. Always pull state, formState, and
forwardRef out of props before spreading the rest onto the underlying DOM
node — otherwise these non-DOM props leak to the DOM and React warns.
Wiring forwardRef to the inner input. forwardRef is RHF's RefCallBack
((instance: any) => void), not React.Ref<HTMLInputElement>. For a
plain <input> use ref={forwardRef}. For MUI v9 components (e.g.
Checkbox) that no longer accept a top-level inputRef, wire it via slots:
slotProps={{ input: { ref: forwardRef } }}Runtime delivery (important). <Field> delivers the RHF ref as a regular,
top-level forwardRef prop — no React.forwardRef wrap is required for a
plain function component that destructures forwardRef and wires it to the
inner input (ref={forwardRef}). Consumers migrating off the old
React-special ref key: a React.forwardRef-wrapped component should consume
props.forwardRef (PRD §20.4), and under React 19 ref-as-prop use forwardRef
directly. The type ships the intended contract so consumers can stop
hand-rolling WithFormality.
Utilities
makeProxyState
Create proxy state for efficient subscriptions:
import { makeProxyState, makeDeepProxyState } from "@formality-ui/react";
const proxy = makeProxyState(initialState);
const deepProxy = makeDeepProxyState(initialState);Testing & Coverage
Run the test suite with coverage from the repo root:
pnpm test:coverage
# equivalent to: vitest run --coverageCoverage is enforced as a hard gate: the run exits non-zero if any of statements, branches, functions, or lines drop below 90% (vitest coverage thresholds).
Coverage is computed repo-wide (merged across packages/core and
packages/react), excluding only the directories below:
| Glob | Reason |
| -------------------- | ---------------------- |
| examples/** | Demo apps; not shipped |
| packages/svelte/** | Stubbed adapter |
| packages/vue/** | Stubbed adapter |
| **/dist/** | Build output |
All other code — packages/core/**, packages/react/**, and any future
adapter with a real implementation — is in scope and must clear 90%. See
vitest.config.ts for the exact configuration.
Known Issues
isDisabledcondition matcher (React adapter) — conditions using theisDisabledfield-state matcher do not currently evaluate correctly in the React adapter because thefieldStatesmap intentionally omits thedisabledproperty (to avoid circular re-render dependencies). SeeKNOWN_ISSUES.mdfor the symptom, root cause, and a value-based workaround.
License
MIT
