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
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"
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"}
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}
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}
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"}
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'
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
}
// ...
})
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([
/*...*/
])
}
})
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>
*/
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}
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!
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=
…
Top comments (4)
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.
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.
Really interesting ideas. Great article!
Thanks!