Browse docs
Browse docs
Build forms that adapt to user input with conditional fields, runtime options, and dependent behavior.
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:
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;
}}
/>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.
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>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>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
});
}
}
}
];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>The three mechanisms above — conditional visibility, runtime options, and
dependent values — compose in a single form. Below: "Company name" appears
only for business accounts (visibleWhen), and "Region" loads its
options asynchronously from a reaction that watches "Country"
(optionsFromFieldData + dependent value). Use the StackBlitz button
to open the full project:
import { DashForm, useFieldRuntime } from '@dashforge/forms';
import type { ReactionDefinition } from '@dashforge/forms';
import { TextField, Autocomplete, RadioGroup, Box, Stack } from '@dashforge/tw';
type Option = { value: string; label: string };
async function fetchRegions(country: string): Promise<Option[]> {
// your API call here
return [];
}
// Runtime options + dependent values: Region depends on Country.
const reactions: ReactionDefinition[] = [
{
id: 'load-regions',
watch: ['country'],
run: async (ctx) => {
const country = ctx.getValue<string>('country');
if (!country) {
ctx.setRuntime('region', { status: 'idle', data: null });
return;
}
const requestId = ctx.beginAsync('fetch-regions');
ctx.setRuntime('region', { status: 'loading' });
const options = await fetchRegions(country);
if (ctx.isLatest('fetch-regions', requestId)) {
ctx.setRuntime('region', { status: 'ready', data: { options } });
}
},
},
];
function RegionField() {
const runtime = useFieldRuntime('region');
const options = (runtime?.data as { options?: Option[] } | null)?.options ?? [];
return (
<Autocomplete
name="region"
label="Region"
options={options}
visibleWhen={(engine) => engine.getNode('country')?.value != null}
fullWidth
/>
);
}
function RegistrationForm() {
return (
<DashForm
defaultValues={{ accountType: 'personal', companyName: '', country: '', region: '' }}
reactions={reactions}
>
<Stack gap={2}>
<RadioGroup
name="accountType"
label="Account type"
options={[
{ value: 'personal', label: 'Personal' },
{ value: 'business', label: 'Business' },
]}
/>
{/* Conditional visibility — business accounts only */}
<TextField
name="companyName"
label="Company name"
visibleWhen={(engine) => engine.getNode('accountType')?.value === 'business'}
fullWidth
/>
<Autocomplete
name="country"
label="Country"
options={[
{ value: 'Italy', label: 'Italy' },
{ value: 'United States', label: 'United States' },
{ value: 'Canada', label: 'Canada' },
]}
fullWidth
/>
{/* Runtime options + dependent on Country */}
<RegionField />
</Stack>
</DashForm>
);
}Some forms need a variable number of rows — a list of skills, line items
on an invoice, phone numbers, team members. The useDashFieldArray hook
manages this: it tracks the items, gives you append / remove (plus
move / insert / replace), and hands back stable IDs for React keys.
Each item exposes a pre-computed name (e.g. skills.0, skills.1), so you
register the inner fields by path — ${field.name}.name — and per-row
validation rules work exactly like they do on a flat field.
import { DashForm, useDashFieldArray } from '@dashforge/forms';
import { TextField, Box, Stack, Button, Typography } from '@dashforge/tw';
interface Skill {
name: string;
}
function SkillsList() {
const { fields, append, remove } = useDashFieldArray<Skill>('skills');
return (
<Stack gap={2} sx="w-full">
{fields.length === 0 && (
<Typography variant="body2" color="muted">
No skills yet — click "Add skill" to start.
</Typography>
)}
{fields.map((field, idx) => (
<Box key={field.id} sx="flex gap-2 items-start">
<TextField
name={`${field.name}.name`}
label={`Skill #${idx + 1}`}
rules={{ required: 'Skill name is required' }}
fullWidth
/>
<Button color="danger" variant="outline" size="sm" onClick={() => remove(idx)}>
Remove
</Button>
</Box>
))}
<Button variant="outline" onClick={() => append({ name: '' })} sx="self-start">
+ Add skill
</Button>
</Stack>
);
}
export function SkillsForm() {
return (
<Box sx="w-full max-w-md">
<DashForm defaultValues={{ skills: [] }}>
<SkillsList />
</DashForm>
</Box>
);
}Key points:
field.id is the stable React key — it survives reorder and removal,
so rows never remount unexpectedly.field.name is the registration path — combine it with the inner
field name (${field.name}.name).rules={{ required }} on
skills.0.name blocks submit just like a top-level field.<DashForm> (or
<DashFormProvider>) — it reads the form context.visibleWhen or reactions