Reactivity
Reactivity in Genie apps allows developers to create interactive and responsive user interfaces that automatically update when the underlying data changes or the user interacts with the UI. This is accomplished using a combination of reactive variables, handlers, and UI components. This page introduces the core concepts of reactivity in Genie applications and explains how they work together to create dynamic user interfaces.
Reactive code
The reactive code in a Genie app is implemented in the block delimited by the @app
macro, which holds the definitions for the reactive variables and handlers:
using GenieFramework
@genietools
# Reactive code
@app begin
# Reactive variables and handlers are defined here
end
This code block can only contain reactive variable and handler definitions, which are explained over the next two sections.
Reactive variables
Reactive variables are used to store the state of UI components, allowing the backend to be aware of the changes made in the frontend and vice-versa. Reactive variables are defined using the @in
and @out
macros inside the @app
block, and each indicates the following:
@in
: this variable can be modified from the UI and its changes will be propagated to the backend.@out
: this variable is read-only and cannot be modified from the UI. However, it can be updated from the backend to reflect changes in the data.
Additionally, there's the @private
macro to define reactive variables that are not exposed to the UI. These variables will be copied to every user session, and any changes made to them will not be propagated to the UI or other users. Still, these variables can trigger a reactive handler.
@in
and @out
reactive variables are bound to UI components to store their state information, like the number selected with a slider, or the content of a text field, and trigger a handler whenever the variable's content changes. For instance, we can bind the textfield
component to a variable as:
@app begin
@in msg = ""
end
ui() = textfield("Message", :msg )
Whenever the user types in the text field in the browser, the msg
variable will be updated in the backend.
Reactive variables must be initialized to a constant value of the appropriate type, which may come from a previously defined variable or assigned at declaration time.
const total_N = 100
@app begin
@in N = total_N
@in M = 25
end
Moreover, you cannot use a reactive variable to initialize another variable, as in the following example:
@in N = 0
@in M = N + 1
This code will throw an error due to N
not existing.
Reactive handlers
Reactive handlers define the code that is executed when a reactive variable changes in value. The first two types of handlers are defined using the @onchange
or @onbutton
macros, and they are triggered whenever a specified reactive variable's value changes, either from the frontend or the backend.
@app begin
@in msg = ""
@out msg_length = 0
@onchange N begin
msg_length = length(msg)
end
end
ui() = [textfield("Message", :msg), p("Length: {{msg_length}}")]
Handlers can also be triggered by modifying their watched variable from another handler:
@app begin
@in N = 0
@in M = 0
@onchange N begin
M = N+1
end
@onchange M begin
@info "M changed to $M"
end
end
Moreover, it is possible to modify a reactive variable without triggering its handler by appending the [!] suffix to it:
@app begin
@in N = 0
@in M = 0
@onchange N begin
M[!] = 0 # this won't trigger the handler
M = N+1
end
@onchange M begin
@info "M changed to $M"
end
end
The @onbutton
macro is used to watch booleans, and it sets their value to false
after the handler is executed.
@in trigger = false
@onbutton trigger begin
print("Action triggered")
end
ui = [btn("Trigger action", @click(:trigger))]
Finally, some components like the file uploader emit events when the user interacts with them. These events can be intercepted in the backend with the @event
macro as in this example:
@event uploaded begin
@info "File uploaded"
@notify("File uploaded")
end
@event rejected begin
@info "rejected"
@notify("File rejected")
end
Reactive variable scoping
There are some concepts to keep in mind when writing reactive code:
- Reactive variables can only be modified within a handler implemented with the
@onchange
,@onbutton
or@event
blocks.
Any change attempts made to a reactive variable outside a handler will not modify the reactive variable. For example, in the example below the assignmentN = 45
won't modify the declared reactiveN
, but will create a newN
variable in the module scope. Only the assignmentN = N + X
inside the handler will make changes to the reactive variable.@app begin @in N = 0 @in X = 0 N = 45 @onchange X begin N = N + X end end
- The reactive variables watched by a handler are passed to the handler by value, not by reference.
This is relevant when running a long task inside a handler that depends on the attached reactive variable, like in this snippet:@app begin @in toggle_on = false @onchange toggle_on begin while toggle_on == true begin @info "Toggle set to true" sleep(1) end end end ui() = toggle("Toggle", :toggle_on)
Once the toggle is clicked andtoggle_on
set totrue
, the handler will execute and enter the while loop. In this loop,toggle_on
will always be true since it was was passed by value to the handler. So, if we set the toggle tofalse
in the UI the reactive variable will be updated but the loop will not exit.
To check the global value oftoggle_on
, we need to directly access the reactive variable as stored in the reactive model__model__
@onchange toggle_on begin # This loop will check the referenced value instead of what's passed to the handler while __model__.toggle_on[] == true begin @info "Toggle set to true" sleep(1) end end
See here to learn how the reactive model works. - Variables declared within an
@onchange
block are scoped, meaning that they will not overwrite global variables but create new ones.
To modify a global variable, you must use theglobal
keyword:N = 0 M = 0 @app begin @in toggle = false @onchange toggle begin global N = N + 1 M = M+1 # This will create a new variable M inside the handler end end
Composite objects and reactivity
Modifying a field in a composite object like an array or a struct in the Julia code won't trigger a value synchronization to the UI. To trigger the synchronization, the object needs to be reassigned or synced manually with the @push
macro as this example shows:
@app begin
@out x = collect(1:10)
@out y = randn(10)
@in add_data = false
# How to update array data
@onbutton add_data begin
push!(x, length(x)+1) # This will not trigger an update in the UI
@push x # This will send the value to the UI
y = vcat(y, randn(1)) # Variable reassignments also trigger UI updates
end
end
Similarly, a handler won't be triggered when a field in an object is modified, only when the entire object is assigned a new value.
Using custom types as reactive variables
Reactive variables are serialized and sent to the browser as Javascript objects. Most base Julia types, like, for example, Int
, String
, Vector{String}
, Dict
, can be declared as reactive. Moreover, custom struct definitions can also be exposed to the UI like in this example:
using GenieFramework
@genietools
mutable struct MyContent
c::Int
end
mutable struct MyData
description::String
data::MyContent
end
@app begin
@out d = MyData("hello", MyContent(1))
end
ui() = [p("{{d.description}}"),p("{{d.data}}"),p("{{d.data.c}}")]
@page("/", ui)
up()
If some object cannot be serialized, you'll need to specialize Stipple.render
and Stipple.stipple_parse
to make it work. For example, this specialization allows sending a Matrix
type to the browser as an array of arrays:
Stipple.render(X::Matrix) = [X[:, i] for i in 1:size(X, 2)]
Stipple.stipple_parse(T::Type{Matrix}, X::Matrix) = hcat(X...)
Under the hood: reactive models and observables
Reactive variables are stored as fields in a struct named __model__
, which is only exposed inside the @app
block. The content of a model can be printed from a handler like this:
@app begin
@in N = 0
@onchange isready begin
@show __model__
@show __model__.N
end
end
__model__ = Instance of 'Main_ReactiveModel'
channel__ (internal): QTQJPSJHFPNOEKMXSAUSRBPZPEWYPWIP
modes__ (internal): LittleDict{Symbol, Int64, Vector{Symbol}, Vector{Int64}}()
isready (autofield, in): true
isprocessing (autofield, in): false
fileuploads (autofield, in): Dict{AbstractString, AbstractString}()
N (in): 0
__model__.N = Reactive{Int64}(Observable(0), 1, false, false, "/Users/pere/genie/mwes/reactivemodel/app.jl:5")
Notice that the variable N
is an Observable
from the Observables.jl package, which implements the reactivity Stipple relies on. Thus, one can directly interact with the observable via the __model__
struct and access its value as __model__.N[]
.
Moreover, Stipple translates its handlers to ObserverFunctions
from Observables.jl
, which are defined like this:
obs_func = on(observable) do val
println("Got an update: ", val)
end
Notice that the handler is an anonymous function taking the observable value, which explains the scoping limitations discussed in point 2 of the Reactive variable scoping section.
Under the hood: reactive storage and handlers
When reactive variables and handlers are defined, they are stored in the REACTIVE_STORAGE
and HANDLERS
dictionaries of the GenieFramework.Stipple.ReactiveTools
module. For example, these storage objects may look like this:
julia> GenieFramework.Stipple.ReactiveTools.REACTIVE_STORAGE[Main]
LittleDict{Symbol, Expr, Vector{Symbol}, Vector{Expr}} with 6 entries:
:channel__ => :(channel__::String = Stipple.channelfactory())
:modes__ => :(modes__::Stipple.LittleDict{Symbol, Any} = $(QuoteNode(LittleDict{Symbol, Any, Vector{Symbol}, Vector{Any}}())))
:isready => :(isready::Stipple.R{Bool} = false)
:isprocessing => :(isprocessing::Stipple.R{Bool} = false)
:N => :(N::R{Int64} = R(0, 1, false, false, "REPL[2]:2"))
:total => :(total::R{Int64} = R(0, 4, false, false, "REPL[2]:3"))
julia> GenieFramework.Stipple.ReactiveTools.HANDLERS[Main]
1-element Vector{Expr}:
quote
#= /Users/pere/.julia/packages/Stipple/pgem3/src/ReactiveTools.jl:689 =#
on(__model__.N) do N
#= /Users/pere/.julia/packages/Stipple/pgem3/src/ReactiveTools.jl:690 =#
#= REPL[2]:5 =#
print("N value changed to $(N)")
#= REPL[2]:6 =#
__model__.total[] = __model__.total[] + N
end
end
When a user makes an HTTP request to a route, a new ReactiveModel
instance is created from the storage for that specific user session. This ensures that each user has an isolated state and can interact with the application independently, without affecting the state of other users.
The model instantiated for the request can be accessed with @init
when using the route
function instead of @page
:
route("/") do
model = @init
@show model
page(model, ui()) |> html
end
var"##Main_ReactiveModel!#292"("OSINKNHRJHNKBFXCFZVKSWQVMWTUMUNN", LittleDict{Symbol, Any, Vector{Symbol},
Vector{Any}}(), Reactive{Bool}(Observable(false), 1, false, false, ""), Reactive{Bool}(Observable(false), 1, false, false, ""),
Reactive{Int64}(Observable(0), 1, false, false, "REPL[2]:2"), Reactive{Int64}(Observable(0), 4, false, false, "REPL[2]:3"))
When the new reactive model instance is created, it is assigned a unique identifier, which is used to track the user's session and maintain the state for the entire duration of the session. This identifier is used by the Genie server to route the websocket messages to the appropriate reactive model instance. Communication between the frontend and the backend is facilitated by websockets, which provide real-time, bidirectional communication channels between the client and the server. When a reactive variable's value changes in the frontend or backend, a websocket message is sent to the other side containing the updated value.
Introduction
The Stipple.jl package in Genie Framework implements a low-code API that helps Julia developers quickly and easily create interactive data dashboards and data-centric applications, as well as web-based user interfaces for Julia software. Moreover, it offers a large collection of user interface elements (such as inputs, buttons, sliders, tabs, data tables, responsive layouts, and many more) and powerful plotting capabilities.
Low-code UI API
Implement UIs in pure Julia with the low-code API.