Interactive
Drive what JoyDom renders from external SwiftUI state — the document is a function of your app's state.
A Joy DOM document is just data, and JoyDom is just a SwiftUI view. So you get interactivity for free: make the document a function of your app's state, and whenever that state changes, SwiftUI re-evaluates body, the document is recomputed, and the view updates. Events flow the other way — .onEvent mutates the state — closing the loop.
state changes ──▶ recompute Spec ──▶ JoyDom re-renders
▲ │
└──────────── .onEvent ◀───────────────┘State → document → view
Hold your state in @State (or an @Observable model) and build the Spec from it. Because the spec is recomputed on each render, any value you interpolate is always current. A tap on a node that carries an onclick binding reaches your one .onEvent handler; mutate state there and the cycle repeats.
import SwiftUI
import JoyDOM
struct LikeCard: View {
@State private var likes = 0
private let registry = ComponentRegistry().withDefaultPrimitives()
var body: some View {
JoyDom(spec: cardSpec, components: registry)
.viewport(Viewport(width: 360))
.onEvent { event in
if event.name == "like" { likes += 1 } // event → state
}
}
// The document is derived from `likes`, so the count in the button
// label updates every time the state changes.
private var cardSpec: Spec {
let json = """
{
"version": 1, "style": {}, "breakpoints": [],
"layout": { "type": "div", "props": {
"id": "like", "style": { "display": "flex", "padding": { "value": 12, "unit": "px" } },
"onclick": { "type": "event", "name": "like" }
},
"children": ["♥ Like (\(likes))"]
}
}
"""
return (try? JSONDecoder().decode(Spec.self, from: Data(json.utf8)))
?? Spec(layout: Node(type: "div"))
}
}This is exactly what the one-file JoyDOMShowcase example does — git clone the package, then swift run JoyDOMShowcase from its Example/ folder to watch the count climb.
Re-decoding is cheap, but you don't have to
Rebuilding the spec from a string re-decodes JSON on every change, which is fine for small
documents. For larger ones, keep a decoded Spec and mutate just the node you need — its
props/children are plain Codable values.
External controls
The state doesn't have to come from inside the document — anything in your app can drive it. Put native SwiftUI controls next to JoyDom and feed their values into the spec, and the rendered document reacts live:
struct PreviewTuner: View {
@State private var fontSize = 16.0
@State private var dark = false
private let registry = ComponentRegistry().withDefaultPrimitives()
var body: some View {
VStack {
// Native controls, outside the document.
Slider(value: $fontSize, in: 12...32)
Toggle("Dark", isOn: $dark)
// …feeding the document that JoyDom renders.
JoyDom(spec: spec(fontSize: fontSize, dark: dark), components: registry)
.viewport(Viewport(width: 360))
}
.padding()
}
// The args are interpolated straight into the document: `fontSize`
// becomes a px length, `dark` picks a background colour.
private func spec(fontSize: Double, dark: Bool) -> Spec {
let json = """
{
"version": 1, "breakpoints": [],
"style": { "#card": {
"display": "flex", "padding": { "value": 16, "unit": "px" },
"fontSize": { "value": \(fontSize), "unit": "px" },
"backgroundColor": "\(dark ? "#111827" : "#F3F4F6")"
}
},
"layout": { "type": "div", "props": { "id": "card" },
"children": ["Live-restyled text"]
}
}
"""
return (try? JSONDecoder().decode(Spec.self, from: Data(json.utf8)))
?? Spec(layout: Node(type: "div"))
}
}Because spec(fontSize:dark:) is called inside body, moving the slider or flipping the toggle recomputes the document and JoyDom re-renders with the new styling — no manual refresh, no diffing on your side.
Where next
- Events — the
onclick/emit/.onEventcontract that feeds state back in. - Custom components — components that hold their own state and
emitfrom it. - How it works — what happens between a
Specchange and the pixels.