Blog/Behind the Code/Building a Configurable Form Engine with Conditional Logic
POST
November 08, 2025
LAST UPDATEDNovember 08, 2025

Building a Configurable Form Engine with Conditional Logic

How we built a JSON-driven form engine for a mobile audit app with 200+ field types, conditional show/hide logic, nested repeaters, offline validation, and dynamic calculations.

Tags

Form BuilderReact NativeArchitectureEnterprise
Building a Configurable Form Engine with Conditional Logic
8 min read

Building a Configurable Form Engine with Conditional Logic

TL;DR

A JSON schema-driven form engine with a rule evaluation pipeline handled 200+ field configurations and complex conditional logic, letting non-technical teams create and modify audit forms without developer involvement. The engine supported drag-and-drop form building, conditional show/hide logic, branching workflows, scored assessments, offline-capable data capture, and industry-specific templates. Built in React Native, it ran on both iOS and Android tablets used by field auditors in environments with unreliable connectivity.

The Challenge

I was building an enterprise audit application used by field inspectors across multiple industries: food safety, workplace safety, environmental compliance, and quality assurance. Each industry had its own regulatory requirements, audit templates, and scoring methodologies.

The core problem was form rigidity. Every time a client needed a new audit template or a modification to an existing one, it required a developer to write code, test it, and push a release. For a mobile app distributed through enterprise MDM (Mobile Device Management), this release cycle took weeks. Clients were frustrated because a simple change, like adding a new question to an audit checklist, required a development sprint.

The requirements were demanding:

  • Non-technical administrators needed to build and modify forms through a drag-and-drop interface
  • Forms needed conditional logic: show question B only if question A is answered "Yes," skip entire sections based on prior responses
  • Branching workflows needed to route auditors through different paths based on inspection type, findings, or risk scores
  • Assessments needed automatic scoring with weighted questions and pass/fail thresholds
  • The mobile app needed to work fully offline, with forms and responses stored locally and synced when connectivity was available
  • Templates needed to be versioned so in-progress audits were not broken by template updates

The Architecture

JSON Schema Design

The form engine was driven by a JSON schema that described every aspect of a form: fields, layout, validation, conditional logic, and scoring. The schema was the single source of truth; the rendering engine, validation engine, and scoring engine all consumed the same schema.

typescript
// Form schema structure
interface FormSchema {
  id: string;
  version: number;
  title: string;
  industry: string;
  sections: Section[];
  scoringConfig?: ScoringConfig;
  metadata: {
    createdBy: string;
    createdAt: string;
    templateId: string;
  };
}
 
interface Section {
  id: string;
  title: string;
  description?: string;
  fields: Field[];
  conditions?: Condition[];  // section-level visibility
  repeatable?: boolean;       // for repeating sections
  maxRepetitions?: number;
}
 
interface Field {
  id: string;
  type: FieldType;
  label: string;
  required: boolean;
  helpText?: string;
  placeholder?: string;
  validation?: ValidationRule[];
  conditions?: Condition[];     // field-level visibility
  scoring?: FieldScoring;       // scoring weight and mapping
  properties: FieldProperties;  // type-specific config
}
 
type FieldType =
  | 'text'
  | 'textarea'
  | 'number'
  | 'select'
  | 'multi-select'
  | 'radio'
  | 'checkbox'
  | 'date'
  | 'time'
  | 'datetime'
  | 'photo'
  | 'signature'
  | 'slider'
  | 'location'
  | 'barcode'
  | 'temperature'
  | 'file-attachment';

A real-world food safety audit template looked like this in schema form:

