Examples

Interactive examples built with Therapy.jl. Code snippets below are simplified — see the full source in docs/src/components.

Counter

Signals, memos, and effects — open your browser console to see the effect logging.

0
doubled 0
using Therapy: Div, Button, Span
using Therapy: @island, create_signal, create_memo, create_effect, js

@island function InteractiveCounter(; initial::Int = 0)
    count, set_count = create_signal(initial)
    doubled = create_memo(() -> count() * 2)
    create_effect(() -> js("console.log('count:', $1, 'doubled:', $2)", count(), doubled()))

    return Div(
        Div(
            Button(:on_click => () -> set_count(count() - 1), "-"),
            Span(count),
            Button(:on_click => () -> set_count(count() + 1), "+")
        ),
        Span("doubled ", Span(doubled))
    )
end

Dark Mode Toggle

Cross-island signal sharing. This toggle and the one in the nav bar are separate @island instances, each with their own WASM module. They share a module-level signal automatically — WASM reads the shared value via an import call. Click either toggle and both stay in sync.

Toggle dark mode →
using Therapy: Button
using Therapy: @island, create_signal, js

# Module-level signal — shared across ALL instances automatically
const dark_mode = create_signal(0)

@island function DarkModeToggle()
    is_dark, set_dark = dark_mode  # captures the shared signal

    # Sync with browser dark state on hydration
    js("if(document.documentElement.classList.contains('dark'))$1(1)", set_dark)

    return Button(:on_click => () -> begin
        set_dark(1 - is_dark())
        js("document.documentElement.classList.toggle('dark')")
        js("localStorage.setItem('therapy-theme', ...)")
    end, "Toggle")
end

Leptos-style string signals. create_signal("") creates a WasmGC ref-typed global that holds the query string. On each keystroke, the input value is bridged to a WasmGC string and written to the global. The memo reads it via query() and filters using lowercase and startswith — all running in WebAssembly. This is the same code shown below.

37 languages
Julia
Python
Rust
Go
JavaScript
TypeScript
Haskell
Elixir
Ruby
Swift
Kotlin
Scala
@island function SearchableList(;
        items_data::Vector{String} = String[],
        visible_init::Int = 12
    )
    # String signal — the query text, stored as a WasmGC ref global
    query, set_query = create_signal("")

    # Integer signals for pagination
    visible_count, set_visible_count = create_signal(visible_init)
    total_count, _ = create_signal(length(items_data))

    # Memo: filter by query, then take first N items.
    # query() reads the WasmGC string global.
    # lowercase() and startswith() compile to WASM intrinsics.
    visible_items = create_memo(() -> begin
        q = lowercase(query())
        n = visible_count()

        filtered = String[]
        for i in 1:length(items_data)
            if length(q) == 0 || startswith(lowercase(items_data[i]), q)
                push!(filtered, items_data[i])
            end
        end

        result = String[]
        for i in 1:min(n, length(filtered))
            push!(result, filtered[i])
        end
        result
    end)

    return Div(
        Input(:type => "text", :on_input => set_query),
        For(visible_items) do item
            Div(item)
        end,
        # Show() with closure conditions — compiled to WASM
        Show(() -> visible_count() < total_count()) do
            Button(:on_click => () -> set_visible_count(visible_count() + 12),
                "show more")
        end,
        Show(() -> visible_count() > 12) do
            Button(:on_click => () -> set_visible_count(visible_count() - 12),
                "show less")
        end
    )
end

Todo List

Dynamic list rendering with For(). An integer signal tracks how many items to show. A memo derives the visible Vector{String} slice. When the count shrinks, For removes DOM nodes and disposes their owners. Show() conditions control button visibility based on signal comparisons compiled to WASM.

Todos

5 / 5
Buy milk
Write Julia code
Ship to production
Review PR
Fix tests

Click 'Remove last' to shrink the list

