DEV Community

Cover image for WhatsUp - front-end framework based on ideas of streams and fractals
Dani Chu
Dani Chu

Posted on

WhatsUp - front-end framework based on ideas of streams and fractals

Hi guys!

My name is Dan. Today I want to share my project with you. It is a frontend framework. I collected my most exotic ideas in it.

npm i whatsup
Enter fullscreen mode Exit fullscreen mode

It is built on generators, provides functionality similar to react + mobx out of the box, has good performance, and weighs less than 5kb gzip. With a reactive soul. With minimal api. With the maximum use of native language constructs.

The architectural idea is that our entire application is a tree structure, along the branches of which the flow of data is organized in the direction of the root, reflecting the internal state. During development, we describe the nodes of this structure. Each node is a simple self-similar entity, a full-fledged complete application, all work of which is to receive data from other nodes, process it and send it next.

This is the first part of my story. We'll take a look at state management here.

Cause & Conse

Two basic streams for organizing reactive data state. For ease of understanding, they can be associated with the familiar computed and observable.

const name = conse('John')

whatsUp(name, (v) => console.log(v))
//> "John"

name.set('Barry')
//> "Barry"
Enter fullscreen mode Exit fullscreen mode

Example

Nothing special, right? conse creates a stream with an initial value, whatsUp - add the observer. Through .set(...) we change the value - the observer reacts - a new entry appears in the console.

Cause is created from a generator, inside which the yield* expression is the "connection" of an external thread to the current one. The situation inside the generator can be viewed as if we are inside an isolated room, in which there are several yield* inputs and only one return output

const name = conse('John')

const user = cause(function* () {
    return {
        name: yield* name,
        //    ^^^^^^ connect stream name 
    }
})

whatsUp(user, (v) => console.log(v))
//> {name: "John"}

name.set('Barry')
//> {name: "Barry"}
Enter fullscreen mode Exit fullscreen mode

Example

yield* name sets the dependence of the user stream on the name stream, which in turn also leads to quite expected results, namely - change the name - the user changes - the observer reacts - the console shows a new record.

What is the advantage of generators?

Let's complicate our example a little. Let's imagine that in the data of the user stream, we want to see some additional parameter revision, that reflects the current revision.

It's easy to do - we declare a variable revision, the value of which is included in the dataset of the user stream, and each time during the recalculation process, we increase it by one.

const name = conse('John')

let revision = 0

const user = cause(function* () {
    return {
        name: yield* name,
        revision: revision++,
    }
})

whatsUp(user, (v) => console.log(v))
//> {name: "John", revision: 0}

name.set('Barry')
//> {name: "Barry", revision: 1}
Enter fullscreen mode Exit fullscreen mode

Example

But something is wrong here - revision looks out of context and unprotected from outside influences. There is a solution to this - we can put the definition of this variable in the body of the generator, and to send a new value to the stream (exit the room) use yield instead of return, which will allow us not to terminate the execution of the generator, but to pause and resume from the place of the last stops on next update.

const name = conse('John')

const user = cause(function* () {
    let revision = 0

    while (true) {
        yield {
            name: yield* name,
            revision: revision++,
        }
    }
})

whatsUp(user, (v) => console.log(v))
//> {name: "John", revision: 0}

name.set('Barry')
//> {name: "Barry", revision: 1}
Enter fullscreen mode Exit fullscreen mode

Example

Without terminating the generator, we get an additional isolated scope, which is created and destroyed along with the generator. In it, we can define the variable revision, available from calculation to calculation, but not accessible from outside. At the end of the generator, revision will go to trash, on creation - it will be created with it.

Extended example

The functions cause and conse are shorthand for creating streams. There are base classes of the same name available for extension.

import { Cause, Conse, whatsUp } from 'whatsup'

type UserData = { name: string }

class Name extends Conse<string> {}

class User extends Cause<UserData> {
    readonly name: Name

    constructor(name: string) {
        super()
        this.name = new Name(name)
    }

    *whatsUp() {
        while (true) {
            yield {
                name: yield* this.name,
            }
        }
    }
}

const user = new User('John')

whatsUp(user, (v) => console.log(v))
//> {name: "John"}

user.name.set('Barry')
//> {name: "Barry"}
Enter fullscreen mode Exit fullscreen mode

Example

When extending, we need to implement a whatsUp method that returns a generator.

Context & Disposing

The only argument accepted by the whatsUp method is the current context. It has several useful methods, one of which is update - allows you to force initiate the update procedure.

To avoid unnecessary and repeated computation, all dependencies between threads are dynamically tracked. When the moment comes when the stream has no observers, the generator is automatically destroyed. The occurrence of this event can be handled using the standard try {} finally {} language construct.

Consider an example of a timer thread that generates a new value with a 1 second delay using setTimeout, and when destroyed, calls clearTimeout to clear the timeout.

const timer = cause(function* (ctx: Context) {
    let timeoutId: number
    let i = 0

    try {
        while (true) {
            timeoutId = setTimeout(() => ctx.update(), 1000)
            // set a timer with a delay of 1 sec

            yield i++
            // send the current value of the counter to the stream 
        }
    } finally {
        clearTimeout(timeoutId)
        // clear timeout
        console.log('Timer disposed')
    }
})

const dispose = whatsUp(timer, (v) => console.log(v))
//> 0
//> 1
//> 2
dispose()
//> 'Timer disposed'
Enter fullscreen mode Exit fullscreen mode

Пример на CodeSandbox

Mutators

A simple mechanism to generate a new value based on the previous one. Consider the same example with a mutator based timer.