json
{
  "id": "food-safety-daily-v3",
  "version": 3,
  "title": "Daily Food Safety Inspection",
  "industry": "food-safety",
  "sections": [
    {
      "id": "temperature-checks",
      "title": "Temperature Monitoring",
      "fields": [
        {
          "id": "fridge-temp",
          "type": "temperature",
          "label": "Walk-in Refrigerator Temperature",
          "required": true,
          "validation": [
            {
              "type": "range",
              "min": -5,
              "max": 50,
              "unit": "celsius",
              "message": "Temperature must be between -5°C and 50°C"
            }
          ],
          "scoring": {
            "weight": 10,
            "passCondition": { "operator": "lte", "value": 4 },
            "criticalFail": { "operator": "gt", "value": 8 }
          },
          "properties": {
            "unit": "celsius",
            "precision": 1
          }
        },
        {
          "id": "fridge-corrective-action",
          "type": "textarea",
          "label": "Corrective Action Taken",
          "required": true,
          "conditions": [
            {
              "field": "fridge-temp",
              "operator": "gt",
              "value": 4,
              "action": "show"
            }
          ],
          "properties": {
            "minLength": 20,
            "maxLength": 500
          }
        },
        {
          "id": "fridge-photo",
          "type": "photo",
          "label": "Photo Evidence of Corrective Action",
          "required": true,
          "conditions": [
            {
              "field": "fridge-temp",
              "operator": "gt",
              "value": 8,
              "action": "show"
            }
          ],
          "properties": {
            "maxPhotos": 3,
            "requireTimestamp": true,
            "requireLocation": true
          }
        }
      ]
    }
  ]
}

Notice the conditional logic: the corrective action text field only appears when the temperature exceeds 4 degrees, and the photo evidence field only appears when it exceeds 8 degrees (a critical failure). The auditor sees only what is relevant to their current situation.

Rule Evaluation Engine

The conditional logic engine evaluated rules on every form state change. This was the most performance-sensitive part of the system because complex forms could have hundreds of interdependent conditions.

typescript
// rule-engine.ts
interface Condition {
  field: string;             // source field ID
  operator: ConditionOperator;
  value: unknown;
  action: 'show' | 'hide' | 'require' | 'disable';
  logicGate?: 'AND' | 'OR'; // for compound conditions
}
 
type ConditionOperator =
  | 'eq' | 'neq'
  | 'gt' | 'gte' | 'lt' | 'lte'
  | 'contains' | 'not_contains'
  | 'in' | 'not_in'
  | 'is_empty' | 'is_not_empty';
 
class RuleEngine {
  private dependencyGraph: Map<string, Set<string>>;
 
  constructor(schema: FormSchema) {
    // Build dependency graph at initialization for efficient evaluation
    this.dependencyGraph = this.buildDependencyGraph(schema);
  }
 
  private buildDependencyGraph(schema: FormSchema): Map<string, Set<string>> {
    const graph = new Map<string, Set<string>>();
 
    for (const section of schema.sections) {
      for (const field of section.fields) {
        if (!field.conditions) continue;
 
        for (const condition of field.conditions) {
          if (!graph.has(condition.field)) {
            graph.set(condition.field, new Set());
          }
          graph.get(condition.field)!.add(field.id);
        }
      }
    }
    return graph;
  }
 
  evaluateVisibility(
    fieldId: string,
    conditions: Condition[],
    formState: FormState
  ): boolean {
    if (!conditions || conditions.length === 0) return true;
 
    const logicGate = conditions[0].logicGate ?? 'AND';
 
    if (logicGate === 'AND') {
      return conditions.every(c => this.evaluateCondition(c, formState));
    }
    return conditions.some(c => this.evaluateCondition(c, formState));
  }
 
  private evaluateCondition(
    condition: Condition,
    formState: FormState
  ): boolean {
    const fieldValue = formState.getValue(condition.field);
 
    switch (condition.operator) {
      case 'eq':
        return fieldValue === condition.value;
      case 'neq':
        return fieldValue !== condition.value;
      case 'gt':
        return typeof fieldValue === 'number' && fieldValue > (condition.value as number);
      case 'gte':
        return typeof fieldValue === 'number' && fieldValue >= (condition.value as number);
      case 'lt':
        return typeof fieldValue === 'number' && fieldValue < (condition.value as number);
      case 'lte':
        return typeof fieldValue === 'number' && fieldValue <= (condition.value as number);
      case 'contains':
        return typeof fieldValue === 'string' &&
          fieldValue.toLowerCase().includes((condition.value as string).toLowerCase());
      case 'in':
        return Array.isArray(condition.value) && condition.value.includes(fieldValue);
      case 'is_empty':
        return fieldValue === null || fieldValue === undefined || fieldValue === '';
      case 'is_not_empty':
        return fieldValue !== null && fieldValue !== undefined && fieldValue !== '';
      default:
        return true;
    }
  }
 
