Dynamic Forms | Dashforge-UI
DocsStarter Kits
v0.1.0-alpha

Dynamic Forms

Build forms that adapt to user input with conditional fields, runtime options, and dependent behavior.

What Are Dynamic Forms?

Beyond static field lists

Dynamic forms change based on user input. Fields appear or disappear, options update in real-time, and validation rules adapt to context. Dashforge provides three primary mechanisms for building dynamic forms:

  • Conditional Visibility: Show/hide fields based on other field values
  • Runtime Options: Load dropdown options dynamically from APIs or reactions
  • Dependent Values: Update field values in response to other fields changing

Conditional Visibility

Show and hide fields without manual rendering logic

Without a system, conditional fields require manual rendering logic, state tracking, and careful cleanup. With visibleWhen, you declare the condition—the system handles rendering, unmounting, and state cleanup automatically.

The function receives the reactive engine and can read any field's current state:

<TextField
  name="companyName"
  label="Company Name"
  visibleWhen={(engine) => 
    engine.getNode('accountType')?.value === 'business'
  }
/>

<Select
  name="industry"
  label="Industry"
  options={industries}
  visibleWhen={(engine) => {
    const accountType = engine.getNode('accountType')?.value;
    const hasCompany = engine.getNode('companyName')?.value != null;
    return accountType === 'business' && hasCompany;
  }}
/>

ℹ️

Fields with visibleWhen automatically re-evaluate when watched fields change. No manual orchestration needed.


Runtime Options

Load dropdown options dynamically without manual state

Manually managing dynamic options requires useState, useEffect, loading flags, and careful prop threading. With optionsFromFieldData, fields read directly from the runtime store—reactions populate the data, fields consume it.

Combine with reactions to fetch options dynamically:

// In your component
const reactions = [
  {
    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 subcategories = await fetchSubcategories(category);

      if (ctx.isLatest('fetch-subcategories', requestId)) {
        ctx.setRuntime('subcategory', {
          status: 'ready',
          data: { options: subcategories }
        });
      }
    }
  }
];

// In your JSX
<DashForm reactions={reactions}>
  <Select
    name="category"
    label="Category"
    options={['Electronics', 'Clothing', 'Books']}
  />

  <Select
    name="subcategory"
    label="Subcategory"
    optionsFromFieldData
    visibleWhen={(engine) => 
      engine.getNode('category')?.value != null
    }
  />
</DashForm>

The field automatically displays loading states and handles empty states when optionsFromFieldData is enabled.


Chained Dependencies

Multiple levels without callback hell

Without orchestration, chained dependencies (Country → State → City) quickly become nested useEffect hooks, each managing its own loading state and cleanup. Dashforge lets you define each level independently—the system coordinates execution order automatically.

Each reaction clears its dependent fields and manages its own async state:

const reactions = [
  {
    id: 'load-states',
    watch: ['country'],
    run: async (ctx) => {
      const country = ctx.getValue<string>('country');
      
      // Clear dependent fields
      ctx.setValue('state', null);
      ctx.setValue('city', null);
      
      if (!country) {
        ctx.setRuntime('state', { status: 'idle', data: null });
        return;
      }

      const requestId = ctx.beginAsync('fetch-states');
      ctx.setRuntime('state', { status: 'loading' });

      const states = await fetchStates(country);

      if (ctx.isLatest('fetch-states', requestId)) {
        ctx.setRuntime('state', {
          status: 'ready',
          data: { options: states }
        });
      }
    }
  },
  {
    id: 'load-cities',
    watch: ['state'],
    run: async (ctx) => {
      const state = ctx.getValue<string>('state');
      
      // Clear dependent field
      ctx.setValue('city', null);
      
      if (!state) {
        ctx.setRuntime('city', { status: 'idle', data: null });
        return;
      }

      const requestId = ctx.beginAsync('fetch-cities');
      ctx.setRuntime('city', { status: 'loading' });

      const cities = await fetchCities(state);

      if (ctx.isLatest('fetch-cities', requestId)) {
        ctx.setRuntime('city', {
          status: 'ready',
          data: { options: cities }
        });
      }
    }
  }
];

<DashForm reactions={reactions}>
  <Select
    name="country"
    label="Country"
    options={countries}
  />

  <Select
    name="state"
    label="State"
    optionsFromFieldData
    visibleWhen={(engine) => 
      engine.getNode('country')?.value != null
    }
  />

  <Autocomplete
    name="city"
    label="City"
    optionsFromFieldData
    visibleWhen={(engine) => 
      engine.getNode('state')?.value != null
    }
  />
