Config System Deep Dive
Pre-requisite: It is highly recommended to study The Unified Class Config System first to understand the foundational concepts and benefits.
The Neo.mjs class configuration system is a cornerstone of the engine, providing a powerful, declarative, and reactive way to manage the state of your components and classes. With the introduction of functional components in v10, this system has evolved into a sophisticated, two-tier reactivity model that combines the robustness of a classic "push" system with the fine-grained efficiency of a modern "pull" system.
This guide will take you on a deep dive into how this hybrid system works, giving you the knowledge to build highly performant and maintainable applications.
Tier 1: The Classic "Push" System
The original reactivity model in Neo.mjs is a "push" system. It's imperative, meaning that when you change a value, the system actively "pushes" that change through a series of predefined lifecycle hooks. This system remains a core part of v10 and is the foundation for class-based components.
1. Core Concepts Recap
At its heart, the push system is built on a few key principles:
static configBlock: All configurable properties of a class are declared in astatic config = {}block. This provides a single, clear source of truth for a class's API._Suffix Convention: Config properties that require custom logic when they change are declared with a trailing underscore (e.g.,myValue_). This signals the engine to automatically create a native getter and setter on the class's prototype for this property.- Lifecycle Hooks: For a config like
myValue_, the engine provides optional lifecycle hooks that you can implement in your class:beforeGetMyValue(value): Called before the getter returns the value.beforeSetMyValue(value, oldValue): Called before the setter applies the new value.afterSetMyValue(value, oldValue): Called after the setter has applied the new value.
- Reactivity: The
afterSethooks are the heart of the reactive system. They allow you to define logic that automatically runs whenever a specific config property changes, ensuring your UI and application state are always in sync.
2. The Internal Mechanics: set(), processConfigs(), and configSymbol
To truly understand how Neo.mjs handles complex scenarios like simultaneous updates and inter-dependencies, we must
look at the internal machinery: the set() and processConfigs() methods in Neo.core.Base, and the special
configSymbol object.
The set() Method: Your Gateway to Updates
The set() method is the public interface for changing one or more config properties at once. When you call
this.set({a: 1, b: 2}), you kick off a carefully orchestrated sequence.
// Simplified for clarity
set(values={}) {
let me = this;
// If there are pending configs from a previous operation, process them first.
if (Object.keys(me[configSymbol]).length > 0) {
me.processConfigs();
}
// Stage the new values in the configSymbol object.
Object.assign(me[configSymbol], values); // (A)
// Start processing the newly staged values.
me.processConfigs(true); // (B)
}
Here’s the breakdown:
- Pre-processing (within
construct()): The method first checks if the internalconfigSymbolobject has any leftover configs from a previous, unfinished operation (e.g., from a parent class'sconstruct()call). If so, it processes them to ensure a clean state before new values are staged. - Staging (A):
Object.assign(me[configSymbol], values)is the critical first step. All new values from yourset()call are merged into theconfigSymbolobject. This object acts as a temporary staging area. It creates a snapshot of the intended end-state for all properties in this specificset()operation before any individual setters orafterSethooks are invoked. - Processing (B):
me.processConfigs(true)is called. This kicks off the process of applying the staged values fromconfigSymbolto the actual instance properties. Thetrueargument (forceAssign) is crucial, as we'll see next.
The processConfigs() Method: The Heart of the Operation
This internal method iteratively processes the configs stored in configSymbol. It's designed as a recursive
function to handle the dynamic nature of config processing, where one afterSet might trigger another set().
// Simplified for clarity
processConfigs(forceAssign=false) {
let me = this,
keys = Object.keys(me[configSymbol]); // Get keys of pending configs
if (keys.length > 0) {
let key = keys[0];
let value = me[configSymbol][key];
// The auto-generated setter for the config is triggered here.
me[key] = value; // (C)
// The config is removed from the staging area after its setter is called.
delete me[configSymbol][key]; // (D)
// Recursively call to process the next config.
me.processConfigs(forceAssign); // (E)
}
}
- Iteration:
processConfigstakes the first key fromconfigSymbol. It avoids a standard loop to prevent issues if anafterSethook modifiesconfigSymbol. - Assignment (C):
me[key] = valueis the most important step. This does not directly change a backing field. Instead, it triggers the actual auto-generated setter for the config property (e.g.,set a(value)). This native setter is responsible for:- Running the
beforeSethook (if it exists). - Updating the internal backing property (e.g.,
this._a = value). - Running the
afterSethook (if it exists and the value has changed).
- Running the
- Deletion (D):
delete me[configSymbol][key]removes the property from the staging area after its setter has been invoked. This is vital to prevent reprocessing and to mark the config as handled. - Recursion (E): The method calls itself to process the next item in
configSymboluntil it's empty.
3. Solving the "Circular Reference" Problem
What happens when two afterSet methods depend on each other's properties?
Consider this common scenario:
class MyComponent extends Component {
static config = {
a_: 1,
b_: 2
}
afterSetA(value, oldValue) {
// This depends on 'b'
console.log(`a changed to ${value}, b is ${this.b}`);
}
afterSetB(value, oldValue) {
// This depends on 'a'
console.log(`b changed to ${value}, a is ${this.a}`);
}
onConstructed() {
super.onConstructed();
this.set({
a: 10,
b: 20
});
}
}
When this.set({a: 10, b: 20}) is called, which afterSet runs first? And when it runs, what value will it see
for the other property?
This is where the brilliance of the configSymbol shines.
Here's the sequence:
set()called:this.set({a: 10, b: 20})is executed.- Staging: The
configSymbolis immediately populated:me[configSymbol] = {a: 10, b: 20}. The internal backing properties_aand_bhave not been updated yet. processConfigs()starts:- It picks
a. The settersetA(10)is called. - Inside
setA, the internalthis._ais updated to10. afterSetA(10, 1)is triggered.
- It picks
- Inside
afterSetA:- The code encounters
this.b. This calls the auto-generated getter forb. - Crucially, the getter for
bis smart. It first checks ifbexists as a key in theconfigSymbolstaging area. - It finds
b: 20inconfigSymboland immediately returns20, the new, pending value. It does not return the old value fromthis._b. - The console logs:
a changed to 10, b is 20.
- The code encounters
processConfigs()continues:ais removed fromconfigSymbol.- The recursion continues, and it now picks
b. The settersetB(20)is called. - Inside
setB,this._bis updated to20. afterSetB(20, 2)is triggered.
- Inside
afterSetB:- The code encounters
this.a. The getter forais called. - It checks
configSymbol, butais no longer there (it was processed). - It therefore returns the value from the internal backing property,
this._a, which is now10. - The console logs:
b changed to 20, a is 10.
- The code encounters
Conclusion: The configSymbol acts as a consistent, authoritative snapshot for the duration of a set()
operation. This guarantees that all afterSet handlers, regardless of their execution order, operate on the most
current and consistent state of all config properties involved in that operation.
Tier 2: The Declarative "Pull" System (v10+)
With the introduction of functional components, Neo.mjs now includes a "pull" reactivity system. This system is declarative and optimized for fine-grained updates, making it ideal for modern, state-driven UI development. Instead of "pushing" changes through hooks, the system "pulls" data as needed, automatically tracking dependencies and re-running computations only when necessary.
This tier is powered by three key classes: Neo.core.Config, Neo.core.Effect, and EffectManager.
1. Neo.core.Config: The Atomic Unit of State
A Neo.core.Config instance is a lightweight wrapper around a single, reactive piece of data. Think of it as an
"atom" of state.
- Value Storage: It holds the current value of a config property.
- Subscription Management: It maintains a list of subscribers (effects or other logic) that depend on its value.
- Dependency Tracking: When its
get()method is called within a reactive context (an "effect"), it registers itself as a dependency of that effect. - Notification: When its
set()method is called and the value changes, it notifies all its subscribers, triggering them to re-run.
2. Neo.core.Effect: The Reactive Computation
An Neo.core.Effect represents a reactive computation—a function that depends on one or more Config atoms.
- Wrapping a Function: It wraps a function (e.g., a component's rendering logic).
- Automatic Dependency Tracking: When the effect runs, it automatically detects which
Configatoms areget()inside its function. It then subscribes to them. - Automatic Re-execution: If any of its dependencies change (i.e., their
set()method is called), the effect's function is automatically re-executed, ensuring the computation is always up-to-date.
3. EffectManager: The Global Coordinator
The EffectManager is a singleton that orchestrates the entire pull system.
- Effect Stack: It maintains a stack of currently running effects. This is how a
Configatom knows which effect to register itself with when itsget()method is called. - Batching: It provides
Neo.batch(), a crucial optimization function. It allows the system to pause effect re-runs, perform multiple state changes, and then resume, running all affected effects only once at the end. This prevents "glitches" and unnecessary intermediate computations.
4. createVdom as a Master Effect
In a functional component, the createVdom() method is the perfect example of an effect in action.
- The
vdomEffect: When a functional component is constructed, the engine automatically wraps itscreateVdom()method in aNeo.core.Effect. - Reading is Subscribing: When your
createVdom()function runs, every component config you access (e.g.,config.text,config.items) is a call to that config's underlyingNeo.core.Configatom'sget()method. This automatically subscribes thevdomEffectto those configs. - Automatic UI Updates: If any of those configs change later, they notify the
vdomEffect. The effect then re-runs yourcreateVdom()function, generating a new virtual DOM based on the new state. The engine then efficiently diffs this new VDOM with the old one and applies the minimal necessary changes to the actual DOM.
This is the essence of declarative, state-driven UI. You declare what the UI should look like for a given state, and the engine handles the "how" and "when" of updating it.
The Bridge: How "Push" and "Pull" Work Together
The true power of the v10 config system is how these two tiers are seamlessly integrated. This bridge is forged in
the auto-generated setters of reactive configs and the set() method of Neo.core.Base.
When you change a config on any component (class-based or functional):
myComponent.myConfig = 'new value';
// or
myComponent.set({myConfig: 'new value'});
Here's what happens under the hood:
- Batching Begins: The
set()method inNeo.core.Baseimmediately callsEffectManager.pause(). This tells the "pull" system to queue up any effects that get triggered but not to run them yet. - The Setter is Called: The auto-generated setter for
myConfigis invoked. This setter is the heart of the bridge. It performs two critical actions:- Pull System Update: It retrieves the
Neo.core.Configinstance formyConfig(usingthis.getConfig('myConfig')) and calls itsset()method with the new value. This updates the reactive atom and queues any dependent effects (like a functional component'svdomEffect). - Push System Update: It proceeds with the classic "push" system logic, adding the new value to the
configSymbolstaging area and eventually calling theafterSetMyConfig()hook.
- Pull System Update: It retrieves the
- Batching Ends: After the
set()method incore.Basehas finished processing all configs in the batch, itsfinallyblock callsEffectManager.resume(). This tells theEffectManagerto run all the unique effects that were queued during the operation, ensuring that the UI and other reactive computations update exactly once.
This elegant integration means you get the best of both worlds: the predictable, hook-based logic of the push system and the automatic, fine-grained reactivity of the pull system, all working in harmony.
In-depth Example: A Reactive MainContainer
Let's analyze a practical example to see these concepts in action. The Neo.examples.core.config.MainContainer
demonstrates how to build a reactive UI declaratively using the classic "push" system.
The Goal: Create a container with two labels. The text of each label is calculated based on the values of two
config properties, a and b. A button allows the user to change a and b simultaneously.
The Declarative Approach (static config)
The entire UI structure, including child components and event handlers, is defined within the static config block.
This is the recommended approach as it makes the component's structure immediately clear.
// From: Neo.examples.core.config.MainContainer
import Panel from '../../../src/container/Panel.mjs';
import Viewport from '../../../src/container/Viewport.mjs';
class MainContainer extends Viewport {
static config = {
className: 'Neo.examples.core.config.MainContainer',
a_: null,
b_: null,
style : { padding: '20px' },
items: [{
module: Panel,
// ... panel configs
headers: [{
dock : 'top',
items: [
{ ntype: 'label', flag: 'label1' },
{ ntype: 'label', flag: 'label2' },
{ ntype: 'component', flex: 1 },
{
handler: 'up.changeConfig', // Declarative handler
iconCls: 'fa fa-user',
text : 'Change configs'
}
]
}],
items: [{ ntype: 'label', text: 'Click the change configs button!' }]
}]
}
onConstructed() {
super.onConstructed();
this.set({ a: 5, b: 5 });
}
afterSetA(value, oldValue) {
if (oldValue !== undefined) {
this.down({flag: 'label1'}).text = value + this.b;
}
}
afterSetB(value, oldValue) {
if (oldValue !== undefined) {
this.down({flag: 'label2'}).text = value + this.a;
}
}
changeConfig(data) {
this.set({ a: 10, b: 10 });
}
}
Tracing the Data Flow
Initialization (
onConstructed):this.set({a: 5, b: 5})is called. This happens within theonConstructed()lifecycle hook, which is guaranteed to run after the instance'sconstruct()method has fully processed its initial configuration.configSymbolbecomes{a: 5, b: 5}.afterSetAruns. It calculateslabel1.textasvalue (5) + this.b (reads 5 from configSymbol) = 10.afterSetBruns. It calculateslabel2.textasvalue (5) + this.a (reads 5 from _a) = 10.- Initial State:
label1shows "10",label2shows "10".
Button Click (
changeConfig):- The button's
handler: 'up.changeConfig'finds and calls thechangeConfigmethod on theMainContainer. this.set({a: 10, b: 10})is called.configSymbolbecomes{a: 10, b: 10}.afterSetAruns. It calculateslabel1.textasvalue (10) + this.b (reads 10 from configSymbol) = 20.afterSetBruns. It calculateslabel2.textasvalue (10) + this.a (reads 10 from _a) = 20.- New State:
label1shows "20",label2shows "20".
- The button's
This example vividly demonstrates the dynamic and reactive nature of the "push" system, where a single declarative state
change automatically propagates through the component logic via afterSet hooks.
Best Practices for the Hybrid System
- Embrace Declarativity: For functional components, define your UI structure inside
createVdom. Trust the reactive system to handle updates. For class-based components, define your entire UI structure insidestatic configwhenever possible. This improves readability and maintainability. - Use the
_Suffix Wisely: Only add the trailing underscore to configs that needafterSet,beforeSetorbeforeGetbased logic. For simple value properties, omit it to avoid unnecessary overhead. - Keep
afterSetHandlers Pure: AnafterSethandler should ideally only react to the change of its own property and update other parts of the application. Avoid triggering complex chains ofset()calls from within anafterSetif possible. - Batch Updates with
set(): When you need to change multiple properties at once, always use a singleset({a: 1, b: 2})call. This is more efficient and ensures consistency across both reactivity systems. - Use
onConstructedfor Post-Construction Logic: Use theonConstructedlifecycle method to perform any setup that depends on the instance's initial configuration being fully processed. This is the ideal place for logic that requires all configs to be set and potentially other instances to be created (if set-driven). - Understand Your Dependencies: Be mindful of which configs you access inside
createVdomand other effects, as this determines when they will re-run.
By understanding these internal mechanics and following best practices, you can leverage the full power of Neo.mjs's hybrid config system to build highly complex, reactive, and maintainable applications with confidence.