  // Returns only the fields affected by a change to optimize re-evaluation
  getAffectedFields(changedFieldId: string): Set<string> {
    const affected = new Set<string>();
    const queue = [changedFieldId];
 
    while (queue.length > 0) {
      const current = queue.shift()!;
      const dependents = this.dependencyGraph.get(current);
 
      if (dependents) {
        for (const dep of dependents) {
          if (!affected.has(dep)) {
            affected.add(dep);
            queue.push(dep); // cascading dependencies
          }
        }
      }
    }
 
    return affected;
  }
}

The dependency graph was crucial for performance. When a user changed the value of one field, instead of re-evaluating every condition on the form, we only re-evaluated conditions on fields that depended (directly or transitively) on the changed field. For a form with 200+ fields, this reduced rule evaluation from scanning every condition to processing only the affected subset.

Scoring Engine

Audit forms needed automated scoring with weighted questions, section scores, and overall pass/fail determination:

typescript
// scoring-engine.ts
interface ScoringConfig {
  method: 'weighted' | 'percentage' | 'points';
  passingThreshold: number;
  criticalFailThreshold?: number;
  sectionWeights?: Record<string, number>;
}
 
interface FieldScoring {
  weight: number;
  passCondition: ScoreCondition;
  criticalFail?: ScoreCondition;
  scoreMapping?: Record<string, number>; // for select/radio fields
}
 
class ScoringEngine {
  calculateScore(
    schema: FormSchema,
    formState: FormState,
    ruleEngine: RuleEngine
  ): AuditScore {
    const sectionScores: SectionScore[] = [];
    let hasCriticalFail = false;
 
    for (const section of schema.sections) {
      let sectionPoints = 0;
      let sectionMaxPoints = 0;
      const findings: Finding[] = [];
 
      for (const field of section.fields) {
        // Skip fields that are hidden by conditions — don't score invisible fields
        if (!ruleEngine.evaluateVisibility(field.id, field.conditions ?? [], formState)) {
          continue;
        }
 
        if (!field.scoring) continue;
 
        const value = formState.getValue(field.id);
        const fieldScore = this.scoreField(field, value);
 
        sectionMaxPoints += field.scoring.weight;
        sectionPoints += fieldScore.points;
 
        if (fieldScore.isCriticalFail) {
          hasCriticalFail = true;
          findings.push({
            fieldId: field.id,
            severity: 'critical',
            message: `Critical failure: ${field.label}`,
            value,
          });
        } else if (!fieldScore.passed) {
          findings.push({
            fieldId: field.id,
            severity: 'non-conformance',
            message: `Non-conformance: ${field.label}`,
            value,
          });
        }
      }
 
      sectionScores.push({
        sectionId: section.id,
        sectionTitle: section.title,
        points: sectionPoints,
        maxPoints: sectionMaxPoints,
        percentage: sectionMaxPoints > 0
          ? (sectionPoints / sectionMaxPoints) * 100
          : 100,
        findings,
      });
    }
 
    const totalPoints = sectionScores.reduce((sum, s) => sum + s.points, 0);
    const totalMaxPoints = sectionScores.reduce((sum, s) => sum + s.maxPoints, 0);
    const overallPercentage = totalMaxPoints > 0
      ? (totalPoints / totalMaxPoints) * 100
      : 100;
 
    return {
      overallPercentage,
      passed: !hasCriticalFail &&
        overallPercentage >= (schema.scoringConfig?.passingThreshold ?? 80),
      hasCriticalFail,
      sectionScores,
      totalPoints,
      totalMaxPoints,
    };
  }
 