@island function TodoList(;
        items_data::Vector{String} = String[]
    )
    remaining, set_remaining = create_signal(length(items_data))
    total, _ = create_signal(length(items_data))

    visible = create_memo(() -> begin
        n = remaining()
        result = String[]
        for i in 1:min(n, length(items_data))
            push!(result, items_data[i])
        end
        result
    end)

    create_effect(() -> js("console.log('todo remaining:', \$1)", remaining()))

    return Div(
        Span(remaining, " / $(length(items_data))"),
        For(visible) do item
            Div(Span(item))
        end,
        Show(() -> remaining() > 0) do
            Button(:on_click => () -> set_remaining(remaining() - 1), "Remove last")
        end,
        Show(() -> remaining() < total()) do
            Button(:on_click => () -> set_remaining(remaining() + 1), "Add back")
        end
    )
end

Show / Fallback

Conditional rendering with Show() and a fallback prop. When the signal is truthy, the content is inserted into the DOM. When falsy, the fallback replaces it. Owner disposal ensures effects inside the shown content are cleaned up on each toggle — open the console to see the effect log.

I exist in the DOM right now!

Inspect this element — when you click Toggle, these nodes are completely removed and the fallback content appears instead.

Content is hidden. Click Toggle to show it.

This is the fallback prop — swapped in when condition is false.

using Therapy: Div, Button, P, Code, Strong, Show
using Therapy: @island, create_signal, create_effect, js

@island function ShowDemo(; initial_visible::Int = 1)
    visible, set_visible = create_signal(initial_visible)

    create_effect(() -> js("console.log('ShowDemo visible:', \$1)", visible()))

    return Div(
        Button(:on_click => () -> set_visible(1 - visible()), "Toggle Content"),

        Show(visible; fallback=Div(
                P("Content is hidden. Click Toggle to show it."),
                P("This is the ", Code("fallback"), " prop.")
            )) do
            Div(
                P("I exist in the DOM right now!"),
                P("These nodes are completely ", Strong("removed"),
                  " when you click Toggle.")
            )
        end
    )
end

Mount vs Effect Lifecycle

on_mount runs exactly once after the island hydrates. create_effect re-runs every time a tracked signal changes. Open your browser console (F12) and click the button — you will see a single on_mount log, but the effect logs on every click.

count: 0

Open console (F12) — on_mount prints once, create_effect prints on every click.

using Therapy: Div, Button, P
using Therapy: @island, create_signal, create_effect, on_mount, js

@island function MountDemo()
    count, set_count = create_signal(0)

    # Runs ONCE after hydration — never again
    on_mount(() -> js("console.log('on_mount: I ran once!')"))

    # Runs on every count() change
    create_effect(() -> js("console.log('create_effect: count is', \$1)", count()))

    return Div(
        Button(:on_click => () -> set_count(count() + 1), "Click me"),
        P("count: ", count)
    )
end

Auto-Batching

Event handlers are automatically batched. The handler sets two signals, but the effect that reads both fires only once per click — not twice. Open the console (F12) and click a button to verify: you should see a single effect: log per click.

a=0 b=0

Open console — each click logs one effect, not two

using Therapy: Div, Button, P, Span, Strong
using Therapy: @island, create_signal, create_effect, js

@island function BatchDemo()
    a, set_a = create_signal(0)
    b, set_b = create_signal(0)

    # Effect reads BOTH signals — with auto-batch, fires once per click
    create_effect(() -> js("console.log('effect: a=', \$1, 'b=', \$2)", a(), b()))

    return Div(
        P("a=", Span(a), "  b=", Span(b)),
        Button(:on_click => () -> begin
            set_a(a() + 1)
            set_b(b() + 10)
        end, "Increment both"),
        Button(:on_click => () -> begin
            set_a(0)
            set_b(0)
        end, "Reset")
    )
end

Signal Types

All four signal types compiled to WASM. Int64 (i64 global), Bool (i32 global), Float64 (f64 global), String (WasmGC ref global). Each type has its own WASM representation and JS bridge.

