Reactions
Declarative side effects that power dynamic form behavior.
What Are Reactions?
The core of Dashforge's dynamic forms
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:
- What to watch: Which field names trigger this reaction
- When to run: Optional conditions that must be true
- What to do: The effect to execute (sync or async)
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 }
});
}
};Before/After Comparison
Imperative vs declarative
Without Reactions (Imperative)
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}
/>
</>
);
}With Reactions (Declarative)
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>
);
}- No useEffect: Dependencies declared, not manually wired
- No component state: Runtime store handles loading
- No cancellation logic: Built into beginAsync/isLatest
- No prop threading: Fields read from runtime store
Execution Model
When and how reactions run
Reactions execute at two key moments:
1. Initial Evaluation (Once)
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.
2. Incremental Evaluation (Per Change)
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.
ℹ️
Reactions are async fire-and-forget. They run independently and don't block the UI or each other. Use beginAsync/isLatest to handle race conditions.
Common Patterns
Practical reaction examples
Pattern 1: Load Dynamic Options
{
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 }
});
}
}
}Pattern 2: Clear Dependent Fields
{
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 });
}
}Pattern 3: Conditional Execution
{
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
});
}
}
}Pattern 4: React to Multiple Fields
{
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));
}
}Stale Response Protection
Handling async race conditions
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
}
}✓
How it works:
- User types "ca" → Request ID 1 starts
- User types "t" (now "cat") → Request ID 2 starts
- Request 2 finishes first → Updates UI
- Request 1 finishes later → Discarded (not latest)
Reaction Definition API
Complete reference
Reaction definition properties:
| 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 |
Run Context API
Methods available inside reactions
The context object passed to run provides these methods:
| Prop | Type | Default | Description |
|---|---|---|---|
| getValue<T> | (name: string) => T | - | Read current value of any field |
| 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 |
Best Practices
Guidelines for writing good reactions
- Keep reactions focused: One reaction should do one thing. Split complex logic into multiple reactions.
- Always use beginAsync/isLatest: For any async operation that might be outdated.
- Handle null/undefined: Users might clear fields. Always check values before using them.
- Use when conditions: Avoid running expensive operations when inputs aren't ready.
- Clear dependent state: When a parent field changes, clear dependent fields to avoid stale values.
- Unique reaction IDs: Use descriptive, unique IDs for debugging and maintenance.