  private scoreField(
    field: Field,
    value: unknown
  ): { points: number; passed: boolean; isCriticalFail: boolean } {
    const scoring = field.scoring!;
 
    // Check critical failure first
    if (scoring.criticalFail && this.meetsCondition(value, scoring.criticalFail)) {
      return { points: 0, passed: false, isCriticalFail: true };
    }
 
    // Check pass condition
    const passed = this.meetsCondition(value, scoring.passCondition);
 
    // For select/radio fields, use score mapping
    if (scoring.scoreMapping && typeof value === 'string') {
      const mappedScore = scoring.scoreMapping[value] ?? 0;
      return { points: mappedScore, passed, isCriticalFail: false };
    }
 
    return {
      points: passed ? scoring.weight : 0,
      passed,
      isCriticalFail: false,
    };
  }
}

The key design decision was that hidden fields were excluded from scoring. If a conditional field was not visible (because its conditions were not met), it was not scored. This prevented phantom score deductions from fields the auditor never saw or interacted with.

Offline Capability

Field auditors worked in warehouses, kitchens, construction sites, and remote facilities where cellular and Wi-Fi connectivity was unreliable. The form engine needed to work entirely offline.

typescript
// offline-storage.ts
import AsyncStorage from '@react-native-async-storage/async-storage';
import NetInfo from '@react-native-community/netinfo';
 
class OfflineFormStore {
  // Download and cache form templates for offline use
  async cacheTemplates(templates: FormSchema[]): Promise<void> {
    for (const template of templates) {
      await AsyncStorage.setItem(
        `template:${template.id}:v${template.version}`,
        JSON.stringify(template)
      );
    }
    // Store manifest for offline template listing
    const manifest = templates.map(t => ({
      id: t.id,
      version: t.version,
      title: t.title,
      industry: t.industry,
    }));
    await AsyncStorage.setItem('template:manifest', JSON.stringify(manifest));
  }
 
  // Save in-progress audit locally
  async saveAuditProgress(
    auditId: string,
    formState: FormState,
    metadata: AuditMetadata
  ): Promise<void> {
    const record: OfflineAuditRecord = {
      auditId,
      templateId: metadata.templateId,
      templateVersion: metadata.templateVersion,
      formState: formState.serialize(),
      lastModified: new Date().toISOString(),
      syncStatus: 'pending',
      photos: metadata.capturedPhotos, // stored as local file URIs
    };
 
    await AsyncStorage.setItem(`audit:${auditId}`, JSON.stringify(record));
 
    // Update pending sync queue
    const queue = await this.getSyncQueue();
    if (!queue.includes(auditId)) {
      queue.push(auditId);
      await AsyncStorage.setItem('sync:queue', JSON.stringify(queue));
    }
  }
 
  // Sync pending audits when connectivity is restored
  async syncPendingAudits(): Promise<SyncResult[]> {
    const networkState = await NetInfo.fetch();
    if (!networkState.isConnected) {
      return [];
    }
 
    const queue = await this.getSyncQueue();
    const results: SyncResult[] = [];
 
    for (const auditId of queue) {
      try {
        const record = await this.getAuditRecord(auditId);
        if (!record) continue;
 
        // Upload photos first
        const uploadedPhotos = await this.uploadPhotos(record.photos);
 
        // Replace local photo URIs with remote URLs in form state
        const updatedState = this.replacePhotoUris(
          record.formState,
          uploadedPhotos
        );
 
        // Submit audit to API
        await apiClient.submitAudit({
          auditId: record.auditId,
          templateId: record.templateId,
          templateVersion: record.templateVersion,
          formData: updatedState,
          submittedAt: record.lastModified,
        });
 
        // Mark as synced
        record.syncStatus = 'synced';
        await AsyncStorage.setItem(`audit:${auditId}`, JSON.stringify(record));
        results.push({ auditId, status: 'success' });
 
      } catch (error) {
        results.push({ auditId, status: 'failed', error: error.message });
      }
    }
 
    // Remove synced audits from queue
    const remaining = queue.filter(
      id => !results.some(r => r.auditId === id && r.status === 'success')
    );
    await AsyncStorage.setItem('sync:queue', JSON.stringify(remaining));
 
    return results;
  }
}