Int64
0
Bool
Float64
98.6
String
@island function SignalTypesDemo()
    count, set_count = create_signal(0)        # Int64 → WASM i64
    active, set_active = create_signal(false)  # Bool → WASM i32
    temp, set_temp = create_signal(98.6)       # Float64 → WASM f64
    name, set_name = create_signal("")         # String → WasmGC ref

    create_effect(() -> js("console.log($1, $2, $3)", count(), active(), temp()))

    return Div(
        Button(:on_click => () -> set_count(count() + 1)),
        Button(:on_click => () -> set_active(!active())),
        Button(:on_click => () -> set_temp(temp() + 1.0)),
        Input(:type => "text", :on_input => set_name)
    )
end

Data Table

Sortable, paginated table — all sorting runs in WebAssembly. DataTable() is an SSR function that passes four column vectors to DataExplorer(), an @island that sorts integer indices by the selected column using isless() on string values, compiled to WASM via the cmp overlay. Click any column header to toggle ascending/descending sort.

NameAgeScoreCity
Alice2895.2Portland
Bob3587.1Austin
Carol4291.8Denver
Dave2378.4Seattle
Eve3193.6Boston
Frank4582.3Chicago
Grace2796.1Miami
Heidi3388.5Phoenix
Ivan2990.3Dallas
Judy3884.7Atlanta
# TIER 1: SSR — split data into column vectors
function DataTable()
  names  = ["Alice", "Bob", "Carol", ...]
  ages   = ["28", "35", "42", ...]
  scores = ["95.2", "87.1", "91.8", ...]
  cities = ["Portland", "Austin", "Denver", ...]
  DataExplorer(col_names=names, col_ages=ages,
    col_scores=scores, col_cities=cities)
end

# TIER 2: @island — WASM-compiled sorting
@island function DataExplorer(;
    col_names::Vector{String}=String[], ...)
  visible_count, set_visible_count = create_signal(10)
  sort_col, set_sort_col = create_signal(0)

  # Memo: sort indices by selected column
  visible_indices = create_memo(() -> begin
    c = sort_col()
    indices = Int64[]
    for i in 1:length(col_names)
      push!(indices, Int64(i))
    end
    if c == 1 || c == -1
      # Insertion sort by col_names (isless compiles via cmp overlay)
      for ii in 2:length(indices)
        key_idx = indices[ii]
        jj = ii - 1
        while jj >= 1
          if isless(col_names[indices[jj]], col_names[key_idx])
            break
          end
          indices[jj+1] = indices[jj]; jj -= 1
        end
        indices[jj+1] = key_idx
      end
    end
    indices[1:min(visible_count(), length(indices))]
  end)

  sort_by_name() = begin
    if sort_col() == 1; set_sort_col(-1)
    else; set_sort_col(1); end
  end

  Div(Table(
    Thead(Tr(
      Th(:on_click => sort_by_name, "Name"), ...)),
    Tbody(For(visible_indices) do idx
      Tr(Td(col_names[idx]), Td(col_ages[idx]),
         Td(col_scores[idx]), Td(col_cities[idx]))
    end)))
end

Plot Dashboard

One @island, one <canvas>, one WasmPlot.Figure with four Axis subplots — driven by three signals (freq, n_pts, shift). Each signal touches a unique plot AND a shared one: adjusting freq redraws both the line plot and the heatmap; n_pts updates the scatter and the line; shift rotates the barplot and shifts the heatmap phase. Every redraw is a single signal → effect → render! pass.

freq3
n_pts12
shift0
using WasmPlot
using Therapy: @island, create_signal, create_effect, Div, Span, Button, Canvas

