Joy DOM

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/.onEvent contract that feeds state back in.
  • Custom components — components that hold their own state and emit from it.
  • How it works — what happens between a Spec change and the pixels.

On this page