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, code, 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 variables and code

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 non-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.

Reactive code blocks are used to define the behavior of the application when a reactive variable changes in value. The blocks are defined using the @onchange macro, and they are triggered whenever a specified reactive variable's value changes, either from the frontend or the backend.

@app begin
    @in N = 0
    @out total = 0
    @onchange N begin
        print("N value changed to $N")
        total = total + N
    end
end

The definition of a new reactive variable requires an initial value of the appropriate type. For instance, in the example above, both N and total are of type Int. If the value introduced in the UI for N is a Float, the app will throw an error.

Reactive variables tagged with @in or @out can only be modified within a block delimited by the @onchange macro. Any changes made outside of it will not be reflected in the UI.

Reactive UI components

Genie apps use in their UI Vue.js components from the Quasar framework, which provides a wide variety of responsive and reusable components for building feature-rich web applications. Reactivity is established by connecting the Quasar components to the reactive variables defined in the Julia backend. This is achieved through the v-model attribute on the components, which binds the input elements to the reactive variables.

Below, using the low-code API, we bind a textfield component to the reactive variable N from the previous example:

textfield("N", :N )

The resulting HTML code includes the v-model attribute, which connects the input field to the N reactive variable:

<q-input label="N" v-model="N"></q-input>

This ensures that any change in the value of the input field will be reflected in the N reactive variable, and the reactive code block will be executed accordingly.

Variable types

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 to make it work.

Recursive reactivity

In general, composite objects are not recursively reactive. This means that changing one of their fields will not always trigger a reactive handler. With dictionaries, for instance, changing a dict field in the Julia code will not propagate the new value to the browser. Only replacing the entire dict, or changing a field in the browser, will trigger a handler and propagate changes.

The example below depicts this behavior. There are three buttons to modify the data field in a dict: one runs code in the browser, and the other two trigger a handler in the backend. The reactive handler watching the dictionary d is only triggered when pressing the first (frontend) and third (dict replacement) buttons. Modifying the field from the backend using the second button increases the counter but does not trigger the handler.

using GenieFramework
@genietools

@app begin
    @in d = Dict("description" => "hello", "data" => 1)
    @in change_field = false
    @in replace_dict = false
    @onchange d begin
        @show d
    end
    @onbutton change_field begin
        d["data"] += 1
    end
    @onbutton replace_dict begin
        d = Dict("description" => d["description"], "data" => d["data"] + 1)
    end
end

ui() = [p("{{d.data}}"), btn("Frontend +1 to field", @click("d.data += 1")), br(), btn("backend +1 to field", @click(:change_field)), br(), btn("backend replace dict", @click(:replace_dict))]
@page("/", ui)
up()

Under the hood: reactive models

Reactive models work by maintaining an internal representation of reactive variables and code blocks. When you define reactive variables and code blocks, they are stored in the REACTIVE_STORAGE and HANDLERS dictionaries of the GenieFramework.Stipple.ReactiveTools module. For example, the storage for the @app block in the previous example contains:

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.