Template versioning was critical for offline scenarios. When a template was updated on the server, in-progress audits on the mobile device continued using the version they started with. The app downloaded the new version on next sync, and new audits used the latest template. This prevented the situation where a template change mid-audit would invalidate or reorder questions the auditor had already answered.

Drag-and-Drop Form Builder

The web-based form builder let administrators create and modify templates visually. This was a React application using @dnd-kit for drag-and-drop:

typescript
// FormBuilder.tsx — simplified drag-and-drop builder
import { DndContext, closestCenter } from '@dnd-kit/core';
import {
  SortableContext,
  verticalListSortingStrategy,
} from '@dnd-kit/sortable';
 
function FormBuilder({ schema, onChange }: FormBuilderProps) {
  const handleDragEnd = (event: DragEndEvent) => {
    const { active, over } = event;
    if (!over || active.id === over.id) return;
 
    const updatedSections = reorderFields(
      schema.sections,
      active.id as string,
      over.id as string
    );
 
    onChange({ ...schema, sections: updatedSections });
  };
 
  return (
    <div className="flex gap-6">
      {/* Field palette — drag new fields from here */}
      <FieldPalette fieldTypes={availableFieldTypes} />
 
      {/* Form canvas — drop and arrange fields */}
      <DndContext collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
        <div className="flex-1 space-y-4">
          {schema.sections.map(section => (
            <SectionEditor key={section.id} section={section}>
              <SortableContext
                items={section.fields.map(f => f.id)}
                strategy={verticalListSortingStrategy}
              >
                {section.fields.map(field => (
                  <SortableFieldCard
                    key={field.id}
                    field={field}
                    onEdit={(updated) => handleFieldUpdate(section.id, updated)}
                    onDelete={() => handleFieldDelete(section.id, field.id)}
                  />
                ))}
              </SortableContext>
            </SectionEditor>
          ))}
        </div>
      </DndContext>
 
      {/* Configuration panel — edit selected field properties */}
      <FieldConfigPanel
        selectedField={selectedField}
        allFields={getAllFields(schema)}
        onUpdate={handleFieldUpdate}
      />
    </div>
  );
}

The configuration panel let administrators set up conditional logic by selecting a source field, an operator, and a value from dropdowns. This was far more accessible than writing JSON by hand and eliminated syntax errors.

Key Decisions & Trade-offs

JSON schema vs. code-generated forms. The JSON schema approach added a schema parsing and validation layer that code-generated forms would not need. The trade-off was justified because it decoupled form definitions from the app release cycle. Template changes shipped instantly through a configuration update instead of requiring a new app build.

Client-side rule evaluation vs. server-side. Rules evaluated on the device meant forms worked offline with full conditional logic. The trade-off was that complex forms with deep dependency chains could cause noticeable evaluation delays on older tablets. The dependency graph optimization mitigated this, but we still had to set a practical limit on condition nesting depth.

AsyncStorage vs. SQLite for offline storage. AsyncStorage was simpler to implement and sufficient for our data sizes (individual audit records, not bulk data). SQLite would have provided query capabilities and better performance for large datasets. For our use case, where we read and wrote complete audit records, AsyncStorage's key-value model was adequate.

Template versioning vs. template migration. We chose to let in-progress audits keep their original template version rather than migrating them to the new version. This was simpler and safer (no risk of data loss during migration) but meant that audit reports could contain data from old template versions, requiring version-aware report generation.

Results & Outcomes

The form engine eliminated the development bottleneck for template changes. Client administrators created and modified audit templates independently, reducing the turnaround for template changes from weeks (developer sprint + app release) to hours.

The conditional logic system reduced audit completion time because auditors only saw questions relevant to their current inspection context. A clean temperature reading meant fewer follow-up fields; a critical finding triggered the full corrective action workflow automatically.

