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 variables, handlers and UI components

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.

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:

using GenieFramework
@app begin
    @in msg = ""
end
ui() = textfield("Message", :msg )
@page("/", ui)

Whenever the user enters text in the field in the browser, the msg variable will be updated in the backend.

Rective handlers

Reactive handlers define the code that is executed when a reactive variable changes in value. The 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}}")]

The @onbutton macro is used to watch booleans, and it sets their value to false after the handler is executed.

    @in trigger = false
    @onchange true begin
        print("Action triggered")
    end
ui = [btn("Trigger action", @click(:trigger))]

Reactive UI components

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 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 in the browser will be reflected on N in the Julia code, and the reactive code block will be executed accordingly. Likewise, any change to N in the backend will update the input field in the browser.

Defining reactive variables

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. 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 variables can only be modified within a handler implemented with the @onchange or @onbutton macros. Any changes made outside of it will not be reflected in the UI. This is because these variables reside within the @app block, and they are instantiated for each user session.

Finally, 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 the global 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
    end
end

Reactive UI components

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.