Dependency Linkage

Framework:

Dependency linkage allows you to declare relationships between fields, where changes to dependent fields automatically trigger linkage logic. This chapter explains the configuration and usage of dependency linkage.

Dependency Declaration

Use the deps array in the field configuration to declare dependent fields. The onDepsChange callback is triggered when dependencies change.

const schema = [
  {
    type: 'string',
    name: 'province',
    label: 'Province'
  },
  {
    type: 'string',
    name: 'city',
    label: 'City',
    deps: ['province'],      // Declare dependency
    onDepsChange: async ({ deps, schema }) => {
      // Triggered when province changes
      const cities = await getCitiesByProvince(deps[0]);
      return {
        patch: { city: '' },  // Clear city value
        schema: {
          ...schema,
          props: { options: cities }
        }
      };
    }
  }
];

Cascade Selection

Implement cascade selection through dependency linkage, where the city list updates when the province changes.

onDepsChange Callback

Parameters

The callback receives the following parameters:

type OnDepsChangeParams = {
  deps: Array<any>;                    // Current values of dependent fields
  schema: FieldSchema;                 // Current field configuration
  name: string;                        // Current field name
  value: unknown;                      // Current field value
  data: Readonly<Record<string, unknown>>;  // Latest form data
  isInitialTrigger: boolean;          // Whether it's the initial trigger
  trigger: string;                     // Name of the field that triggered the callback
  triggerCount: number;               // Number of times triggered by this trigger field
  totalTriggerCount: number;          // Total number of times triggered by all dependent fields
};
ParameterTypeDescription
depsArray<any>Current values of dependent fields
schemaFieldSchemaCurrent field configuration (recommend using lodash's set method to update)
namestringCurrent field name
valueunknownCurrent field value
dataReadonly<Record<string, unknown>>Latest form data
isInitialTriggerbooleanWhether it's the initial trigger
triggerstringName of the field that triggered the callback
triggerCountnumberNumber of times triggered by this trigger field
totalTriggerCountnumberTotal number of times triggered by all dependent fields

Return Value

The callback can return the following:

type OnDepsChangeReturn = Partial<{
  patch: Record<string, unknown>;  // Form data patch
  schema: FieldSchema;             // Updated field configuration
}>;
Return ValueTypeDescription
patchRecord<string, unknown>Partial update to form data (only return fields that need to be merged, will be merged into existing form data)
schemaFieldSchemaUpdated field configuration (completely replaces current field's schema)

Example:

// Original data: {name: 'John', age: 20}
// patch: {age: 21}
// Merged result: {name: 'John', age: 21}

Chain Linkage & Circular Dependency Protection

Chain Linkage

When field A's linkage callback modifies field B's value, and field B is a dependency of field C, chain linkage is automatically triggered. Schema FormX has a built-in maximum of 20 linkage trigger limit. When exceeded, the chain is automatically terminated and an over maxTimes error is logged to the console.

Circular Dependency Protection

If fields form circular dependencies (e.g., A depends on B, B depends on A), it causes infinite recursion. Schema FormX protects against this through:

  1. Trigger limit: The linkage chain triggers at most 20 times, then automatically stops
  2. Monitoring parameters: Use triggerCount and totalTriggerCount to monitor linkage depth
// Monitor linkage depth
onDepsChange: ({ totalTriggerCount }) => {
  if (totalTriggerCount > 10) {
    console.warn('Too many linkage triggers, possible circular dependency');
    return {};
  }
  // Normal linkage logic
}

Recommendation: Ensure dependencies are unidirectional. Avoid circular dependency patterns like A→B→A.

Cascade Example

Important: Unlike validator, exceptions in onDepsChange callbacks are NOT automatically caught. If an error occurs (e.g., async request failure), the exception will be thrown upward. It is recommended to use try-catch in onDepsChange:

onDepsChange: async ({ deps, schema }) => {
  try {
    const data = await fetchData(deps[0]);
    return { schema: { ...schema, props: { options: data } } };
  } catch (error) {
    console.error('Failed to load linkage data:', error);
    return {};  // Return empty object to keep current state
  }
}

Cascade Example

const schema = [
  {
    type: 'string',
    name: 'country',
    label: 'Country',
    props: {
      options: ['China', 'USA', 'Japan']
    }
  },
  {
    type: 'string',
    name: 'city',
    label: 'City',
    deps: ['country'],
    onDepsChange: async ({ deps, schema }) => {
      const cityMap = {
        China: ['Beijing', 'Shanghai', 'Guangzhou'],
        USA: ['New York', 'Los Angeles', 'Chicago'],
        Japan: ['Tokyo', 'Osaka', 'Kyoto']
      };
      const cities = cityMap[deps[0]] || [];
      return {
        patch: { city: '' },
        schema: {
          ...schema,
          props: { options: cities }
        }
      };
    }
  }
];

Conditional Display

Show or hide fields based on dependency values:

const schema = [
  {
    type: 'boolean',
    name: 'showContact',
    label: 'Show Contact Info'
  },
  {
    type: 'string',
    name: 'phone',
    label: 'Phone',
    deps: ['showContact'],
    onDepsChange: async ({ deps, schema }) => {
      return {
        schema: {
          ...schema,
          hidden: !deps[0]
        }
      };
    }
  },
  {
    type: 'string',
    name: 'email',
    label: 'Email',
    deps: ['showContact'],
    onDepsChange: async ({ deps, schema }) => {
      return {
        schema: {
          ...schema,
          hidden: !deps[0]
        }
      };
    }
  }
];

Dynamic Options

Dynamically load options based on dependency values:

const schema = [
  {
    type: 'string',
    name: 'category',
    label: 'Category',
    props: {
      options: ['Electronics', 'Clothing', 'Food']
    }
  },
  {
    type: 'string',
    name: 'product',
    label: 'Product',
    deps: ['category'],
    onDepsChange: async ({ deps, schema }) => {
      const productMap = {
        Electronics: ['Phone', 'Computer', 'Tablet'],
        Clothing: ['Shirt', 'Pants', 'Shoes'],
        Food: ['Fruit', 'Snack', 'Drink']
      };
      const products = productMap[deps[0]] || [];
      return {
        patch: { product: '' },
        schema: {
          ...schema,
          props: { options: products }
        }
      };
    }
  }
];

Dynamic Validation

Dynamically adjust validation rules based on other field values:

{
  type: 'string',
  name: 'endDate',
  label: 'End Date',
  deps: ['startDate'],
  onDepsChange: ({ deps, schema }) => {
    const startDate = deps[0];
    return {
      schema: {
        ...schema,
        validator: ({ value, label }) => {
          if (!value) return `${label} is required`;
          if (startDate && value < startDate) {
            return `${label} must be after start date`;
          }
          return undefined;
        }
      }
    };
  }
}

Circular Dependency Protection

Automatic Protection Mechanism

Schema FormX has built-in circular dependency protection:

  1. Trigger count limit: Maximum 20 triggers per linkage
  2. Recursion detection: Automatically detects and prevents infinite recursion
  3. Error output: Outputs error message to console when limit is exceeded
// Protection logic in source code
const maxTimes = 20;
let times = 0;

// When limit is exceeded
if (times > maxTimes) {
  console.error('over maxTimes');
  break;
}

Common Circular Dependency Scenarios

Wrong Example:

// ❌ Wrong: A depends on B, B depends on A
const schema = [
  {
    name: 'fieldA',
    deps: ['fieldB'],
    onDepsChange: ({ deps }) => {
      return { patch: { fieldA: deps[0] } };
    }
  },
  {
    name: 'fieldB',
    deps: ['fieldA'],
    onDepsChange: ({ deps }) => {
      return { patch: { fieldB: deps[0] } };
    }
  }
];

Correct Example:

// ✅ Correct: Unidirectional dependency
const schema = [
  {
    name: 'province',
    label: 'Province'
  },
  {
    name: 'city',
    label: 'City',
    deps: ['province'],
    onDepsChange: async ({ deps, schema }) => {
      const cities = await getCities(deps[0]);
      return { schema: { ...schema, props: { options: cities } } };
    }
  }
];

triggerCount Parameter

The triggerCount parameter helps identify circular dependencies:

{
  name: 'field',
  deps: ['dep1', 'dep2'],
  onDepsChange: ({ trigger, triggerCount, totalTriggerCount }) => {
    console.log(`Trigger field: ${trigger}`);
    console.log(`This field trigger count: ${triggerCount}`);
    console.log(`Total trigger count: ${totalTriggerCount}`);
    
    // If trigger count is too high, may be circular dependency
    if (totalTriggerCount > 10) {
      console.warn('Trigger count too high, may have circular dependency');
      return {};
    }
    
    // Normal linkage logic
  }
}

Chain Linkage

Automatic Cascading Triggers

When fields in patch are dependencies of other fields, chain linkage is automatically triggered:

// Province → City → District
const schema = [
  { name: 'province', label: 'Province' },
  {
    name: 'city',
    label: 'City',
    deps: ['province'],
    onDepsChange: async ({ deps, schema }) => {
      const cities = await getCities(deps[0]);
      return { 
        patch: { city: '' },  // Clear city
        schema: { ...schema, props: { options: cities } } 
      };
    }
  },
  {
    name: 'district',
    label: 'District',
    deps: ['city'],
    onDepsChange: async ({ deps, schema }) => {
      const districts = await getDistricts(deps[0]);
      return { 
        schema: { ...schema, props: { options: districts } } 
      };
    }
  }
];

// Linkage flow:
// 1. User selects province
// 2. Triggers city's onDepsChange, returns patch and new schema
// 3. City in patch changes, triggers district's onDepsChange
// 4. Continues until all linkages complete

Chain Linkage Limitations

  • Maximum 20 linkage triggers
  • Exceeding limit stops and outputs error
  • Recommended to keep linkage depth within 3-4 levels

Async Linkage

Async Callbacks

Linkage callbacks support async operations:

onDepsChange: async ({ deps, schema }) => {
  const result = await fetchData(deps[0]);
  
  return {
    schema: {
      ...schema,
      props: { options: result }
    }
  };
}

Error Handling

Async linkage needs error handling:

onDepsChange: async ({ deps, schema }) => {
  try {
    const result = await fetchData(deps[0]);
    return {
      schema: {
        ...schema,
        props: { options: result }
      }
    };
  } catch (error) {
    console.error('Linkage failed:', error);
    return {};  // Return empty object, keep current config
  }
}

Best Practice: It is recommended to encapsulate async data fetching logic inside custom components (e.g., in the component's useEffect), and only pass data identifiers (e.g., categoryId) via schema.props in onDepsChange. This provides better control over loading states, error handling, and caching.

// ✅ Recommended: Component handles async logic internally
const ProductSelect = ({ value, onChange, categoryId }) => {
  const [options, setOptions] = useState([]);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    if (!categoryId) return;
    setLoading(true);
    fetchProducts(categoryId)
      .then(setOptions)
      .catch(console.error)
      .finally(() => setLoading(false));
  }, [categoryId]);

  return <Select value={value} onChange={onChange} options={options} loading={loading} />;
};

// Schema only passes the identifier
{
  type: 'string',
  name: 'product',
  component: 'ProductSelect',
  deps: ['category'],
  onDepsChange: ({ deps, schema }) => ({
    schema: { ...schema, props: { ...schema.props, categoryId: deps[0] } }
  })
}

Performance Optimization

Debounce Handling

For frequently triggered linkage, use debounce:

import { debounce } from 'lodash-es';

const debouncedFetch = debounce(async (value) => {
  return await fetchData(value);
}, 300);

{
  type: 'string',
  name: 'search',
  label: 'Search',
  deps: ['keyword'],
  onDepsChange: async ({ deps, schema }) => {
    const keyword = deps[0];
    const results = await debouncedFetch(keyword);
    
    return {
      schema: {
        ...schema,
        props: { options: results }
      }
    };
  }
}

Cache Results

For requests with the same parameters, cache the results:

const cache = new Map();

{
  type: 'string',
  name: 'product',
  label: 'Product',
  deps: ['category'],
  onDepsChange: async ({ deps, schema }) => {
    const category = deps[0];
    
    if (cache.has(category)) {
      return {
        schema: {
          ...schema,
          props: { options: cache.get(category) }
        }
      };
    }
    
    const products = await fetchProducts(category);
    cache.set(category, products);
    
    return {
      schema: {
        ...schema,
        props: { options: products }
      }
    };
  }
}

Best Practices

Dependency Management

  • Keep dependency chains simple
  • Ensure dependencies are unidirectional
  • Avoid circular dependencies
  • Use async operations for server-side data

Performance Optimization

  • Avoid expensive operations in onDepsChange
  • Use caching for dependency data
  • Handle loading states
  • Use debouncing for frequent triggers
  • Cache results for same request parameters

Error Handling

  • Handle network errors gracefully with try-catch
  • Provide fallback data
  • Show loading or error indicators
  • Control linkage depth within 3-4 levels