You've built this form before. A few fields, a couple of conditions. Then more fields get added. Fields start depending on each other — one change should cascade into another, a selection should trigger a fetch, a toggle should auto-fill another field. Before long you have useEffect blocks watching fields, setValue calls pushing changes back, and derived state scattered across the component. The real dependency graph is buried inside effect hooks where no one can see it.
Formality is a declarative form logic layer built on top of React Hook Form. You describe field relationships, conditional behavior, and derived values in configuration. The library handles the wiring.
All form behavior lives in one place instead of being scattered across components.
function ManualForm() {
const { register, watch, setValue, resetField } = useForm();
const country = watch("country");
const quantity = watch("quantity");
const unitPrice = watch("unitPrice");
// Cascading: load states when country changes
useEffect(() => {
if (country) fetchStates(country.id).then(setStateOptions);
else { resetField("state"); resetField("city"); }
}, [country]);
// Derived value: compute total
const [totalPrice, setTotalPrice] = useState(0);
useEffect(() => {
setTotalPrice((quantity ?? 0) * (unitPrice ?? 0));
}, [quantity, unitPrice]);
// Auto-fill: set address when toggled
const useDefault = watch("useDefaultAddress");
useEffect(() => {
if (useDefault) setValue("shippingAddress", "123 Main St");
}, [useDefault]);
return (
<form>
<select {...register("country")} />
<select {...register("state")} disabled={!country} />
{paymentMethod === "Credit Card" && <input {...register("cardNumber")} />}
<p>Total: {totalPrice}</p>
</form>
);
}const config = {
country: { type: "select", props: { useOptions: useCountries } },
state: {
type: "select",
props: { useOptions: useStates },
selectProps: { queryParams: "country.id", disabled: "!country" },
},
paymentMethod: { type: "select", props: { options: ["Credit Card", "Bank Transfer", "PayPal"] } },
cardNumber: {
type: "textField",
conditions: [{ when: "paymentMethod", is: "Credit Card", visible: true }],
},
quantity: { type: "number" },
unitPrice: { type: "number" },
totalPrice: {
type: "number",
conditions: [{ selectWhen: "quantity && unitPrice", selectSet: "quantity * unitPrice" }],
disabled: true,
},
useDefaultAddress: { type: "switch" },
shippingAddress: {
type: "textField",
conditions: [{ when: "useDefaultAddress", truthy: true, set: "123 Main St" }],
},
};No useEffect. No manual syncing. Every relationship is visible in one config object.
Formality does not replace your form library. It uses React Hook Form internally for registration, validation, and submission.
- Full access to all RHF APIs via the render function's
methods - Drop down to RHF at any point — no lock-in
- Formality is an abstraction layer on top of RHF, not an alternative to it
<Form config={config} onSubmit={onSubmit}>
{({ methods }) => (
<form onSubmit={methods.handleSubmit(onSubmit)}>
{/* methods is the full UseFormReturn from RHF */}
<Field name="email" />
<button type="submit" disabled={!methods.formState.isValid}>Submit</button>
</form>
)}
</Form>If Formality doesn't handle something, use RHF directly. The escape hatch is always available.
Form logic does not belong in components. A component should render UI, not orchestrate field dependencies, compute derived values, or manage cascading side effects.
In a typical complex form, the logic is scattered:
useEffectblocks scattered across the component for field synchronizationwatchcalls pulling values into the render cyclesetValuecalls pushing changes back imperatively- Conditional rendering driven by form state inside JSX
This mixes "what the form looks like" with "what the form does." The result is hard to read, hard to test, and hard to modify without introducing regressions.
Formality separates these concerns:
- Configuration defines behavior (conditions, derived values, dependencies)
- Components handle rendering only
- The library resolves relationships at runtime
Behavior is centralized in config objects. Components stay focused on presentation. The dependency graph is visible at a glance, not buried inside effect hooks.
Formality separates form logic from rendering. All behavior — visibility, disabled state, derived values, cascading dependencies — is expressed in configuration objects, not in component code.
Conditions are the engine behind this. They are not just for showing and hiding fields. They are a general-purpose behavior system that controls:
| Behavior | How | Example |
|---|---|---|
| Visibility | visible |
Show card number only when payment is "Credit Card" |
| Disabled state | disabled |
Disable city selector until state is chosen |
| Static value assignment | set |
Auto-fill address when "use default" is toggled |
| Dynamic computed values | selectSet |
Calculate total from quantity x price |
| Multi-field matching | when object |
Show section only when email is valid AND name is filled |
| Complex expressions | selectWhen |
Flag a discount when age >= 25 AND hasLicense AND years >= 3 |
These all work through the same conditions array on any field or group.
Because Formality expresses form behavior as plain configuration objects, a form's entire logic — field types, relationships, conditions, derived values — can be represented as data. That data can come from anywhere.
// A complete field definition, expressible as JSON
{
"type": "select",
"label": "State",
"props": { "useOptions": "useStates" },
"selectProps": {
"queryParams": "country.id",
"disabled": "!country"
}
}
This means:
- API-driven forms: Fetch field configurations from your backend and render them dynamically. Admin-configurable forms, multi-tenant layouts, or user-customizable dashboards become straightforward.
- Serialization: Config objects can be serialized, stored, and reconstructed. No functions required for standard behavior.
- Declarative business rules: Conditions describe what should happen, not how to make it happen. The engine resolves the how.
String expressions are what enable this model. A condition like selectSet: "price * quantity" is a portable, serializable rule — not a JavaScript function bound to a module. Functions remain fully supported for cases that need them (complex calculations, string manipulation, TypeScript type safety), but they are not required for the majority of form logic.
In practice: use string expressions for standard relationships, reach for functions when you need full programming flexibility.
| Package | Description | Status |
|---|---|---|
| @formality-ui/core | Framework-agnostic utilities | Stable |
| @formality-ui/react | React implementation | Stable |
| @formality-ui/vue | Vue implementation | Planned |
| @formality-ui/svelte | Svelte implementation | Planned |
npm install @formality-ui/react react-hook-formimport { FormalityProvider, Form, Field } from "@formality-ui/react";
const inputs = {
textField: {
component: ({ value, onChange, label, error }) => (
<div>
<label>{label}</label>
<input value={value ?? ""} onChange={(e) => onChange(e.target.value)} />
{error && <span className="error">{error}</span>}
</div>
),
defaultValue: "",
},
switch: {
component: ({ checked, onChange, label }) => (
<label>
<input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)} />
{label}
</label>
),
defaultValue: false,
inputFieldProp: "checked",
},
};
const config = {
name: { type: "textField", label: "Full Name" },
email: { type: "textField", label: "Email Address" },
subscribe: { type: "switch", label: "Subscribe to newsletter" },
};
function MyForm() {
return (
<FormalityProvider inputs={inputs}>
<Form config={config} onSubmit={console.log}>
{({ methods }) => (
<form onSubmit={methods.handleSubmit(console.log)}>
<Field name="name" />
<Field name="email" />
<Field name="subscribe" />
<button type="submit">Submit</button>
</form>
)}
</Form>
</FormalityProvider>
);
}Conditions are Formality's core mechanism for expressing field relationships. Every condition has a trigger (what to watch), optional matchers (what to match), and actions (what to do when matched).
const config = {
showDetails: { type: "switch", label: "Show Details" },
details: {
type: "textField",
label: "Additional Details",
conditions: [
{ when: "showDetails", truthy: false, visible: false },
],
},
status: {
type: "select",
label: "Status",
conditions: [
{ when: "signed", is: false, disabled: true },
{ when: "priority", is: "urgent", set: "expedited" },
],
},
};Match on multiple fields simultaneously:
conditions: [
{
when: {
email: { isValid: true },
name: { isTruthy: true },
},
visible: true,
},
]| Property | Description |
|---|---|
when |
Field name to watch (string), or object for multi-field matching |
selectWhen |
Expression to evaluate (for complex conditions) |
is |
Exact value to match |
truthy |
Check if value is truthy (true) or falsy (false) |
isValid |
Check if field is valid (true) or invalid (false) |
isDisabled |
Check if field is disabled (true) or enabled (false) |
disabled |
Set disabled state when condition matches |
visible |
Set visibility when condition matches |
set |
Set static value when condition matches |
selectSet |
Set dynamic value from expression or function |
Resolution rules:
disabled: OR logic — disabled if any condition sets itvisible: AND logic — hidden if any condition setsfalseset/selectSet: last matching condition wins
Most form libraries don't handle computed fields cleanly. Formality treats derived values as a first-class concept.
Auto-fill a field when a condition is met:
shippingAddress: {
type: "textField",
conditions: [
{ when: "useDefaultAddress", truthy: true, set: "123 Main Street" },
],
}Calculate values from other fields using string expressions or functions:
totalPrice: {
type: "number",
conditions: [
{
selectWhen: "basePrice && quantity",
selectSet: "basePrice * quantity * (applyDiscount ? 0.8 : 1)",
},
],
disabled: true,
}fullName: {
type: "textField",
conditions: [
{
selectWhen: "firstName || lastName",
subscribesTo: ["firstName", "lastName"],
selectSet: ({ fields }) => {
const first = fields.firstName?.value ?? "";
const last = fields.lastName?.value ?? "";
return `${first} ${last}`.trim();
},
disabled: true,
},
],
}Cascading updates work automatically. If field A derives from B, and B derives from C, changing C propagates through the entire chain.
Fields can dynamically compute props based on other fields. This handles cascading selects, conditional query parameters, and derived props.
const config = {
country: { type: "select", props: { useOptions: useCountries } },
state: {
type: "select",
props: { useOptions: useStates },
selectProps: {
queryParams: "country.id", // pass country.id to the states hook
disabled: "!country", // disable until country is selected
},
},
city: {
type: "select",
props: { useOptions: useCities },
selectProps: {
queryParams: "state.id",
disabled: "!state",
},
},
};Most options accept both string expressions and callback functions. String expressions are the default because they are portable and serializable — they enable API-driven forms and config storage. Functions are available for anything that needs full programming power.
String expressions (auto-infer dependencies, serializable):
selectProps: {
value: 'price * quantity',
disabled: '!client',
queryParams: 'client.id',
}Callback functions (require explicit subscribesTo):
subscribesTo: ['price', 'quantity', 'discount'],
selectProps: {
value: ({ fields }) => {
const price = fields.price?.value ?? 0;
const qty = fields.quantity?.value ?? 0;
const discount = fields.discount?.value ?? 0;
return Math.round(price * qty * (1 - discount / 100) * 100) / 100;
},
}| Use case | Recommended approach |
|---|---|
| Simple field access | String: 'fieldName' |
| Property access | String: 'client.id' |
| Basic arithmetic | String: 'price * quantity' |
| Comparisons | String: 'age >= 21 && hasLicense' |
| Complex calculations | Function (rounding, formatting) |
| String manipulation | Function (toUpperCase, trim, etc.) |
| Business logic | Function |
| TypeScript type safety | Function |
Compose multiple validators with async support:
const validators = {
required: (value) => (!value ? { type: "required" } : true),
email: (value) => (/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) ? true : "Invalid email"),
minLength: (min) => (value) => (value?.length < min ? `Must be at least ${min} characters` : true),
};
const config = {
email: { type: "textField", validator: ["required", "email"] },
password: {
type: "passwordField",
validator: [
"required",
validators.minLength(8),
async (value) => {
const isCommon = await checkCommonPassword(value);
return isCommon ? "Password is too common" : true;
},
],
},
};Debounced automatic form submission:
<Form config={config} onSubmit={handleSubmit} autoSave debounce={2000}>
{/* fields */}
</Form>
// Input-level debounce control
const inputs = {
textField: { component: TextField, debounce: 1000 },
switch: { component: Switch, debounce: false },
};Apply conditions to multiple fields at once:
const formConfig = {
groups: {
businessFields: {
conditions: [{ when: 'accountType', is: 'personal', visible: false }],
},
},
};
<Form config={fieldConfig} formConfig={formConfig}>
<Field name="accountType" />
<FieldGroup name="businessFields">
<Field name="companyName" />
<Field name="taxId" />
<Field name="companySize" />
</FieldGroup>
</Form>Groups can be nested — inner groups inherit conditions from outer groups.
Transform values between user input and form state:
const inputs = {
currency: {
component: CurrencyInput,
defaultValue: null,
parser: (value) => parseFloat(String(value).replace(/[,$]/g, "")) || null,
formatter: (value) => (value == null ? "" : new Intl.NumberFormat("en-US").format(value)),
},
autocomplete: {
component: Autocomplete,
defaultValue: null,
valueField: "id",
getSubmitField: (name) => `${name}Id`,
},
};┌─────────────────────────────────────────────┐
│ FormalityProvider (Global) │
│ • Input type definitions │
│ • Formatters/Parsers │
│ • Validators & Error messages │
└───────────────────┬─────────────────────────┘
│
▼
┌─────────────────────────────────────────────┐
│ Form (Instance) │
│ • React Hook Form integration │
│ • Field registry & subscriptions │
│ • Condition evaluation │
└───────────────────┬─────────────────────────┘
│
▼
┌─────────────────────────────────────────────┐
│ Field (Component) │
│ • Props resolution & evaluation │
│ • Value transformation │
│ • Condition application │
└─────────────────────────────────────────────┘
Evaluate dynamic expressions against form state:
// Unqualified paths auto-resolve to field values
'client' → fields.client.value
'client.id' → fields.client.value.id
// Qualified paths for specific access
'fields.client.isTouched' → field metadata
'record.originalValue' → original record data
'props.name' → current field nameUse Formality when:
- Your form has field relationships (cascading selects, conditional sections, derived values)
- You're writing
useEffectto synchronize form fields - You need to express complex conditional logic (visibility, disabled state, value overrides)
- You have auto-save requirements with validation awareness
- Form logic is spread across multiple components and hard to track
- You need forms driven by API data or external configuration
- Business rules are buried in component code and should be declarative
You may not need it when:
- Your form is simple (a few fields, no relationships)
- You only need basic validation and submission
- React Hook Form alone handles everything you need
- There are no cross-field dependencies or conditional behavior
| Resource | Description |
|---|---|
| Examples | Comprehensive runnable examples |
| Developer Docs (PRD.md) | Complete technical specification |
| Development Guide | Contributing and development setup |
- Node.js >= 18
- pnpm >= 8
git clone https://github.com/formality-ui/formality.git
cd formality
pnpm install
pnpm build
pnpm test
pnpm typecheckformality/
├── packages/
│ ├── core/ # Framework-agnostic utilities
│ └── react/ # React implementation
├── examples/ # Comprehensive examples
├── PRD.md # Developer documentation
└── package.json
| Script | Description |
|---|---|
pnpm build |
Build all packages |
pnpm test |
Run all tests |
pnpm typecheck |
Type check all packages |
pnpm lint |
Lint all packages |
See the examples directory for comprehensive, runnable examples:
| Example | Description |
|---|---|
| 01-basic-form | Getting started with Form, Field, Provider |
| 02-input-types | Input configuration options |
| 03-conditions | Conditional logic |
| 04-validation | Validation system |
| 05-field-dependencies | Dynamic props and cascading |
| 06-auto-save | Auto-save configuration |
| 07-advanced-features | UnusedFields, ordering, templates |
| 08-real-world-example | Complete Quote form |
| 09-string-vs-function | Expression vs callback comparison |
Contributions are welcome! Please read our contributing guidelines before submitting PRs.
pnpm test
pnpm test --filter=@formality-ui/core
pnpm test --filter=@formality-ui/react
pnpm test -- --coverageIf Formality helps you build something great, consider fueling future development:
MIT
