Reactions | Dashforge-UI
DocsStarter Kits
v0.1.0-alpha

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:

PropTypeDefaultDescription
idstringrequiredUnique identifier for this reaction
watchstring[]requiredField names to watch for changes
when(ctx) => boolean-Optional condition that must be true to run
run(ctx) => void | Promise<void>requiredEffect to execute when watched fields change

Run Context API

Methods available inside reactions

The context object passed to run provides these methods:

PropTypeDefaultDescription
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.

On This Page