[tip box yellow] This is considered an experimental feature.
Imba provides MobX-like reactivity implemented as decorators.
-
Properties decorated with
@observable
will be marked as observable, changing the way they interact with other properties as follows: -
Methods decorated with
@autorun
will be automatically invoked when any observable properties used by them change. -
Getters decorated with
@computed
will have their value cached on first use and will only recompute when any observable properties used by them change.
See the following guide for a holistic explanation of
@observable
or jump to reference information.
In Imba, it's important to keep in mind that commit is called after every handled event. This means that state changes which occur inside of event handlers will be reflected in the next render. We can apply this knowledge to implement a simplistic search:
# [preview=lg]
css body d:flex fld:column jc:start ai:center p:5
css input bg:blue0 mb:1 rd:2 outline:none p:1 3 bd:1px solid blue3
caret-color:blue4 bxs:sm bg@focus:blue1 transition:background 300ms
css div bg:gray1 rd:2 p:1 3 bdb:1px solid gray2 c:gray6 mb:1 bxs:xs
# ---
let query = ""
let words = ["apple", "orange", "strawberry"]
tag app
<self>
<input bind=query> # bind implicitly handles input events
for word in words.filter(do $1.includes(query))
<div> word
# ---
imba.mount <app>
There are some problems with this first approach:
-
words
is being filtered on every single render, even though it only needs to be updated when our query actually changes.If you were to implement everything this way, with multiple computation-heavy methods running on every render in multiple nested components, eventually your app would start to feel slow.
-
Semantically,
words
being filtered has nothing to do withrender
. Regardless of performance, it doesn't really make sense for the filtering logic to live in the render method, since the two are not related.
We can implement a less wasteful pattern manually:
# [preview=lg]
css body d:flex fld:column jc:start ai:center p:5
css input bg:blue0 mb:1 rd:2 outline:none p:1 3 bd:1px solid blue3
caret-color:blue4 bxs:sm bg@focus:blue1 transition:background 300ms
css div bg:gray1 rd:2 p:1 3 bdb:1px solid gray2 c:gray6 mb:1 bxs:xs
# ---
let query = ""
let words = ["apple", "orange", "strawberry"]
let filtered_words = words
tag app
def search
filtered_words = words.filter do $1.includes(query)
<self>
<input bind=query @input=search>
for word in filtered_words
<div> word
# ---
imba.mount <app>
Now we are only filtering every time our query
changes. This
addresses the issues listed above; it is now more efficient and
more semantic.
However, with our new approach there are some new problems:
-
In situations where multiple sources can modify
query
, we have to manually callsearch
after every modification or elsefiltered_words
will be out of sync. -
We are forced to use an event listener (
@input
), a method (search
), and an extra variable (filtered_words
).
We can address some of these issues with @autorun
and
@observable
:
# [preview=lg]
css body d:flex fld:column jc:start ai:center p:5
css input bg:blue0 mb:1 rd:2 outline:none p:1 3 bd:1px solid blue3
caret-color:blue4 bxs:sm bg@focus:blue1 transition:background 300ms
css div bg:gray1 rd:2 p:1 3 bdb:1px solid gray2 c:gray6 mb:1 bxs:xs
# ---
let words = ["apple", "orange", "strawberry"]
let filtered_words = words
tag app
@observable query = ""
@autorun def search
filtered_words = words.filter do $1.includes(query)
<self>
<input bind=query>
for word in filtered_words
<div> word
# ---
imba.mount <app>
A method decorated with @autorun
will be reactively invoked
whenever any @observable
properties used by it change.
This example effectively works the same as the manual approach,
except now if we modify query
from some other part of the code,
we don't have to remember to call search manually, which is
especially helpful when the code becomes more complex and
interrelated. We also don't need @input
anymore.
For example, if the search bar were to double as an input to add
new entries upon clicking a button, typically we'd want to clear
the input after adding a new entry. In doing so, we'd have to
remember to call search
after clearing query
. If we forget,
our app may not behave the way we want, or it may even lead to
erroneous behavior in more complex situations.
# [preview=lg]
css body d:flex fld:column jc:start ai:center p:5
css input bg:blue0 mb:1 rd:2 outline:none p:1 3 bd:1px solid blue3
caret-color:blue4 bxs:sm bg@focus:blue1 transition:background 300ms
css div bg:gray1 rd:2 p:1 3 bdb:1px solid gray2 c:gray6 mb:1 bxs:xs
css button px:2 cursor:pointer c:blue5 outline:none c@active:gray4
# ---
let words = ["apple", "orange", "strawberry"]
let filtered_words = words
tag app
@observable query = ""
@autorun def search
filtered_words = words.filter do $1.includes(query)
def add_word
words.push(query) if query
query = ""
<self>
<input bind=query @keydown.enter=add_word>
<button @click=add_word> "ADD"
for word in filtered_words
<div> word
# ---
imba.mount <app>
Notice how we don't have to call search
after query = ""
, we
can just change query
freely and search
gets invoked
automagically. When there are many different methods that can
change query
, this becomes quite convenient.
It's also worth noting that if clearing query
every time
words
is changed were also part of our specification, we could
easily implement that with @observable
and @autorun
as well.
Feel free to try that out as an exercise!
@autorun
is nice for calling functions, but in this example we
really only need a value, the computed array. If we want to get a
value rather than call a function, we can use @computed
.
Let's simplify our running example a bit:
# [preview=lg]
css body d:flex fld:column jc:start ai:center p:5
css input bg:blue0 mb:1 rd:2 outline:none p:1 3 bd:1px solid blue3
caret-color:blue4 bxs:sm bg@focus:blue1 transition:background 300ms
css div bg:gray1 rd:2 p:1 3 bdb:1px solid gray2 c:gray6 mb:1 bxs:xs
# ---
let words = ["apple", "orange", "strawberry"]
tag app
@observable query = ""
@computed get filtered_words
words.filter do $1.includes(query)
<self>
<input bind=query>
for word in filtered_words
<div> word
# ---
imba.mount <app>
A getter decorated with @computed
will be cached on first use
and will only be recomputed whenever any @observable
properties
used by it change.
Notice how our search
method and filtered_words
variable
have been merged into one getter.
- Methods decorated with
@autorun
will be automatically invoked when any observable properties used by them change. - Autorun in classes:
- Will run immediately after instantiation.
- Does not call
imba.commit
automatically.
- Autorun in tags:
- Will run immediately after mount.
- Automatically dispose on unmount.
@autorun
also accepts modifiers passed as an object, currently:@autorun(delay: 100ms)
will debounce invocations by the specified amount of time.
- Getters decorated with
@computed
will have their value cached on first use and will only recompute when any observable properties used by them change.
- Getters decorated with
@lazy
will only be evaluated once and then return the resulting value forever after.
- Methods prefixed with
@action
can update multiple observables without causing multiple@autorun
calls or@computed
cache invalidations.
- Imba doesn't do comparisons on the output of
@computed
values since rendering is already memoized. - Since Imba integrates MobX at the language-level it supports
features that are not possible in plain JS, currently:
- Observable subclasses.
- Autorunning methods that automatically dispose.
- These features are tree-shaken out of your built imba projects when they are not used.