Custom Functions

Extend your forms with custom JavaScript/TypeScript logic for complex operations that go beyond JSON configuration.

🔧

What Are Custom Functions?

Custom functions are JavaScript/TypeScript functions you write for complex logic that's too hard to express in JSON. They have full access to the form context and can perform any operation - API calls, data transformations, complex calculations, and more.

When to Use

Use Custom Functions For:

  • • Complex API calls with error handling
  • • Data transformations (arrays, objects)
  • • Complex conditionals
  • • Date/time manipulation
  • • Multiple steps of logic
  • • Validation against external systems

💡Use Computed Fields For:

  • • Simple math (price × quantity)
  • • Percentages
  • • String concatenation
  • • Rounding numbers
  • • Simple conditionals

Basic Setup

Step 1: Define Your Function

typescript
import { DynamicContext } from '@seanblock/form-builder';

const customFunctions = {
  myFunction: async (context: DynamicContext) => {
    // Your logic here
    
    // Read from context
    const value = context.myField;
    
    // Do something
    const result = await fetch('/api/endpoint');
    const data = await result.json();
    
    // Update context
    context.myField = data.value;
    
    // Return data (optional)
    return data;
  }
};

Step 2: Pass to FormBuilderV3

typescript
<FormBuilderV3
  config={myConfig}
  context={{}}
  customFunctions={customFunctions}
/>

Step 3: Register as a Service

json
{
  "services": {
    "myFunction": {
      "type": "function",
      "function": "myFunction"
    }
  }
}

Step 4: Call from Actions

json
{
  "actions": [
    {
      "type": "callApi",
      "service": "myFunction"
    }
  ]
}

Complete Example: Validate Discount Code

The Function (TypeScript)

typescript
const customFunctions = {
  validateDiscountCode: async (context: DynamicContext) => {
    const code = context.discount?.code?.toUpperCase();
    
    // Call your API
    const response = await fetch('/api/validate-discount', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ code })
    });
    
    const data = await response.json();
    
    // Update context based on response
    if (data.valid) {
      context.discount.percent = data.percent;
      context.discount.isValid = true;
      context.discount.errorMessage = '';
    } else {
      context.discount.percent = 0;
      context.discount.isValid = false;
      context.discount.errorMessage = 'Invalid discount code';
    }
    
    return data;
  }
};

The Configuration (JSON)

json
{
  "context": {
    "discount": {
      "code": "",
      "percent": 0,
      "isValid": false,
      "errorMessage": ""
    }
  },
  "services": {
    "validateDiscountCode": {
      "type": "function",
      "function": "validateDiscountCode"
    }
  },
  "blocks": [
    {
      "id": "discount-input",
      "blockType": "InputBlock",
      "metadata": {
        "label": "Discount Code",
        "contextPath": "discount.code"
      }
    },
    {
      "id": "validate-btn",
      "blockType": "ButtonBlock",
      "metadata": {
        "label": "Validate Code"
      },
      "actions": [
        {
          "type": "callApi",
          "service": "validateDiscountCode"
        }
      ]
    },
    {
      "id": "error-message",
      "blockType": "ParagraphBlock",
      "className": "text-red-600",
      "displayConditions": [
        {
          "matchType": "exact",
          "field": "discount.isValid",
          "value": false
        }
      ],
      "metadata": {
        "text": "{{context:discount.errorMessage}}"
      }
    }
  ]
}

Working with Context

Reading Values

typescript
async (context: DynamicContext) => {
  // Simple values
  const name = context.user.name;
  const email = context.user.email;
  
  // Nested values
  const street = context.address?.street || '';
  
  // Arrays
  const items = context.cart.items || [];
  
  // Numbers
  const quantity = context.order.quantity || 1;
}

Updating Values

typescript
async (context: DynamicContext) => {
  // Simple assignment
  context.user.name = "John Doe";
  
  // Nested object
  context.address = {
    street: "123 Main St",
    city: "New York",
    state: "NY"
  };
  
  // Update existing object
  context.cart.total = calculateTotal(context.cart.items);
  
  // Boolean flags
  context.form.isValid = true;
  context.form.isSubmitting = false;
}

Common Patterns

Pattern 1: API Call with Error Handling

typescript
const customFunctions = {
  fetchUserData: async (context: DynamicContext) => {
    try {
      context.loading = true;
      
      const response = await fetch(`/api/users/${context.userId}`);
      
      if (!response.ok) {
        throw new Error('Failed to fetch user data');
      }
      
      const data = await response.json();
      
      context.user = data;
      context.loading = false;
      context.error = null;
      
      return { success: true, data };
    } catch (error) {
      context.loading = false;
      context.error = error.message;
      
      return { success: false, error: error.message };
    }
  }
};

