Dynamic Forms
Build forms that adapt to user input with conditional fields, runtime options, and dependent behavior.
What Are Dynamic Forms?
Beyond static field lists
Dynamic forms change based on user input. Fields appear or disappear, options update in real-time, and validation rules adapt to context. Dashforge provides three primary mechanisms for building dynamic forms:
- Conditional Visibility: Show/hide fields based on other field values
- Runtime Options: Load dropdown options dynamically from APIs or reactions
- Dependent Values: Update field values in response to other fields changing
Conditional Visibility
Show and hide fields without manual rendering logic
Without a system, conditional fields require manual rendering logic, state tracking, and careful cleanup. With visibleWhen, you declare the condition—the system handles rendering, unmounting, and state cleanup automatically.
The function receives the reactive engine and can read any field's current state:
<TextField
name="companyName"
label="Company Name"
visibleWhen={(engine) =>
engine.getNode('accountType')?.value === 'business'
}
/>
<Select
name="industry"
label="Industry"
options={industries}
visibleWhen={(engine) => {
const accountType = engine.getNode('accountType')?.value;
const hasCompany = engine.getNode('companyName')?.value != null;
return accountType === 'business' && hasCompany;
}}
/>ℹ️
Fields with visibleWhen automatically re-evaluate when watched fields change. No manual orchestration needed.
Runtime Options
Load dropdown options dynamically without manual state
Manually managing dynamic options requires useState, useEffect, loading flags, and careful prop threading. With optionsFromFieldData, fields read directly from the runtime store—reactions populate the data, fields consume it.
Combine with reactions to fetch options dynamically:
// In your component
const reactions = [
{
id: 'load-subcategories',
watch: ['category'],
run: async (ctx) => {
const category = ctx.getValue<string>('category');
if (!category) {
ctx.setRuntime('subcategory', {
status: 'idle',
data: null
});
return;
}
const requestId = ctx.beginAsync('fetch-subcategories');
ctx.setRuntime('subcategory', { status: 'loading' });
const subcategories = await fetchSubcategories(category);
if (ctx.isLatest('fetch-subcategories', requestId)) {
ctx.setRuntime('subcategory', {
status: 'ready',
data: { options: subcategories }
});
}
}
}
];
// In your JSX
<DashForm reactions={reactions}>
<Select
name="category"
label="Category"
options={['Electronics', 'Clothing', 'Books']}
/>
<Select
name="subcategory"
label="Subcategory"
optionsFromFieldData
visibleWhen={(engine) =>
engine.getNode('category')?.value != null
}
/>
</DashForm>The field automatically displays loading states and handles empty states when optionsFromFieldData is enabled.
Chained Dependencies
Multiple levels without callback hell
Without orchestration, chained dependencies (Country → State → City) quickly become nested useEffect hooks, each managing its own loading state and cleanup. Dashforge lets you define each level independently—the system coordinates execution order automatically.
Each reaction clears its dependent fields and manages its own async state:
const reactions = [
{
id: 'load-states',
watch: ['country'],
run: async (ctx) => {
const country = ctx.getValue<string>('country');
// Clear dependent fields
ctx.setValue('state', null);
ctx.setValue('city', null);
if (!country) {
ctx.setRuntime('state', { status: 'idle', data: null });
return;
}
const requestId = ctx.beginAsync('fetch-states');
ctx.setRuntime('state', { status: 'loading' });
const states = await fetchStates(country);
if (ctx.isLatest('fetch-states', requestId)) {
ctx.setRuntime('state', {
status: 'ready',
data: { options: states }
});
}
}
},
{
id: 'load-cities',
watch: ['state'],
run: async (ctx) => {
const state = ctx.getValue<string>('state');
// Clear dependent field
ctx.setValue('city', null);
if (!state) {
ctx.setRuntime('city', { status: 'idle', data: null });
return;
}
const requestId = ctx.beginAsync('fetch-cities');
ctx.setRuntime('city', { status: 'loading' });
const cities = await fetchCities(state);
if (ctx.isLatest('fetch-cities', requestId)) {
ctx.setRuntime('city', {
status: 'ready',
data: { options: cities }
});
}
}
}
];
<DashForm reactions={reactions}>
<Select
name="country"
label="Country"
options={countries}
/>
<Select
name="state"
label="State"
optionsFromFieldData
visibleWhen={(engine) =>
engine.getNode('country')?.value != null
}
/>
<Autocomplete
name="city"
label="City"
optionsFromFieldData
visibleWhen={(engine) =>
engine.getNode('state')?.value != null
}
/>
</DashForm>✓
Notice how each reaction clears dependent fields when parent values change. This prevents stale selections.
Calculated Values
Update fields based on other field values
Reactions can update field values directly using setValue:
const reactions = [
{
id: 'calculate-total',
watch: ['quantity', 'price', 'taxRate'],
run: (ctx) => {
const quantity = ctx.getValue<number>('quantity') || 0;
const price = ctx.getValue<number>('price') || 0;
const taxRate = ctx.getValue<number>('taxRate') || 0;
const subtotal = quantity * price;
const tax = subtotal * (taxRate / 100);
const total = subtotal + tax;
ctx.setValue('subtotal', subtotal.toFixed(2));
ctx.setValue('tax', tax.toFixed(2));
ctx.setValue('total', total.toFixed(2));
}
}
];
<DashForm reactions={reactions}>
<NumberField name="quantity" label="Quantity" />
<NumberField name="price" label="Unit Price" />
<NumberField name="taxRate" label="Tax Rate (%)" />
<TextField name="subtotal" label="Subtotal" disabled />
<TextField name="tax" label="Tax" disabled />
<TextField name="total" label="Total" disabled />
</DashForm>Conditional Validation
Change validation rules dynamically
While static validation is handled by React Hook Form's rules prop, you can add dynamic validation through runtime state:
const reactions = [
{
id: 'validate-date-range',
watch: ['startDate', 'endDate'],
when: (ctx) => {
const start = ctx.getValue('startDate');
const end = ctx.getValue('endDate');
return start != null && end != null;
},
run: (ctx) => {
const start = new Date(ctx.getValue('startDate'));
const end = new Date(ctx.getValue('endDate'));
if (end < start) {
ctx.setRuntime('endDate', {
status: 'error',
error: 'End date must be after start date'
});
} else {
ctx.setRuntime('endDate', {
status: 'ready',
error: null
});
}
}
}
];⚠️
Runtime errors show in the UI but don't prevent form submission. Use React Hook Form rules for blocking validation, and runtime state for non-blocking feedback.
Real-World Example
Putting it all together
A complex registration form with multiple dynamic behaviors:
const reactions = [
{
id: 'load-states',
watch: ['country'],
run: async (ctx) => {
const country = ctx.getValue<string>('country');
ctx.setValue('state', null);
if (!country) return;
const requestId = ctx.beginAsync('states');
ctx.setRuntime('state', { status: 'loading' });
const states = await fetchStates(country);
if (ctx.isLatest('states', requestId)) {
ctx.setRuntime('state', {
status: 'ready',
data: { options: states }
});
}
}
},
{
id: 'check-username-availability',
watch: ['username'],
run: async (ctx) => {
const username = ctx.getValue<string>('username');
if (!username || username.length < 3) return;
const requestId = ctx.beginAsync('check-username');
ctx.setRuntime('username', { status: 'loading' });
const available = await checkUsernameAvailable(username);
if (ctx.isLatest('check-username', requestId)) {
ctx.setRuntime('username', {
status: available ? 'ready' : 'error',
error: available ? null : 'Username already taken'
});
}
}
}
];
<DashForm reactions={reactions}>
<TextField
name="username"
label="Username"
rules={{
required: 'Username is required',
minLength: { value: 3, message: 'Min 3 characters' }
}}
/>
<Select
name="accountType"
label="Account Type"
options={['personal', 'business']}
/>
<TextField
name="companyName"
label="Company Name"
visibleWhen={(engine) =>
engine.getNode('accountType')?.value === 'business'
}
rules={{ required: 'Company name is required' }}
/>
<Select
name="country"
label="Country"
options={countries}
rules={{ required: 'Country is required' }}
/>
<Select
name="state"
label="State"
optionsFromFieldData
visibleWhen={(engine) =>
engine.getNode('country')?.value != null
}
/>
</DashForm>Best Practices
Guidelines for dynamic forms
- Clear dependent fields: When a parent field changes, explicitly clear dependent selections to avoid stale values
- Handle null values: Always check for null/undefined before using field values in visibleWhen or reactions
- Use loading states: Show loading indicators while fetching options to provide feedback
- Combine visibility with validation: Only require fields that are currently visible
- Keep chains shallow: Avoid deeply nested dependencies (3+ levels). Consider splitting into multiple forms
- Test edge cases: What happens when users quickly change selections? What if APIs fail?