DEV Community

Cover image for Reactive Object Composition
Jin
Jin

Posted on • Originally published at mol.hyoo.ru

Reactive Object Composition

Controlled Objects Setup

Using delegation, we can create local aliases for third-party channels. But sometimes we need to do the opposite, transfer control of the channel to ourselves, and leave an alias to our channel in the subordinate object. This is not difficult to do by replacing the channel when creating an object. For example, let’s take control of the duration channel from the task:

class Project_limited extends Project {

    @mems task( id: number ) {
        const task = new Task( id )
        task.duration = duration => this.task_duration( id, duration )
        return task
    }

    @mems task_duration( id: number, duration = 1 ) {
        return Math.min( duration, this.duration_max() )
    }

    duration_max() {
        return 10
    }

}
Enter fullscreen mode Exit fullscreen mode

Now the duration of a task from the project, even if desired, cannot exceed the limit we set. And since task durations and limits themselves are channels, their management can just as easily be intercepted by the account that owns the project, lifting the state even upper in the application hierarchy:

class Account_limited extends Account {

    @mems project( id: number ) {
        const project = new Project_limited( id )
        project.duration_max = ()=> this.duration_max()
        return project
    }

    duration_max( max = Number.POSITIVE_INFINITY ) {
        return max
    }

}
Enter fullscreen mode Exit fullscreen mode

Hacking is a powerful technique that allows you to connect objects with each other in a variety of directions. At the same time, without losing either speed or reliability, since:

  • Multiple objects operate simultaneously on the same state, instead of constantly synchronizing multiple local states with each other.
  • The owner has full control over the state access rules for each object.
  • Typescript checks the compatibility of function signatures when replacing, which reduces the risk of making mistakes during hacking.
  • Dependencies are tracked dynamically, so changing channels does not break reactivity.
  • The object knows only about the objects it owns, but knows nothing about its owner and neighbors.

In the following diagram you can see an ownership tree of 5 objects that share a shared state located in one of them in the middle of the hierarchy:

Channel Binding Variants

In many frameworks, such a thing as binding is very common - synchronizing several states 1-to-1. Typically there are two types of binding:

  • One-sided, where one state is the source of truth, and the second cannot be changed directly - it is set automatically equal to the first.
  • Two-sided, where you can change any state, and the second one is updated automatically.

Two-way binding has a serious problem: if synchronization does not occur immediately when one of the states changes, then it is possible to get a conflict when several states are changed in an incompatible way. Therefore, one often encounters a refusal of two-way binding. However, even one-way binding under these conditions is subject to the problem of observable temporal desynchronization, which can lead to unpredictable consequences.

The approach with delegation and hacking on channels allows us to solve the mentioned problems at the root by eliminating the duplication of state. At the same time, we no longer have two, but four types of binding, through which we can very accurately control information flows between objects:

  • One-way Hacking
  • Two-way Hacking
  • One-way Delegation
  • Two-way Delegation

A simple example illustrating all these options:

class Project extends Object {

    @mems task( id: number ) {
        const task = new Task( id )

        // Hacking one-way
        // duration <= task_duration*
        task.duration = ()=> this.task_duration( id )

        // Hacking two-way
        // cost <=> task_cost*
        task.cost = next => this.task_cost( id, next )

        return task
    }

    // task's duration source of truth
    @mems task_duration( id: number, next = 0 ) {
        return next
    }

    // task's cost source of truth
    @mems task_cost( id: number, next = 0 ) {
        return next
    }

    // Delegation one-way
    // status => task_status*
    task_status( id: number ) {
        return this.task( id ).status()
    }

    // Delegation two-way
    // title = task_title*
    task_title( id: number, next?: string ) {
        return this.task( id ).title( next )
    }

}
Enter fullscreen mode Exit fullscreen mode

Developer Experience

When all objects are created through local factories that take ownership of them, we get a useful bonus: the factory knows its name, the object creation parameters, has a reference to the owner object, and can get its name. This gives it the ability to assign a unique name to the captured object that reflects its semantics.

A simple example where we get several objects and what names they get:

class App extends Object {

    @mems account( id: number ) {
        return new Account( id )
    }

}

const app = new App

// define name of root object directly
app[ Symbol.toStringTag ] = 'app'

const account = app.account(1) // app.account(1)
const project = account.project(23) // app.account(1).project(23)
const task = project.task(456) // app.account(1).project(23).task(456)
Enter fullscreen mode Exit fullscreen mode

By entering this name into the console, we get the corresponding object:

As you can see, factories store the objects they create in the owner, which allows you to easily and simply navigate through objects using developer tools, and at any time understand what kind of object is in front of us.

The easiest way to explain to the debugger how to display our object is to specify the object name using Symbol.toStringTag. In Chrome, this name will also be displayed in stack traces:

And even in the monitoring service, you just need to take a quick look at the stacktrace to understand: yeah, the user clicked on “complete all tasks” in the header, but when writing to the local storage, he caught an exception.

A more advanced way is to use custom formatters to dynamically draw content in the debugger. With their help, we can make navigation through our dependency tree more visual:

Here we see 6 subscriptions for 4 publishers, one of which has the value true, but it is outdated (red), so it will be automatically updated as soon as someone accesses it, and the rest are current (green), so there will be no recomputing.

Finally, we can monitor changes in a subgraph of the reactive state graph, and, for example, log them:

Here we see that the user followed the link, which led to a targeted update of the application states in the most optimal order.

And this is just a special case of using such identifiers. They still have many different uses in testing, styling, statistics, etc. I wrote more about it in the article:

Element Names Fractal

Top comments (0)