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:

  1. 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 assignment N = 45 won't modify the declared reactive N, but will create a new N variable in the module scope. Only the assignment N = 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
    
  2. 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 and toggle_on set to true, 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 to false in the UI the reactive variable will be updated but the loop will not exit.
    To check the global value of toggle_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.
  3. 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 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.


Genie