Dependency Linkage
Framework: React Vue Svelte Solid
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
};
Return Value
The callback can return the following:
type OnDepsChangeReturn = Partial <{
patch : Record < string , unknown >; // Form data patch
schema : FieldSchema ; // Updated field configuration
}>;
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:
Trigger limit : The linkage chain triggers at most 20 times, then automatically stops
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:
Trigger count limit : Maximum 20 triggers per linkage
Recursion detection : Automatically detects and prevents infinite recursion
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 ] } }
})
}
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
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