Offline capability was the feature auditors valued most. Field inspections in warehouses, kitchens, and construction sites no longer required workarounds like paper backup forms or tethering to a mobile hotspot. Audits synced automatically when connectivity was restored.

The scoring engine provided instant audit results on-site, enabling immediate corrective action discussions instead of waiting for back-office score calculations. Critical failures triggered real-time notifications to management even before the auditor left the site (on sync).

What I'd Do Differently

Use a proper state machine for branching workflows. Our branching logic was implemented as conditional visibility on sections, which worked but became difficult to reason about for complex multi-path audits. A formal state machine (like XState) would have made workflow paths explicit and testable.

Implement collaborative editing for the form builder. Multiple administrators occasionally edited the same template simultaneously, and our "last write wins" approach caused lost work. Operational transforms or CRDTs for the form builder would have prevented conflicts.

Build a form preview mode on mobile earlier. Administrators built forms on the web builder but could only verify the mobile rendering by deploying the template and opening it on a device. A mobile preview mode accessible from the builder would have shortened the feedback loop.

Invest in form analytics from the start. We did not track which fields were most frequently left blank, which conditional paths were most commonly triggered, or which sections took the longest to complete. This data would have helped administrators optimize their templates and identify training gaps for auditors.

FAQ

What is a JSON-driven form engine?

A form engine renders forms dynamically from JSON configuration files that describe field types, validation rules, layout, and conditional logic. This separates form definition from code, allowing form changes through configuration without app releases. The JSON schema is the single source of truth consumed by the rendering engine (which draws the UI), the validation engine (which enforces constraints), and the scoring engine (which calculates audit results). Non-technical users modify forms through a visual builder that generates valid JSON, eliminating the need for developer involvement in template changes.

How does conditional logic work in a form engine?

Each field can have visibility and validation conditions defined as rule objects (for example, show field B when field A equals "yes"). A rule evaluation engine processes these conditions on every form state change, toggling field visibility and adjusting validation requirements in real time. Performance is maintained through a dependency graph built at form initialization: when a field value changes, only the fields that directly or transitively depend on it are re-evaluated, rather than scanning every condition on the form. Conditions support compound logic (AND/OR gates) and can cascade, where changing one field's visibility triggers further conditions on dependent fields.

How do you handle nested repeatable sections?

Repeaters are implemented as recursive form sections that can be added or removed dynamically. Each instance gets a unique key for state management, and validation runs independently per instance. The JSON schema supports arbitrary nesting depth with performance guards to prevent excessive DOM rendering. When a repeater instance is added, the engine creates a new scoped state container for that instance, isolating its values and validation state from other instances. Conditional logic within a repeater evaluates against its own instance's values, so showing a follow-up field based on a question answer works correctly even when multiple instances have different answers.

Collaboration

Need help with a project?

Let's Build It

I help startups and established companies design, build, and scale world-class digital products. From deep technical architecture to pixel-perfect UI — let's bring your vision to life.

SH

Article Author

Sadam Hussain

Senior Full Stack Developer

Senior Full Stack Developer with over 7 years of experience building React, Next.js, Node.js, TypeScript, and AI-powered web platforms.

Related Articles

Optimizing Core Web Vitals for e-Commerce
Mar 01, 202610 min read
SEO
Performance
Next.js

Optimizing Core Web Vitals for e-Commerce

Our journey to scoring 100 on Google PageSpeed Insights for a major Shopify-backed e-commerce platform.

Building an AI-Powered Interview Feedback System
Feb 22, 20269 min read
AI
LLM
Feedback

Building an AI-Powered Interview Feedback System

How we built an AI-powered system that analyzes mock interview recordings and generates structured feedback on communication, technical accuracy, and problem-solving approach using LLMs.

Migrating from Pages to App Router
Feb 15, 20268 min read
Next.js
Migration
Case Study

Migrating from Pages to App Router

A detailed post-mortem on migrating a massive enterprise dashboard from Next.js Pages Router to the App Router.