Blok is a reactive UI framework built for the web but flexible enough to be used elsewhere.
Blok is not yet on haxelib, but you can install it using Lix.
lix install gh:blok-ui/blok.core
To get a sense of how Blok works, try creating a simple counter app:
import blok.ui.*;
import blok.html.Html;
function main() {
Client.mount(
js.Browser.document.getElementById('root'),
() -> Counter.node({})
)
}
class Counter extends Component {
@:signal final count:Int = 0;
function decrement(_:blok.html.HtmlEvents.Event) {
count.update(count -> count > 0 ? count - 1 : 0);
}
function render() return Html.view(<div>
<div>count</div>
<button onClick=decrement>"-"</button>
<button onClick={ _ -> count.update(count -> count + 1)}>"+"</button>
</div>);
}
Note: this section is a work in progress.
Blok is a reactive framework. When a value changes, it automatically tracks it and updates as needed.
The core mechanism to make this possible are Signals. Blok's implementation is based heavily on Preact's Signals and (especially) on the implementation used by Angular.
Generally you won't be creating Signals directly (we'll get into why in the Components and Models sections below), but it's useful to understand what's going on with them. Lets set up a simple example:
var foo = new blok.signal.Signal('Foo');
Note:
blok.signal.Signal
is an abstract, so the above could also be writtenvar foo:blok.signal.Signal<String> = 'foo'
. This is a handy Haxe feature that Blok makes extensive use of to make authoring VNodes more ergonomic.
Reading and writing the value of foo
can be done as follows:
// Call it like a function (recommended):
trace(foo());
// Or use the getter:
trace(foo.get());
// Use `set` to update the value:
foo.set('bar');
// ...or `update` to also get access to the current value:
foo.update(value -> 'foo' + value);
None of this is particularly interesting on its own, but it becomes much more useful when we pair our Signal with an Observer:
import blok.signal.*;
function main() {
var foo:Signal<String> = 'foo';
Observer.track(() -> {
trace(foo());
});
foo.update(value -> value + 'bar');
foo.set('done');
}
If you run this code, you'll notice something: it traces "foo", "foobar" and finally "done". This is the key to the power of signals: by simply by calling foo()
inside our Observer
we've subscribed to it and will re-run every time foo
changes.
Note: if you want to get the value of a Signal without subscribing to it, you can use the
peek
method (e.g.foo.peek()
).
Note that when signals change their Observers will update asynchronously since Blok uses a scheduling mechanism behind the scenes. This is to ensure that other asynchronous events, like HTTP requests, don't update out of order and potentially cause strange behavior.
Todo: Explain
Computation
, especially the fact that it can update synchronously when it's accessed.
Note: this section is a work in progress.
Blok apps are built out of Components, and they're the primary thing you'll be using. Let's bring back our Counter example:
Note: Blok does have a JSX-like DSL, but it's still very experimental so we're going to be sticking to a alternate, fluent API to create elements. You can use either method in your apps.
import blok.ui.*;
import blok.html.*;
function main() {
Client.mount(
js.Browser.document.getElementById('root'),
() -> Counter.node({})
)
}
class Counter extends Component {
@:attribute final increment:Int = 1;
@:signal final count:Int = 0;
@:computed final className = 'counter-${count()}';
@:effect function traceWhenCountChanges() {
trace('Count is currently ${count()}');
return () -> trace(
'This is a clean-up function, run when '
+ 'the Component is disposed or the effect '
+ 'is re-computed.'
);
}
function decrement(_:blok.html.HtmlEvents.Event) {
count.update(count -> count > 0 ? count - increment : 0);
}
function render():Child {
return Html.div()
.attr(ClassName, className)
.child([
Html.div().child(count),
Html.button().on(Click, decrement).child('-'),
Html.button()
.on(Click, _ -> count.update(count -> count + increment))
.child('+')
]);
}
}
In our Counter
class, you'll note that we have a bunch of class fields marked with metadata. These are fairly self-explanatory, but let's go over them one by one.
Note: this is still very much in progress and these descriptions are probably not very helpful yet.
Attributes are (mostly) immutable properties passed into a component.
Signal fields create readable/writeable Signals (see the previous section). This is somewhat similar to useState
in React.
Observable fields are read-only Signals passed in from some outside source (such as a parent component).
Computed fields allow you to derive reactive values from any number of Signals.
Resource fields allow you to use async values (such as HTTP requests) in conjunction with SuspenseBoundaries.
Effect methods allow you to create reactive side-effects that track reactive Signals.
Use the given Context.
Note: this section is coming soon.
Note: This section will be expanded and improved soon.
When dealing with asynchronous code you'll want to use Blok's Suspense apis.
First, you'll need to set up a Resource. A resource is a reactive object (a bit like a Computation) that resolves some async Task. Here's a simple example:
final resource = new blok.suspense.Resource<String>(() -> {
new kit.Task(activate -> haxe.Timer.delay(() -> activate(Ok('loaded')), 1000));
});
As previously mentioned, Resources are reactive, so we can cause our Resource to recompute if we use a Signal:
final delay:Signal<Int> = 1000;
final resource = new blok.suspense.Resource<String>(() -> {
// Note that we have to use our Signal here for the Resource to capture it:
var time = delay();
new kit.Task(activate -> haxe.Timer.delay(() -> activate(Ok('loaded')), time));
});
As with other features in Blok, you'll almost never need to create a resource this way. Instead, you'll be using @:resource
fields on components:
class TimerExample extends Component {
@:resource final timer:String = new kit.Task(activate -> {
haxe.Timer.delay(() -> activate(Ok('loaded')), 1000);
});
function render():Child {
return Html.p().child(timer());
}
}
If you try to use the component created above, you'll get an uncaught SuspenseException
and your app will break. To fix this, we need to add a SuspenseBoundary.
class TimerWrapper extends Component {
function render():Child {
return blok.suspense.SuspenseBoundary.node({
onComplete: () -> trace('Done!'),
onSuspended: () -> trace('Suspending!'),
children: TimerExample.node({}),
fallback: () -> Html.p().child('Suspended...')
});
}
}
Now instead of breaking the component will display <p>Suspended...</p>
until the TimerExample
's resource is activated.
SuspenseBoundaries do not propagate suspensions upwards (unless you set their overridable
properties to true
, in which case they will defer suspension to their closest ancestor, if any). If you want to take some action when multiple suspensions occur, you can use a SuspenseBoundaryContext
.
class TimerApp extends Component {
function render():Child {
return blok.context.Provider
.provide(() -> new blok.suspense.SuspenseBoundaryContext({
onComplete: () -> trace('All suspensions complete')
}))
.child(_ -> Fragment.of([
TimeWrapper.node({}),
TimeWrapper.node({})
]));
}
}
Note: this section is coming soon.