const increment = mutator((i = -1) => i + 1)

const timer = cause(function* (ctx: Context) {
    // ...
    while (true) {
        // ...
        // send mutator to the stream
        yield increment
    }
    // ...
})
Enter fullscreen mode Exit fullscreen mode

Example

A mutator is very simple - it is a method that takes a previous value and returns a new one. To make it work, you just need to return it as a result of calculations, all the rest of the magic will happen under the hood. Since the previous value does not exist on the first run, the mutator will receive undefined, the i parameter will default to -1, and the result will be 0. Next time, zero mutates to one, etc. As you can see, increment allowed us to avoid storing the local variable i in the generator body.

That's not all. In the process of distributing updates by dependencies, the values ​​are recalculated in streams, while the new and old values ​​are compared using the strict equality operator ===. If the values ​​are equal, the recalculation stops. This means that two arrays or objects with the same data set, although equivalent, are still not equal and will provoke meaningless recalculations. In some cases this is necessary, in others it can be stopped by using the mutator as a filter.

class EqualArr<T> extends Mutator<T[]> {
    constructor(readonly next: T[]) {}

    mutate(prev?: T[]) {
        const { next } = this

        if (
            prev && 
            prev.length === next.length && 
            prev.every((item, i) => item === next[i])
        ) {
            /*
            We return the old array, if it is equivalent 
            to the new one, the scheduler will compare 
            the values, see that they are equal and stop 
            meaningless recalculations
            */
            return prev
        }

        return next
    }
}

const some = cause(function* () {
    while (true) {
        yield new EqualArr([
            /*...*/
        ])
    }
})
Enter fullscreen mode Exit fullscreen mode

In this way, we get the equivalent of what in other reactive libraries is set by options such as shallowEqual, at the same time we are not limited to the set of options provided by the library developer, but we ourselves can determine the work of filters and their behavior in each specific case. In the future, I plan to create a separate package with a set of basic, most popular filters.

Like cause and conse, the mutator function is shorthand for a short definition of a simple mutator. More complex mutators can be described by extending the base Mutator class, in which the mutate method must be implemented.

Look - this is how you can create a mutator for a dom element. The element will be created and inserted into the body once, everything else will boil down to updating its properties.

class Div extends Mutator<HTMLDivElement> {
    constructor(readonly text: string) {
        super()
    }

    mutate(node = document.createElement('div')) {
        node.textContent = this.text
        return node
    }
}

const name = conse('John')

const nameElement = cause(function* () {
    while (true) {
        yield new Div(yield* name)
    }
})

whatsUp(nameElement, (div) => document.body.append(div))
/*
<body>
    <div>John</div>
</body>
*/
name.set('Barry')
/*
<body>
    <div>Barry</div>
</body>
*/
Enter fullscreen mode Exit fullscreen mode

Example

Actions

Actions are designed to perform batch updates of data in streams.

import { cause, conse, action, whatsUp } from "whatsup";

const name = conse("John");
const age = conse(33);

const user = cause(function* () {
  return {
    name: yield* name,
    age: yield* age
  };
});

whatsUp(user, (v) => console.log(v));
//> {name: "John", age: 33}

// without action
name.set("Barry");
age.set(20);

//> {name: "Barry", age: 33}
//> {name: "Barry", age: 20}

// with action

action(() => {
  name.set("Jessy");
  age.set(25);
});

//> {name: "Jessy", age: 25}
Enter fullscreen mode Exit fullscreen mode

Example

Conclusion

In this article, I described the basic capabilities of WhatsUp for organizing state management. In the next article, I will tell you how WhatsUp can work with jsx, about the event system and the exchange of data through the context.

If you liked the idea of my framework - leave your feedback or a star on the github. I'll be very happy. Thanks!

GitHub logo whatsup / whatsup

A frontend framework for chillout-mode development 🥤 JSX components on generators, fast mobx-like state management and exclusive cssx style system

What is it?

Whatsup is a modern frontend framework with own reactivity system and JSX components based on pure functions and generators.

Features

  • 🎉 easy to use: simple api, just write code
  • 🚀 own reactivity system with high performance
  • 🌈 cool styling system based on css modules
  • 🚦 built-in router with intuitive api
  • glitch free, autotracking and updating of dependencies
  • 🥗 written in typescript, type support out of the box
  • 🗜 small size: ~7kB gzipped (state + jsx + cssx)

Example

import { observable } from 'whatsup'
import { render } from 'whatsup/jsx'
function* App() {
    const counter = observable(0)
    const increment = () => counter(counter() + 1)

    while (true) {
        yield (
            <div>
                <p>You click {counter()} times</p>
                <button onClick=
Enter fullscreen mode Exit fullscreen mode

Top comments (4)

Collapse
 
shadowtime2000 profile image
shadowtime2000

This seems really cool, but I don't think it can be considered a front-end framework since it seems it is isomorphic and has really no relation to the front-end. You could maybe rebrand it as a reactivity framework.

Collapse
 
iminside profile image
Dani Chu

Yes, you remarked very correctly. It is a truly isomorphic reactive framework. The frontend is only one of the directions of its application and at the moment for me it is a priority direction of its development, so for now I position it exactly as a frontend framework, but this is a temporary measure. In the next article, I will talk about how you can use whatsup to create a frontend.

Thanks for the feedback! I would be very happy to see your comments on the next article.

Collapse
 
chuckles_gangly profile image
JP

Really interesting ideas. Great article!

Collapse
 
iminside profile image
Dani Chu

Thanks!