Browse docs
Browse docs
Guidelines for building maintainable, scalable forms with Dashforge.
In Dashforge, reactions are the orchestration layer. Organize them by what they do (load options, calculate values, validate cross-field logic), not by which field they belong to. This makes the form's behavior explicit and maintainable.
// reactions/addressReactions.ts
export const addressReactions = [
{
id: 'load-states',
watch: ['country'],
run: async (ctx) => { /* ... */ }
},
{
id: 'load-cities',
watch: ['state'],
run: async (ctx) => { /* ... */ }
}
];
// reactions/validationReactions.ts
export const validationReactions = [
{
id: 'validate-date-range',
watch: ['startDate', 'endDate'],
run: (ctx) => { /* ... */ }
}
];
// MyForm.tsx
import { addressReactions } from './reactions/addressReactions';
import { validationReactions } from './reactions/validationReactions';
const reactions = [
...addressReactions,
...validationReactions
];
<DashForm reactions={reactions}>
{/* fields */}
</DashForm>One of Dashforge's core principles: components render, reactions orchestrate. Keep field components focused on layout and presentation. Keep reactions focused on business logic and data flow. This separation makes both easier to test and maintain.
// PersonalInfoSection.tsx
export function PersonalInfoSection() {
return (
<>
<TextField name="firstName" label="First Name" />
<TextField name="lastName" label="Last Name" />
<TextField name="email" label="Email" />
</>
);
}
// AddressSection.tsx
export function AddressSection() {
return (
<>
<Select name="country" label="Country" options={countries} />
<Select name="state" label="State" optionsFromFieldData
visibleWhen={(e) => e.getNode('country')?.value != null} />
<TextField name="city" label="City" />
</>
);
}
// RegistrationForm.tsx
export function RegistrationForm() {
return (
<DashForm reactions={allReactions}>
<PersonalInfoSection />
<AddressSection />
</DashForm>
);
}Always handle async failures in reactions:
{
id: 'load-options',
watch: ['category'],
run: async (ctx) => {
const category = ctx.getValue<string>('category');
if (!category) {
ctx.setRuntime('item', { status: 'idle', data: null });
return;
}
const requestId = ctx.beginAsync('fetch-items');
ctx.setRuntime('item', { status: 'loading' });
try {
const items = await fetchItems(category);
if (ctx.isLatest('fetch-items', requestId)) {
ctx.setRuntime('item', {
status: 'ready',
data: { options: items }
});
}
} catch (error) {
if (ctx.isLatest('fetch-items', requestId)) {
ctx.setRuntime('item', {
status: 'error',
error: 'Failed to load options. Please try again.'
});
}
}
}
}Because reactions are pure configuration objects, they're easy to test without rendering components. Mock the context, call the run function, assert on side effects. No need for complex integration tests for orchestration logic.
// addressReactions.test.ts
import { addressReactions } from './addressReactions';
describe('addressReactions', () => {
it('should load states when country changes', async () => {
const mockCtx = {
getValue: jest.fn().mockReturnValue('United States'),
setRuntime: jest.fn(),
beginAsync: jest.fn().mockReturnValue(1),
isLatest: jest.fn().mockReturnValue(true),
};
const reaction = addressReactions.find(r => r.id === 'load-states');
await reaction.run(mockCtx);
expect(mockCtx.setRuntime).toHaveBeenCalledWith('state', {
status: 'ready',
data: { options: expect.any(Array) }
});
});
});The reactive engine uses O(1) lookups for field-to-reaction mapping and Valtio's per-field subscriptions to minimize re-renders. Most forms need no optimization. For very large or complex forms:
when conditions: Skip expensive reactions when inputs aren't readybeginAsync/isLatest: For any async operation that can be outdated"load-cities-when-state-changes" is better than "reaction1"