Browse docs
Browse docs
Declarative side effects that power dynamic form behavior.
Reactions are declarative side effects that replace useEffect, watch, and manual dependency wiring. Instead of scattering imperative logic throughout your component, you declare what should happen when fields change—and the system handles execution, timing, and async coordination automatically.
Each reaction specifies:
const reaction = {
id: 'load-options', // Unique identifier
watch: ['category'], // Watch these fields
when: (ctx) => // Optional condition
ctx.getValue('enabled'),
run: async (ctx) => { // Effect to execute
const category = ctx.getValue('category');
const options = await fetchOptions(category);
ctx.setRuntime('item', {
status: 'ready',
data: { options }
});
}
};function ProductForm() {
const { watch, setValue } = useFormContext();
const [subcategories, setSubcategories] = useState([]);
const [loading, setLoading] = useState(false);
const category = watch('category');
// Manual dependency tracking with useEffect
useEffect(() => {
if (!category) {
setSubcategories([]);
setValue('subcategory', null);
return;
}
let cancelled = false;
setLoading(true);
fetchSubcategories(category).then(subs => {
if (!cancelled) {
setSubcategories(subs);
setLoading(false);
}
});
return () => { cancelled = true; };
}, [category, setValue]);
return (
<>
<Select name="category" options={categories} />
<Select
name="subcategory"
options={subcategories}
loading={loading}
/>
</>
);
}const reactions = [{
id: 'load-subcategories',
watch: ['category'],
run: async (ctx) => {
const category = ctx.getValue('category');
if (!category) return;
const requestId = ctx.beginAsync('fetch-subs');
ctx.setRuntime('subcategory', { status: 'loading' });
const subs = await fetchSubcategories(category);
if (ctx.isLatest('fetch-subs', requestId)) {
ctx.setRuntime('subcategory', {
status: 'ready',
data: { options: subs }
});
}
}
}];
function ProductForm() {
return (
<DashForm reactions={reactions}>
<Select name="category" options={categories} />
<Select name="subcategory" optionsFromFieldData />
</DashForm>
);
}beginAsync/isLatestPick a category — the reaction fires, the Subcategory select shows a loading
state, then fills with options fetched from a (mock) async API. Change the
category quickly and beginAsync / isLatest discard the stale response.
Use the StackBlitz button to open the full project:
import { DashForm, useFieldRuntime } from '@dashforge/forms';
import type { ReactionDefinition } from '@dashforge/forms';
import { Autocomplete, Box, Stack } from '@dashforge/tw';
type Option = { value: string; label: string };
async function fetchSubcategories(category: string): Promise<Option[]> {
// your API call here
return [];
}
const reactions: ReactionDefinition[] = [
{
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 options = await fetchSubcategories(category);
// Stale-response guard: only apply the latest request.
if (ctx.isLatest('fetch-subcategories', requestId)) {
ctx.setRuntime('subcategory', { status: 'ready', data: { options } });
}
},
},
];
// Subcategory reads the runtime options the reaction wrote.
function SubcategoryField() {
const runtime = useFieldRuntime('subcategory');
const options = (runtime?.data as { options?: Option[] } | null)?.options ?? [];
return (
<Autocomplete
name="subcategory"
label="Subcategory"
options={options}
visibleWhen={(engine) => engine.getNode('category')?.value != null}
fullWidth
/>
);
}
function CategoryForm() {
return (
<DashForm
defaultValues={{ category: '', subcategory: '' }}
reactions={reactions}
>
<Stack gap={2}>
<Autocomplete
name="category"
label="Category"
options={[
{ value: 'Electronics', label: 'Electronics' },
{ value: 'Clothing', label: 'Clothing' },
{ value: 'Books', label: 'Books' },
]}
fullWidth
/>
<SubcategoryField />
</Stack>
</DashForm>
);
}Reactions execute at two key moments:
All reactions run once when the form mounts. This establishes initial state, loads default options, and sets up any required data. Protected against React Strict Mode double-execution.
When a field changes, only reactions watching that field execute. This is O(1) thanks to an internal watch index. Multiple reactions watching the same field run in parallel.
{
id: 'load-cities',
watch: ['country'],
run: async (ctx) => {
const country = ctx.getValue<string>('country');
if (!country) {
ctx.setRuntime('city', { status: 'idle', data: null });
return;
}
const requestId = ctx.beginAsync('fetch-cities');
ctx.setRuntime('city', { status: 'loading' });
const cities = await api.fetchCities(country);
if (ctx.isLatest('fetch-cities', requestId)) {
ctx.setRuntime('city', {
status: 'ready',
data: { options: cities }
});
}
}
}{
id: 'clear-state-when-country-changes',
watch: ['country'],
run: (ctx) => {
// When country changes, clear the state selection
ctx.setValue('state', null);
ctx.setValue('city', null);
ctx.setRuntime('state', { status: 'idle', data: null });
ctx.setRuntime('city', { status: 'idle', data: null });
}
}{
id: 'validate-custom-logic',
watch: ['startDate', 'endDate'],
when: (ctx) => {
// Only run if both dates are set
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 (start > end) {
ctx.setRuntime('endDate', {
status: 'error',
error: 'End date must be after start date'
});
} else {
ctx.setRuntime('endDate', {
status: 'ready',
error: null
});
}
}
}{
id: 'calculate-total',
watch: ['quantity', 'unitPrice', 'taxRate'],
run: (ctx) => {
const quantity = ctx.getValue<number>('quantity') || 0;
const unitPrice = ctx.getValue<number>('unitPrice') || 0;
const taxRate = ctx.getValue<number>('taxRate') || 0;
const subtotal = quantity * unitPrice;
const tax = subtotal * (taxRate / 100);
const total = subtotal + tax;
ctx.setValue('total', total.toFixed(2));
}
}When users type quickly or change selections rapidly, you need protection against stale responses. The beginAsync/isLatest pattern solves this:
{
id: 'search-products',
watch: ['searchTerm'],
run: async (ctx) => {
const term = ctx.getValue<string>('searchTerm');
// Generate unique request ID
const requestId = ctx.beginAsync('product-search');
// Show loading state
ctx.setRuntime('results', { status: 'loading' });
// Perform async operation (might be slow)
const results = await api.search(term);
// Only update if this is still the latest request
if (ctx.isLatest('product-search', requestId)) {
ctx.setRuntime('results', {
status: 'ready',
data: { results }
});
}
// Otherwise, silently discard the stale response
}
}| Prop | Type | Default | Description |
|---|---|---|---|
id | string | required | Unique identifier for this reaction |
watch | string[] | required | Field names to watch for changes |
when | (ctx) => boolean | — | Optional condition that must be true to run |
run | (ctx) => void | Promise<void> | required | Effect to execute when watched fields change |
Methods available inside the run function:
| Method | Type | Description |
|---|---|---|
getValue<T> | (name: string) => T | Read current value of any field |
setValue | (name: string, value: unknown) => void | Update a field value |
getRuntime<TData> | (name: string) => FieldRuntimeState<TData> | Read runtime state (status, data, error) |
setRuntime<TData> | (name: string, patch: Partial<FieldRuntimeState<TData>>) => void | Update runtime state for a field |
beginAsync | (key: string) => number | Start async operation, returns request ID |
isLatest | (key: string, requestId: number) => boolean | Check if this response is still valid |
beginAsync/isLatest: For any async operation that might be outdated.when conditions: Avoid running expensive operations when inputs aren't ready.