</DashForm>

Notice how each reaction clears dependent fields when parent values change. This prevents stale selections.


Calculated Values

Update fields based on other field values

Reactions can update field values directly using setValue:

const reactions = [
  {
    id: 'calculate-total',
    watch: ['quantity', 'price', 'taxRate'],
    run: (ctx) => {
      const quantity = ctx.getValue<number>('quantity') || 0;
      const price = ctx.getValue<number>('price') || 0;
      const taxRate = ctx.getValue<number>('taxRate') || 0;

      const subtotal = quantity * price;
      const tax = subtotal * (taxRate / 100);
      const total = subtotal + tax;

      ctx.setValue('subtotal', subtotal.toFixed(2));
      ctx.setValue('tax', tax.toFixed(2));
      ctx.setValue('total', total.toFixed(2));
    }
  }
];

<DashForm reactions={reactions}>
  <NumberField name="quantity" label="Quantity" />
  <NumberField name="price" label="Unit Price" />
  <NumberField name="taxRate" label="Tax Rate (%)" />
  
  <TextField name="subtotal" label="Subtotal" disabled />
  <TextField name="tax" label="Tax" disabled />
  <TextField name="total" label="Total" disabled />
</DashForm>

Conditional Validation

Change validation rules dynamically

While static validation is handled by React Hook Form's rules prop, you can add dynamic validation through runtime state:

const reactions = [
  {
    id: 'validate-date-range',
    watch: ['startDate', 'endDate'],
    when: (ctx) => {
      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 (end < start) {
        ctx.setRuntime('endDate', {
          status: 'error',
          error: 'End date must be after start date'
        });
      } else {
        ctx.setRuntime('endDate', {
          status: 'ready',
          error: null
        });
      }
    }
  }
];

⚠️

Runtime errors show in the UI but don't prevent form submission. Use React Hook Form rules for blocking validation, and runtime state for non-blocking feedback.


Real-World Example

Putting it all together

A complex registration form with multiple dynamic behaviors:

const reactions = [
  {
    id: 'load-states',
    watch: ['country'],
    run: async (ctx) => {
      const country = ctx.getValue<string>('country');
      ctx.setValue('state', null);
      
      if (!country) return;
      
      const requestId = ctx.beginAsync('states');
      ctx.setRuntime('state', { status: 'loading' });
      const states = await fetchStates(country);
      
      if (ctx.isLatest('states', requestId)) {
        ctx.setRuntime('state', { 
          status: 'ready', 
          data: { options: states } 
        });
      }
    }
  },
  {
    id: 'check-username-availability',
    watch: ['username'],
    run: async (ctx) => {
      const username = ctx.getValue<string>('username');
      
      if (!username || username.length < 3) return;
      
      const requestId = ctx.beginAsync('check-username');
      ctx.setRuntime('username', { status: 'loading' });
      
      const available = await checkUsernameAvailable(username);
      
      if (ctx.isLatest('check-username', requestId)) {
        ctx.setRuntime('username', {
          status: available ? 'ready' : 'error',
          error: available ? null : 'Username already taken'
        });
      }
    }
  }
];

<DashForm reactions={reactions}>
  <TextField
    name="username"
    label="Username"
    rules={{ 
      required: 'Username is required',
      minLength: { value: 3, message: 'Min 3 characters' }
    }}
  />

  <Select
    name="accountType"
    label="Account Type"
    options={['personal', 'business']}
  />

  <TextField
    name="companyName"
    label="Company Name"
    visibleWhen={(engine) => 
      engine.getNode('accountType')?.value === 'business'
    }
    rules={{ required: 'Company name is required' }}
  />

  <Select
    name="country"
    label="Country"
    options={countries}
    rules={{ required: 'Country is required' }}
  />

  <Select
    name="state"
    label="State"
    optionsFromFieldData
    visibleWhen={(engine) => 
      engine.getNode('country')?.value != null
    }
  />
</DashForm>

Best Practices

Guidelines for dynamic forms

  • Clear dependent fields: When a parent field changes, explicitly clear dependent selections to avoid stale values
  • Handle null values: Always check for null/undefined before using field values in visibleWhen or reactions
  • Use loading states: Show loading indicators while fetching options to provide feedback
  • Combine visibility with validation: Only require fields that are currently visible
  • Keep chains shallow: Avoid deeply nested dependencies (3+ levels). Consider splitting into multiple forms
  • Test edge cases: What happens when users quickly change selections? What if APIs fail?

On This Page