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.
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))
)
endDark 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.
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")
endSearch
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.
@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
)
endTodo 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 / 5Click '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
)
endShow / 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.
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
)
endMount 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)
)
endAuto-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")
)
endSignal 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.
@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)
)
endData 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.
| Name | Age | Score | City |
|---|---|---|---|
| Alice | 28 | 95.2 | Portland |
| Bob | 35 | 87.1 | Austin |
| Carol | 42 | 91.8 | Denver |
| Dave | 23 | 78.4 | Seattle |
| Eve | 31 | 93.6 | Boston |
| Frank | 45 | 82.3 | Chicago |
| Grace | 27 | 96.1 | Miami |
| Heidi | 33 | 88.5 | Phoenix |
| Ivan | 29 | 90.3 | Dallas |
| Judy | 38 | 84.7 | Atlanta |
# 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)))
endPlot 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.
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)), "+"),
),
)
endNotebook
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.
Array Operations
Read-only code cells with their output rendered above.
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.
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.
Step 3: Slider → Reactive Output
The @bind pattern. A slider signal drives a create_memo — the dependent cell updates whenever the slider moves.
Interactive Computation
Drag the slider — the dependent cell recomputes reactively.
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.
Reactive Plot
Slider drives a chain: @bind cell → compute → WasmPlot redraws into the next cell.
Step 5: Multiple Inputs
Two @bind sliders feed one create_memo. Handlers are auto-batched — changing either slider fires the dependent effect exactly once.
Multiple Inputs
Two sliders feed one create_memo. Handlers are auto-batched — moving either slider fires the dependent effect once.
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.
2D Wave Analysis
Interactive heatmap of sin(x*f) · cos(y*f). Adjust the frequency parameter below.