@island function InteractivePlotDashboard()
    # Three independent signals driving four subplots
    freq,  set_freq  = create_signal(Int64(3))
    n_pts, set_n_pts = create_signal(Int64(12))
    shift, set_shift = create_signal(Int64(0))

    # SINGLE effect — reads all three signals, (re)builds fig + 4 axes, renders once
    create_effect(() -> begin
        f = Float64(freq()); npts = Int64(n_pts()); sh = Int64(shift())
        phase = Float64(sh) * 0.5

        fig = WasmPlot.Figure(size=(1000, 560))

        # [1,1] lines — depends on freq + n_pts. Makie convention: title + subtitle.
        ax_ln = Axis(fig[1, 1]; title="lines!", subtitle="depends on freq + n_pts",
                                xlabel="x", ylabel="sin(freq*x)")
        n_ln = npts * Int64(12); xs_ln = Float64[]; ys_ln = Float64[]
        i = Int64(1)
        while i <= n_ln
            xi = Float64(i) / Float64(n_ln) * 6.28318
            push!(xs_ln, xi); push!(ys_ln, sin(xi * f))
            i += Int64(1)
        end
        lines!(ax_ln, xs_ln, ys_ln; color=:blue, linewidth=2.0)

        # [1,2] scatter — depends on n_pts
        ax_sc = Axis(fig[1, 2]; title="scatter!", subtitle="depends on n_pts", xlabel="x", ylabel="y")
        xs_sc = Float64[]; ys_sc = Float64[]; seed = UInt64(1); j = Int64(1)
        while j <= npts
            seed = seed * UInt64(6364136223846793005) + UInt64(1442695040888963407)
            push!(xs_sc, Float64(seed >> 32) / Float64(typemax(UInt32)) * 10.0)
            seed = seed * UInt64(6364136223846793005) + UInt64(1442695040888963407)
            push!(ys_sc, Float64(seed >> 32) / Float64(typemax(UInt32)) * 10.0)
            j += Int64(1)
        end
        scatter!(ax_sc, xs_sc, ys_sc; color=:red, markersize=8.0)

        # [2,1] barplot — depends on shift
        ax_bp = Axis(fig[2, 1]; title="barplot!", subtitle="depends on shift", xlabel="category", ylabel="value")
        base = Float64[3.0, 7.0, 2.0, 5.0, 8.0, 4.0, 6.0]
        xs_bp = Float64[]; hs_bp = Float64[]; nb = length(base); k = Int64(1)
        while k <= nb
            push!(xs_bp, Float64(k))
            idx = (k - Int64(1) + sh) % Int64(nb)
            if idx < Int64(0); idx += Int64(nb); end
            push!(hs_bp, base[idx + Int64(1)])
            k += Int64(1)
        end
        barplot!(ax_bp, xs_bp, hs_bp; color=:green)

        # [2,2] heatmap — depends on freq + shift
        ax_hm = Axis(fig[2, 2]; title="heatmap!", subtitle="depends on freq + shift",
                                xlabel="x", ylabel="y")
        nx = Int64(20); ny = Int64(12); values = Float64[]; row = Int64(0)
        while row < ny
            col = Int64(0)
            while col < nx
                x = Float64(col) / Float64(nx) * 6.28318
                y = Float64(row) / Float64(ny) * 6.28318
                push!(values, sin(x * f + phase) * cos(y * f))
                col += Int64(1)
            end
            row += Int64(1)
        end
        heatmap!(ax_hm, (0.0, 10.0), (0.0, 6.0), Int(nx), Int(ny), values)

        render!(fig)   # single pass — all 4 subplots drawn together
    end)

    # Return the island's DOM tree.
    #
    # The `Canvas()` element renders a plain `<canvas>` — no prop wires it to
    # WasmPlot. The connection happens inside Therapy's runtime: when the island
    # hydrates, `__tw.io(island)` (WasmRuntime.jl) auto-detects any `<canvas>`
    # child with `el.querySelector('canvas')` and grabs its 2D context, then
    # supplies it as the `canvas2d` namespace in the WASM import object. Every
    # call WasmPlot makes to `canvas_move_to`, `canvas_fill`, `canvas_stroke`,
    # etc. routes through that context. So Canvas() "just shows up" because
    # Therapy silently wires the 2D context to WasmPlot's import stubs at
    # instantiate time — no user-visible plumbing needed.
    return Div(
        Canvas(:width => 1000, :height => 560),
        Div(
            Span("freq"),
            Button(:on_click => () -> set_freq(freq() - Int64(1)), "-"),
            Span(freq),
            Button(:on_click => () -> set_freq(freq() + Int64(1)), "+"),
        ),
        Div(
            Span("n_pts"),
            Button(:on_click => () -> set_n_pts(n_pts() - Int64(4)), "-"),
            Span(n_pts),
            Button(:on_click => () -> set_n_pts(n_pts() + Int64(4)), "+"),
        ),
        Div(
            Span("shift"),
            Button(:on_click => () -> set_shift(shift() - Int64(1)), "-"),
            Span(shift),
            Button(:on_click => () -> set_shift(shift() + Int64(1)), "+"),
        ),
    )
