Patterns & Best Practices
Guidelines for building maintainable, scalable forms with Dashforge.
Organize Reactions by Purpose
Group by responsibility, not by field
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>Separate UI from Orchestration
Dashforge enforces clean boundaries
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>
);
}Avoid Deep Dependencies
Keep dependency chains shallow
⚠️
Forms with 4+ levels of dependencies (A → B → C → D → E) become difficult to reason about and debug. Consider splitting into multiple steps or forms.
- Good: Country → State → City (3 levels)
- Problematic: Region → Country → State → City → District → Neighborhood (6 levels)
- Solution: Split into multiple forms or use a wizard/stepper pattern
Error Handling
Handle failures gracefully
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.'
});
}
}
}
}Testing Strategy
Reactions are testable in isolation
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) }
});
});
});Performance Considerations
Dashforge is optimized by default
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:
- Debounce expensive operations: For search or API calls, debounce watch fields
- Use when conditions: Skip expensive reactions when inputs aren't ready
- Avoid unnecessary re-renders: Split large forms into sections with their own contexts
- Cache API responses: Store fetched data at a higher level if it's reused
- Lazy load sections: For very large forms, use code splitting
Golden Rules
Core principles to follow
- Reactions should be pure side effects: No component state, no refs, only form values and runtime state
- Clear dependent fields explicitly: Don't assume the system will clear them
- Always use beginAsync/isLatest: For any async operation that can be outdated
- Handle null/undefined: Users can clear fields at any time
- Keep reaction IDs descriptive: "load-cities-when-state-changes" is better than "reaction1"
- Document complex dependencies: Add comments explaining why dependencies exist