Declarative Field Dependencies in React: How Configuration Replaces useEffect Spaghetti
The Problem
Complex forms are everywhere in enterprise applications. Registration forms, checkout flows, survey builders, data entry systems. They all share a common challenge: fields that depend on other fields.
Country selection shows state dropdown. Application type determines available submission types. Certain combinations trigger validation rules or show warnings.
The standard React approach? useEffect hooks watching field changes. For simple cases, this works. For complex forms with 30+ fields and multiple dependency chains? It becomes unmaintainable.
This article explores a declarative alternative: describing field relationships in configuration objects instead of imperative effect code.
Note on examples: I use pharmaceutical submission forms as examples throughout. This domain provides good complexity (regulatory requirements, conditional fields, multi-country variations) while being publicly understood. The patterns apply to any complex form domain - e-commerce, finance,
healthcare, government services, etc.
Looking Backward to Move Forward
Years ago, I worked with an XML-driven UI framework that had an interesting feature. You could describe entire forms declaratively, including field relationships:
<field name="city"
dependsOn="country"
loadFrom="/api/cities?country={country}"
clearOnChange="true"/>
No imperative code, no event handlers, no effect hooks. The framework read the configuration and handled all the logic automatically.
This pattern stuck with me. Modern React emphasizes declarative UI, yet field dependencies are typically handled imperatively through effects. Why not apply the declarative approach to form logic?
The challenge: building this as a side project without unlimited time, while still learning modern frontend patterns.
The useEffect Spaghetti Problem
Here's what the imperative approach looks like. A pharmaceutical submission form where fields depend on each other:
function SubmissionForm() {
const [applicationType, setApplicationType] = useState('')
const [submissionType, setSubmissionType] = useState('')
const [country, setCountry] = useState('')
const [state, setState] = useState('')
const [states, setStates] = useState([])
const [submissionTypes, setSubmissionTypes] = useState([])
const [showState, setShowState] = useState(false)
// Load submission types when application type changes
useEffect(() => {
if (applicationType) {
fetch(`/api/submission-types?applicationType=${applicationType}`)
.then(res => res.json())
.then(data => setSubmissionTypes(data))
} else {
setSubmissionTypes([])
}
}, [applicationType])
// Reset submission type when application type changes
useEffect(() => {
setSubmissionType('')
}, [applicationType])
// Load states when country changes
useEffect(() => {
if (country === 'USA') {
setShowState(true)
fetch(`/api/states?country=${country}`)
.then(res => res.json())
.then(data => setStates(data))
} else {
setShowState(false)
setStates([])
}
}, [country])
// Reset state when country changes
useEffect(() => {
setState('')
}, [country])
// ... and on and on for 20+ more fields
}
This is 5 fields. Real forms have 30, 40, 50+ fields with complex dependencies. The useEffect hooks create dependency chains so complex that debugging becomes a nightmare. Change one field, three others update, triggering five more updates, sometimes creating infinite loops.
Testing? Mock every effect? Write integration tests for every possible combination of field changes?
Maintenance? Every new field dependency requires tracing through all existing effects to avoid breaking something. It's fragile, hard to understand, and gets worse with every feature.
The Declarative Alternative
What if instead of imperative effects, the form was just described as configuration?
const formModel = [
{
name: 'applicationType',
type: 'select',
label: 'Application Type'
},
{
name: 'submissionType',
type: 'select',
label: 'Submission Type',
loadDataFrom: '/api/submission-types?applicationType={applicationType}',
subscribesTo: 'applicationType' // When applicationType changes, reload my data
},
{
name: 'country',
type: 'select',
label: 'Country'
},
{
name: 'state',
type: 'select',
label: 'State',
loadDataFrom: '/api/states?country={country}',
visibleWhen: (values) => values.country === 'USA',
subscribesTo: 'country'
}
]
Just a configuration object. Read it top to bottom and you understand the form. No hunting through effects, no mental gymnastics to figure out what triggers what. The field state literally says "I'm visible when country is USA" and "I subscribe to country changes."
Beautiful, right? In theory, at least. Making it actually work was going to be... interesting.
The Observer Pattern (Because Smart People Already Figured This Out)
I needed the fields to "watch" each other. When Field A changes, Field B needs to know about it and react accordingly. This is a classic programming pattern called the Observer Pattern, but I didn't know that at the time. I just knew what I needed to do.
My first attempt was embarrassingly simple. I literally had a function that ran after every field change and checked every other field to see if it needed to do something:
function handleFieldChange(fieldName, value) {
// Update the value
setValue(fieldName, value)
// Check EVERY OTHER FIELD to see if it cares about this change
allFields.forEach(field => {
if (field.subscribesTo === fieldName) {
// Do something
}
})
}
It worked! Sort of. For about 5 fields. With 30 fields, it was painfully slow. Every keystroke triggered a check of 29 other fields, and if any of those fields had actions to perform, those might trigger more checks... You can see where this is going.
State Management: Why MobX
The declarative system needs state management with fine-grained reactivity – where only fields that actually changed re-render, not the entire form.
Comparing options:
Redux/Zustand: Update any state, entire form re-renders. With 50 fields, that's expensive.
MobX: Fine-grained reactivity. Change one field, only components subscribed to that field update. React DevTools shows exactly one component re-rendering.
This makes the subscription system practical. If Field A changes and Field B subscribes to it, only Field B reacts. The other 48 fields don't even know anything happened.
Crucial for both performance and making the declarative pattern work cleanly.
The MobX Pattern: Functions, Not Classes
Here's where things got interesting. Most MobX examples online use classes – you extend some base class, decorate methods with @action and @observable, and it works. But I wasn't a fan of the class-based approach. It felt heavy and old-fashioned compared to React's hooks and functional
components.
So I started experimenting with a functional approach. What if stores were just functions that returned objects? And what if they could automatically register themselves without any manual setup?
I ended up with a pattern that looked like this:
// Define a store as an object with id and implementation function
export const valuesStore = {
id: 'valuesStoreId',
implementation: () => {
return {
valuesMap: new Map(),
lastChanged: '',
getValue(id, name) {
return this.valuesMap.get(id)?.get(name) || ''
},
setValue(id, name, value) {
this.lastChanged = name
this.valuesMap.get(id)?.set(name, value)
},
// ... more methods
}
}
}
// Store auto-registers itself
globalStores.push(valuesStore)
// Export a typed hook
export const useValuesStore = useStore<ValuesStoreImplementation>(valuesStoreId)
The magic happens in the StoreProvider. It takes all registered stores, wraps their implementations with MobX's
useLocalObservable, and makes them available through React Context:
export const StoreProvider = ({children}) => {
const mobxStores = new Map(globalStores
.map(store => [store.id, useLocalObservable(store.implementation)]))
return <StoreContext.Provider value={mobxStores}>{children}</StoreContext.Provider>
}
This meant I could create a new store by just:
- Writing a function that returns an object with methods
- Pushing it to the global stores array
- Exporting a custom hook
No classes, no decorators, no manual provider setup. The store automatically becomes part of the system. And because
useLocalObservable wraps the returned object, all the properties become reactive automatically.
For the form system, I created five stores this way:
-
valuesStore– Manages all form field values -
attributesStore– Manages field configurations -
dataStore– Manages loaded data for dropdowns and selects -
modelsStore– Manages form mode (view/edit) and structure -
validationsStore– Manages validation states
Each store is completely independent, just a function in a file, but they all work together seamlessly. And adding a new store? Just create a new file, follow the pattern, push to globalStores, done.
Looking back, I'm not sure if this is considered best practice for MobX. I couldn't find many examples of this pattern online. But it worked beautifully, kept the code functional and modular, and made it easy to add new stores without understanding all the MobX internals.
The full pattern is in src/providers/store, and you can see it in action in the form stores at src/dataDisplay/model/store.
Building the Subscription Engine
With MobX handling the reactive state, I could focus on making the subscription system work. The core idea was simple:
- Each field can declare subscriptions – relationships to other fields
- When a field value changes, the system finds all subscriptions that care about that field
- The system evaluates the subscription conditions
- If conditions match, the system executes the actions
In code, a subscription looked like this:
{
name: 'state',
subscribesOn: [
{
name: 'showStateField',
trigger: {
type: 'fieldChange',
params: [
{
name: 'attribute',
value: 'country'
},
{
name: 'condition',
value: {
operator: 'equals',
value: 'USA'
}
}
]
},
action: {
type: 'visible',
params: [
{
name: 'visible',
value: true
}
]
}
}
]
}
Reading this, you can understand it: "Field state subscribes to changes in field country. When country equals '
USA', make the state field visible."
The implementation took... a while. I'm not going to lie, this was hard. Handling chains of subscriptions (Field A => Field B => Field C), preventing circular dependencies, making sure data loading happened in the right order, dealing with race conditions when multiple subscriptions fired at once...
Each of these was its own problem to solve.
There were nights when I thought about giving up and just living with the useEffect soup. But every time I looked at the declarative config and compared it to the imperative mess, it seemed worth pursuing.
Two Types of Subscriptions: Visibility and Data
After a few iterations, two main subscription action types emerged:
1. Visibility Subscriptions – Show or hide fields based on conditions
{
name: 'medicalHistory',
subscribesOn: [
{
trigger: {
params: [
{
name: 'attribute',
value: 'hasPreExistingCondition'
}
],
condition: {
operator: 'equals',
value: true
}
},
action: {
type: 'visible'
}
}
]
}
"Show the medical history field only if they checked the 'has pre-existing condition' box."
2. Data Refresh Subscriptions – Reload a field's options when its dependencies change
{
name: 'city',
loadDataFrom: '/api/cities?country={country}',
subscribesOn: [
{
trigger: {
params: [
{
name: 'attribute',
value: 'country'
}
]
},
action: {
type: 'dataRefresh',
params: [
{
name: 'clearValue',
value: true
}
]
}
}
]
}
"When country changes, reload the city options and clear the current selection."
These two actions covered probably 90% of use cases. The other 10% could be handled with custom logic when needed, but having these built-in made the common cases trivial.
Testing the Approach: A Pharmaceutical Form Example
To test this system, I built a pharmaceutical submission form prototype with 40 fields and complex dependencies:
- Application type determined which submission types were available
- Country selection showed different fields (USA needed states, Canada needed provinces, EU needed different fields entirely)
- Certain combinations of selections would show warning messages
- Some fields were required only if other fields had specific values
The imperative approach would be 500-600 lines of effect code spread across components, with effects triggering other effects. Hard to read, test, and debug.
The declarative approach? 150 lines of configuration. Readable top to bottom:
const pharmaceuticalFormModel = [
{
name: 'applicationType',
type: 'autocomplete',
label: 'Application Type',
required: true,
loadDataFrom: '/api/application-types'
},
{
name: 'submissionType',
type: 'select',
label: 'Submission Type',
required: true,
loadDataFrom: '/api/submission-types?applicationType={applicationType}',
subscribesOn: [{
trigger: {params: [{name: 'attribute', value: 'applicationType'}]},
action: {type: 'dataRefresh', params: [{name: 'clearValue', value: true}]}
}]
},
{
name: 'country',
type: 'select',
label: 'Country',
required: true,
loadDataFrom: '/api/countries'
},
{
name: 'state',
type: 'select',
label: 'State',
required: true,
loadDataFrom: '/api/states?country={country}',
subscribesOn: [{
trigger: {
params: [{name: 'attribute', value: 'country'}],
condition: {operator: 'equals', value: 'USA'}
},
action: {type: 'visible'}
}]
},
// ... 36 more fields with similar clarity
]
Backend developers could read this configuration and understand the form logic. That's a good sign for maintainability.
The Hook: useModelRepresent
All of this declarative configuration needed something to interpret and execute it. That's what the useModelRepresent
hook does. It's the engine that makes the magic happen.
You use it like this:
const {attributes, values} = useModelRepresent({
formId: 'my-form',
attributes: formModel, // The declarative config
mode: 'edit',
values: initialValues
})
return (
<Form>
{attributes.map(attr => (
<Form.Row key={attr.name} {...attr} />
))}
</Form>
)
Behind the scenes, useModelRepresent is:
- Setting up MobX stores for form values, field data, and validations
- Processing subscriptions when field values change
- Loading data from APIs when fields need it
- Managing visibility states
- Handling field validation
- Clearing dependent fields when their parent changes
All the complexity is hidden. As a form developer, you just pass in your config and render the fields. The hook handles everything else.
The implementation lives in src/dataDisplay/model/useModelRepresent.ts if you want to see how it actually works. Fair warning: it's about 170 lines of TypeScript that took me way longer to write than I'd like to admit.
What This Approach Actually Solved
Testing this pattern across different form types and complexity levels revealed these benefits:
Readability: You can understand a form by reading its configuration. No hunting through effect hooks, no tracing execution paths. It's all right there.
Maintainability: Adding a new field dependency? Just add a subscription to the config. No risk of breaking existing effects or creating infinite loops.
Testability: The configuration is pure data. You can test the subscription engine independently of React. You can test individual subscription rules without mounting components. Way easier.
Performance: Thanks to MobX's fine-grained reactivity, forms with 50+ fields remain responsive. Only the fields that actually need to update do update.
Reusability: Form configurations are portable. The same entity definition works in different contexts with different modes (view vs edit).
What This Approach Didn't Solve (Being Honest)
Of course, it's not perfect. Real downsides encountered during experimentation:
Learning Curve: Custom configuration format means no Stack Overflow answers. Requires good documentation and examples.
Complex Logic: Some business rules are too complex to express declaratively. Requires fallback to custom code, mixing declarative and imperative approaches. Not ideal.
Debugging: Tracing through the subscription engine is harder than tracing explicit useEffect hooks. Requires extensive logging.
TypeScript Complexity: Configuration objects with many possible combinations create TypeScript edge cases. Takes time to get types right.
Performance Edge Cases: Forms with hundreds of fields can have slow initial setup even with MobX optimization. Requires lazy loading for field data.
Worth the tradeoffs? Depends on the use case. For simple forms with basic dependencies, the added complexity might not be worth it. This approach shines when you have:
- Many fields with complex dependencies
- Multiple similar forms that share patterns
- A need to generate forms from backend configuration
- Performance requirements for large forms
A Working Example
If you want to see this in action, check out the example in examples/02-field-subscriptions. It's a simplified version that demonstrates the core concepts.
The full implementation in the library includes a lot more features:
- Validation subscriptions (change validation rules based on other fields)
- Multi-field triggers (subscribe to multiple fields at once)
- Custom action handlers
- Data caching and race condition handling
- Form state management
- And probably a few things I'm forgetting
The code is at src/dataDisplay/model if you want to dig into the details.
Comparing to Other Solutions
"Why not just use Formik/React Hook Form/[insert form library]?" is a question I got asked a lot.
The answer is: those libraries are great, but they solve a different problem. Formik and React Hook Form give you excellent tools for managing form state, validation, and submission. But they're still imperative – you write code to define the behavior.
The declarative approach: define the relationships, let the system handle the behavior. The difference:
// Imperative (Formik/React Hook Form)
const formik = useFormik({
values: {country: '', state: ''},
validate: values => {
const errors = {}
if (values.country === 'USA' && !values.state) {
errors.state = 'State is required for USA'
}
return errors
}
})
return (
<>
<Select name="country" value={formik.values.country} onChange={formik.handleChange}/>
{formik.values.country === 'USA' && (
<Select name="state" value={formik.values.state} onChange={formik.handleChange}/>
)}
</>
)
// vs Declarative (this approach)
const model = [
{name: 'country', type: 'select'},
{
name: 'state',
type: 'select',
subscribesOn: [{
trigger: {params: [{name: 'attribute', value: 'country'}], condition: {operator: 'equals', value: 'USA'}},
action: {type: 'visible'}
}],
validators: [{type: 'required', when: (values) => values.country === 'USA'}]
}
]
Both approaches work. Ours has more upfront complexity but scales better for really complex forms. Your mileage may vary.
What I'd Do Differently Now
Looking back after two years, there are things I'd change:
Simpler Configuration Format: The JSON schema is pretty verbose. Starting over, I'd design a more compact syntax, maybe something like:
{
name: 'state',
visible: 'country === "USA"',
// Simple expressions instead of complex objects
reload: 'country',
// Just name the dependency
}
Better DevTools: Should have built debugging tools from the start. A visual graph showing field dependencies, a log of subscription events, a way to pause and inspect the subscription engine. These would have saved significant time.
More Examples: Built for personal use without much documentation. For open-sourcing, would create way more examples and use cases.
Gradual Adoption Path: The system is all-or-nothing. Either use the declarative config or don't. Better design: make it incremental – start with simple forms, gradually add declarative features where they make sense.
Was It Worth It?
I spent about six months, on and off, building and refining this system as a side project. Testing different approaches, fixing bugs, iterating on the design.
Six months is a long time. Could have just lived with the useEffect spaghetti and focused on other things.
But once the system was working, building new forms became significantly faster. What took a week of careful effect management became a day of writing configuration. The forms were more reliable, performed better, and were easier to maintain.
Plus, the declarative JSON format opened an interesting possibility: automatic form generation. Backend entity definitions (like those XML files from old frameworks) could be transformed into form configs. This meant UIs could be automatically generated from backend schemas.
That's when it stopped being just a form library and became a framework for rapid UI development.
Try It Yourself
The full implementation is on GitHub at github.com/NazarUsik/AdaptUI. The subscription system lives in src/dataDisplay/model/, and there's a working example in
examples/02-field-subscriptions/.
Is it perfect? No. Is it the right choice for every project? Probably not. But if you're building complex forms with lots of interdependencies, and you're tired of useEffect spaghetti, maybe give the declarative approach a try.
Or at least think about it. Sometimes old approaches (like XML-driven frameworks) had good ideas worth revisiting with modern tools.
Author: Nazar Usik
GitHub: AdaptUI
Related: This pattern works especially well with the multi-layer configuration system
and component wrapper pattern described in other articles.

Top comments (0)