Form Patterns | Dashforge-UI
DocsStarter Kits
v0.1.0-alpha

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

On This Page