Two-Tier Reactivity (Effects vs Hooks)
The core Config System and its configSymbol holding zone provide an incredibly robust engine for local, imperative state mutations. But a true platform needs a broader awareness.
Frameworks often struggle because they try to solve two fundamentally different problems with the same tool:
- Local, imperative side-effects: ("When I click this specific button, change its CSS class.")
- Global, derived state: ("When the user's currency preference changes, update the total price calculation across 5 different views.")
If you try to solve global state with local hooks, you end up writing manual chains: A updates B, which updates C. This is brittle and difficult to trace. Neo.mjs solves this by providing a "Two-Tier" reactivity architecture, tailored for these distinct use cases: Push-Based Hooks and Pull-Based Effects.
Tier 1: Push-Based (Lifecycle Hooks)
This is the classic, localized reactivity model powered by the dynamically generated getters and setters (the trailing_underscore_ convention) created during Neo.setupClass.
It is push-based because a change to a property explicitly pushes an update through a predefined sequence of methods (beforeSet -> afterSet).
When to use it
- Component-level state management.
- Validating input data before it is applied (
beforeSet). - Triggering direct, surgical DOM updates or side-effects when a specific property changes.
class MyButton extends Component {
static config = {
// Reactive config
isActive_: false
}
// Push-based side effect
afterSetIsActive(value, oldValue) {
if (oldValue !== undefined) {
// Imperatively update the VDOM based on the new value
this.updateCls('active', value);
}
}
}
Tier 2: Pull-Based (Effects & Dependency Tracking)
For complex, cross-component state, we need the engine to be intelligent enough to track dependencies automatically.
Neo.mjs provides a pull-based dependency tracking system via src/core/Effect.mjs and src/core/EffectManager.mjs.
How it works
Instead of explicitly defining when something should update (like an afterSet hook), you define what it depends on via a function. The EffectManager automatically watches this function execute and records any reactive config accessed during that execution. When any of those tracked configs change in the future, the effect is automatically queued to re-run.
Observing Other Instances
Because Effects track any accessed config, they inherently break the boundaries of a single instance. You can create an Effect that listens to configs on entirely different components or state managers.
This is the exact mechanism that powers two of Neo's most advanced features:
1. Functional Components
When you use functional components (Neo.functional.component.Base), the engine automatically wraps your createVdom() method inside an Effect. If your createVdom accesses a reactive config on the component (or any other instance), the effect tracks it. When that config changes, the effect automatically re-runs createVdom() and diffs the output, giving you a modern, declarative rendering loop without manual hooks.
2. The State Provider
The Neo.state.Provider is the ultimate realization of this system. It uses createHierarchicalDataProxy.mjs to wrap its internal data object. When an Effect accesses data.user.firstName, the proxy intercepts the read and registers the underlying config with the EffectManager.
This allows you to write clean formulas or component bindings that automatically update when distant data changes, without ever wiring up an event listener manually:
import Effect from './core/Effect.mjs';
// The effect automatically registers dependencies during its first run.
// If state.a or state.b changes later, this function re-runs automatically.
let myEffect = Neo.create(Effect, {
fn: () => {
// This function will re-run automatically whenever
// the state provider's 'a' or 'b' values change.
sum = stateProvider.getData('a') + stateProvider.getData('b');
}
});
The Synergy of the Two Tiers (The Bridge)
The true magic of the Neo.mjs engine is that these two systems are perfectly integrated.
Since v10, every push-based reactive config generated by Neo.setupClass (Tier 1) is internally backed by a core.Config instance. This is the missing link that connects the two tiers. Because of this architectural bridge, the EffectManager (Tier 2) can literally observe ANY reactive instance config natively—or even standalone custom core.Config instances.
You can create an Effect that re-runs when a component's text changes, seamlessly bridging local component state with global dependency tracking. The Neo.state.Provider heavily leverages this concept: it converts every deeply nested property path (e.g., user.address.city) into a discrete core.Config instance internally, allowing the createHierarchicalDataProxy to intercept reads and pipe them directly into the active Effect.
Furthermore, batch updates via set() (utilizing the configSymbol) are wrapped in EffectManager.pause() and EffectManager.resume(). This guarantees that if multiple tracked configs change during a batch operation, the pull-based dependency graph is not re-evaluated until the entire batch is complete and internally consistent.
You can use explicit hooks for precise component rendering, and automated Effects for complex business logic and functional rendering loops, allowing for optimal performance and clean architecture.
We now have an instance that is compiled, locally reactive, and globally aware. It seems ready to go. But in Neo's multi-threaded architecture, synchronous readiness is an illusion. To truly join the application, the instance needs an asynchronous lifecycle.