end

Notebook

The full notebook UI built up in six steps. Each step composes the previous: static cells → visibility toggles → a slider-driven memo → a reactive WasmPlot chart → multiple inputs → a full published-notebook layout.

Step 1: Static Code Cells

The building block — a read-only code cell with its output. Cell numbers appear on hover.

1

Array Operations

Read-only code cells with their output rendered above.

Code
2
15
0.3 ms
x = [1, 2, 3, 4, 5] sum(x)
Code
3
(3.0, 1.5811388300841898)
12.1 ms
using Statistics mean(x), std(x)
Code
4
[1, 3, 6, 10, 15]
0.1 ms
cumsum(x)

Step 2: Cell Visibility

Hover the left gutter and click the eye to toggle a cell's code. Each cell owns its own create_signal; Show inserts / removes the code block from the DOM on toggle.

1

Cell Visibility

Two patterns. Hover the gutter to reveal the eye — clicking it toggles the code via Show. The first cell renders its output above the code (Pluto convention). The second cell ends with ; — Pluto's suppression marker — so it shows code only, no output.

Code
(3.0, 3.0, 1.5811388300841898)
0.8 ms
mean(x), median(x), std(x)
Code
1.2 ms
results = Dict(:mean => mean(x), :std => std(x));

Step 3: Slider → Reactive Output

The @bind pattern. A slider signal drives a create_memo — the dependent cell updates whenever the slider moves.

1

Interactive Computation

Drag the slider — the dependent cell recomputes reactively.

Code
2
10
0.2 ms
@bind n Slider(1:50)
Code
3
55reactive
0.1 ms
sum(1:n)

Step 4: Reactive Plot

Slider → computation → WasmPlot lines! chart. The signal drives a 3-cell reactive chain compiled to WebAssembly. Each cell has its own eye toggle.

1

Reactive Plot

Slider drives a chain: @bind cell → compute → WasmPlot redraws into the next cell.

Code
5
0.2 ms
@bind freq Slider(1:20)
Code
3
4.2 ms
lines!(ax, x, sin.(x .* freq))

Step 5: Multiple Inputs

Two @bind sliders feed one create_memo. Handlers are auto-batched — changing either slider fires the dependent effect exactly once.

1

Multiple Inputs

Two sliders feed one create_memo. Handlers are auto-batched — moving either slider fires the dependent effect once.

Code
2
freq5
0.1 ms
@bind freq Slider(1:20)
Code
3
amp10
0.1 ms
@bind amp Slider(1:20)
Code
4
3150reactive
1.3 ms
sum(sin.(x .* freq) .* amp)

Step 6: Full Published Notebook

Everything composed: tab bar, markdown, interactive @bind → WasmPlot heatmap, static cells with diagnostic badges, eye toggles, runtime badges, and on_mount lifecycle. This is what a Sessions.jl published notebook will look like.

analysis.jl

2D Wave Analysis

Interactive heatmap of sin(x*f) · cos(y*f). Adjust the frequency parameter below.

Code
3
0.2 ms
@bind freq Slider(1:15)
Code
4
8.4 ms
heatmap!(ax, (0,1), (0,1), nx, ny, values)
Code
5
CSV written: 1,200 rowsstatic
45.2 ms
CSV.write("output.csv", df)