Pattern 2: Data Transformation

typescript
const customFunctions = {
  calculateShipping: async (context: DynamicContext) => {
    const items = context.cart.items || [];
    
    // Calculate total weight
    const totalWeight = items.reduce((sum, item) => {
      return sum + (item.weight * item.quantity);
    }, 0);
    
    // Get shipping zones
    const response = await fetch('/api/shipping/calculate', {
      method: 'POST',
      body: JSON.stringify({
        weight: totalWeight,
        zipCode: context.address.zipCode
      })
    });
    
    const data = await response.json();
    
    context.cart.shipping = data.cost;
    context.cart.shippingMethod = data.method;
    context.cart.estimatedDays = data.estimatedDays;
    
    return data;
  }
};

Pattern 3: Complex Validation

typescript
const customFunctions = {
  validateForm: async (context: DynamicContext) => {
    const errors: string[] = [];
    
    // Complex business logic
    if (context.user.age < 18 && !context.guardian.present) {
      errors.push('Guardian consent required for minors');
    }
    
    if (context.payment.method === 'credit' && !context.payment.verified) {
      errors.push('Credit card verification required');
    }
    
    // Check against external system
    const response = await fetch('/api/validate-order', {
      method: 'POST',
      body: JSON.stringify(context.order)
    });
    
    const validation = await response.json();
    if (!validation.valid) {
      errors.push(...validation.errors);
    }
    
    context.form.errors = errors;
    context.form.isValid = errors.length === 0;
    
    return {
      valid: errors.length === 0,
      errors
    };
  }
};

Pattern 4: Date/Time Operations

typescript
const customFunctions = {
  calculateAvailability: async (context: DynamicContext) => {
    const selectedDate = new Date(context.appointment.date);
    const today = new Date();
    
    // Check if date is in the past
    if (selectedDate < today) {
      context.appointment.error = 'Cannot book past dates';
      return { available: false };
    }
    
    // Check availability
    const response = await fetch(`/api/availability?date=${context.appointment.date}`);
    const data = await response.json();
    
    context.appointment.availableSlots = data.slots;
    context.appointment.nextAvailable = data.nextAvailable;
    
    return data;
  }
};

Combining All Features

Custom functions work seamlessly with computed fields and lifecycle hooks:

typescript
// Custom function
const customFunctions = {
  lookupTaxRate: async (context: DynamicContext) => {
    const response = await fetch(`/api/tax-rate/${context.location.zipCode}`);
    const data = await response.json();
    
    context.location.taxRate = data.rate;
    context.location.city = data.city;
    context.location.state = data.state;
    
    return data;
  }
};
json
{
  "lifecycle": {
    "onContextChange": [
      {
        "watch": ["location.zipCode"],
        "actions": [
          {
            "type": "callApi",
            "service": "lookupTaxRate"
          }
        ]
      }
    ]
  },
  "computedFields": {
    "tax": {
      "targetPath": "order.tax",
      "expression": "context.order.subtotal * context.location.taxRate",
      "dependencies": ["order.subtotal", "location.taxRate"]
    }
  }
}

🎯 The Flow:

  1. 1. User enters ZIP code
  2. 2. Lifecycle hook triggers
  3. 3. Custom function calls API and updates taxRate
  4. 4. Computed field detects dependency change
  5. 5. Tax recalculates automatically!

TypeScript Types

typescript
import { DynamicContext } from '@seanblock/form-builder';

// Custom function signature
type CustomFunction = (context: DynamicContext) => Promise<any>;

// Your functions object
const customFunctions: Record<string, CustomFunction> = {
  myFunction: async (context) => {
    // TypeScript will provide autocomplete for common context patterns
    return { success: true };
  }
};

Best Practices

✅ Always handle errors

Use try/catch and set error flags in context

✅ Set loading states

Update context.loading before/after async operations

✅ Return meaningful data

Return objects with success/error info for action chaining

✅ Provide defaults

Use || [] and || for safety

❌ Don't mutate arrays directly

Create new arrays: context.items = [...oldItems, newItem]

❌ Don't forget async/await

Always use async and await for promises

Debugging

typescript
const customFunctions = {
  myFunction: async (context: DynamicContext) => {
    // Log current context
    console.log('Current context:', context);
    
    // Log specific values
    console.log('User ID:', context.userId);
    
    // Log API responses
    const response = await fetch('/api/endpoint');
    const data = await response.json();
    console.log('API response:', data);
    
    return data;
  }
};

Live Example

🎯 Try It Out

Check out working examples with custom functions: