How godom composes: islands, partials, and shared state
May 4, 2026 • 9 min read
godom's composition model: stateful islands, stateless partials, slot-based content, and the shared-pointer trick for state across islands.
TL;DR
- Islands are godom’s stateful runtime units. Each island is its own Go struct, its own parsed template, its own VDOM tree, its own ID counter, its own goroutine, and its own event channel. Reach for one when you want a long-lived runtime piece.
- Partials are stateless HTML includes resolved at parse time. Zero runtime cost. Use them for shared buttons, icons, layout fragments, anything without state.
- Partials can carry a single
<g-slot/>for consumer-supplied content. Islands compose by nesting: a parent’s template can declare child slots, but parents don’t control children once they mount. - Cross-island state moves through shared embedded Go pointers. Mutate the pointer in one island; the framework re-renders the other islands that embed it.
In the wire-protocol post , I described what travels between Go and the browser. This post is about what sits on each end. The composition model.
godom has two composition tiers. One is template machinery; the other is a real runtime unit. The rest of the post is what each is for, how they nest, and how state moves between them.
What an island is
An island is a Go struct that embeds godom.Island. When you call eng.Register(myIsland), the engine attaches a runtime to it. After registration, the island has all of:
- A parsed template tree.
ParseTemplateruns once on the island’s HTML and produces a[]*TemplateNode. Immutable from that point. - Its own VDOM tree. The current resolved tree, kept around so the next render can diff against it.
- A monotonic
IDCounter, shared engine-wide. Stable node IDs, never reset, unique across all islands. The bridge keeps a per-islandnodeMapkeyed against IDs from that counter. - A buffered event channel. Browser events (
g-click,g-bindupdates) and background refresh requests both arrive here. - A dedicated goroutine. It reads events off the channel, runs the work, triggers renders. One goroutine per island, not per render cycle.
- A refresh function. Background goroutines you write call
island.Refresh()to push a new render to all connected tabs.
Each island also holds its own location in the page, named by TargetName. A <div g-island="counter"></div> element in the host HTML or in a parent island’s template is where this island gets mounted. The bridge handles the rest.
Islands are autonomous runtime units. Each one is small, but each one is real: a thing that exists in your process for as long as the app runs.
Partials: the stateless tier
Partials are HTML includes. You write <todo-item> in a template; godom resolves it to todo-item.html at parse time. There is no goroutine, no state, no lifecycle. It is text substitution by another name, plus a small directive pass on the resolved fragment.
<!-- partials/todo-item.html -->
<li>
<input type="checkbox" g-checked="todo.Done" g-click="Toggle(i)" />
<span g-text="todo.Text"></span>
<button g-click="Remove(i)">x</button>
</li>
<!-- consumer template -->
<ul>
<todo-item g-for="todo, i in Todos"></todo-item>
</ul>
Directives inside the partial resolve against the enclosing island’s state. Loop variables (todo, i) are in scope. Partials don’t take “props” from the consumer; they expand in place and the directive layer treats them like inline content.
Cost at runtime: zero. Use them for shared buttons, icons, headers, layout fragments, list items, anything that doesn’t need state of its own.
<g-slot/> for consumer content
A partial can carry one <g-slot/> marker. Anything between the consumer’s tags fills that slot.
<!-- partials/info-note.html -->
<div class="callout">
<svg>...icon...</svg>
<div><g-slot/></div>
</div>
<!-- consumer template -->
<info-note>
<p>Free-form body content lands inside the callout.</p>
<p>And here.</p>
</info-note>
The callout captures the consumer’s children at the slot position. A partial without a <g-slot/> discards its children, which is the original include behavior preserved for back-compat.
One slot per partial, no named slots, no slot props. If you want more than that, what you actually want is an island.
Islands: the runtime tier
In a template, the difference between an island and a partial is one attribute:
<my-button label="Save"/> <!-- partial: resolved at parse time -->
<div g-island="counter"></div> <!-- island: resolved at runtime -->
The pricing is in the runtime cost, not the syntax. Partials don’t enter the engine’s island registry; islands do. A partial doesn’t get its own VDOM tree; an island does. A partial doesn’t have a goroutine; an island does.
Nesting: parents host, parents don’t control
A parent island’s template can include g-island="child" markers for other registered islands. When the bridge renders the parent, those markers appear in the DOM. The bridge then asks the server for each child’s tree and mounts them.
Once a child is mounted, the parent doesn’t have a handle to it. Each island runs its own goroutine, owns its own state, manages its own refresh cycle. There is no parent method to call children, no parent reference to a child instance, no lifecycle hook firing on the parent when a child renders. The hierarchy in the DOM is real; the hierarchy at runtime is flat.
This is the property that keeps the model simple. The moment a parent could control a child, you’d need an interface contract between them, a mounting protocol, parent-side state about children, ordering rules, cleanup semantics. None of that exists in godom because the relationship is one-way: the parent declares space for a child to live in, and that’s it.
When islands need to talk, they share state on the server through Go’s normal type system.
Shared state via embedded pointers
The simplest cross-island communication mechanism is two islands embedding the same pointer:
type Shared struct {
Count int
}
type Counter struct {
godom.Island
*Shared
}
type Display struct {
godom.Island
*Shared
}
shared := &Shared{}
counter := &Counter{Shared: shared}
display := &Display{Shared: shared}
counter.TargetName = "counter"
display.TargetName = "display"
Both islands embed *Shared. counter.Count and display.Count are the same field, on the same heap object. When counter.Increment() mutates Count, the engine notices that the field is owned by an embedded pointer that a sibling also embeds, and automatically refreshes the sibling. No event bus, no callback registration, no parent middleware.
Mechanically: at startup, the engine walks every registered island and indexes which embedded pointers each one carries. Whenever the framework runs a render (after a method call, after a bound input change, or after a background Refresh() from a goroutine), it also re-renders any sibling whose embedded pointer was touched. The sibling’s diff is computed locally, in its own goroutine, against its own previous tree, with its own IDs. Both islands ship their own SERVER_PATCH. Two islands that don’t share a pointer don’t refresh each other; the indexing is exact.
*Counter mutates. The framework re-renders both islands; each ships its own patch.The animation above shows two islands embedding the same *Shared. A click in one of them mutates the field; the other re-renders without ever knowing the click happened.
Pull-based init for dynamic mounts
Islands can come and go after the page is up.
When a g-island element appears in the DOM after the initial render (because a parent island included it, because user code called godom.mount(), because routing swapped views), the bridge sends a BROWSER_INIT_REQUEST with the island’s name. The server responds with a SERVER_INIT carrying that island’s tree. The bridge mounts it.
This sounds simple but it matters. The alternative is the server eagerly pushing every island’s tree on connect, even ones that aren’t on screen yet. Pull-based init means islands have a real lifecycle: they exist on the server unconditionally, but their browser footprint comes and goes with the DOM. Out-of-order arrivals (a child init landing before its parent’s host element rendered) are handled by the bridge queueing and retrying.
Embedded mode: godom inside someone else’s page
The whole composition story lands here. godom is meant to be embeddable.
The most pedestrian case: an existing HTML page you control, with one godom island dropped in for the part that needs state.
<!-- a static page you already have -->
<article>
<h1>...</h1>
<p>...</p>
<!-- godom takes over just this region -->
<div g-island="comments"></div>
</article>
<script>window.GODOM_WS_URL = "ws://localhost:9091/ws";</script>
<script src="http://localhost:9091/godom.js"></script>
The Go side registers comments as an island, runs eng.NoBrowser = true and eng.ListenAndServe(), and that’s it. The host page is whatever Markdown-rendered, server-rendered, or static-HTML thing you already had. godom slots into the marked region and runs there.
The example I keep coming back to is examples/multi-page-v2/: a small site composed of separate Go packages, each registering its own islands and partials, mounted onto pages served by ordinary net/http handlers. There’s no godom router, no page lifecycle in godom, no tooling to learn. Go’s standard library handles routing; godom just supplies the live regions.
A more aggressive case is examples/embedded-widget/. The host page is a fictional MarketPulse news site served by an ordinary http.FileServer on port 9090. godom runs on port 9091 and serves only its WebSocket and the bridge JS bundle. The host page declares three island targets (<div g-island="marquee">, <div g-island="stock">, <div g-island="heatmap">) and pulls in godom.js from the godom server. Each island renders into its slot, talks to its own Go state on the server, and the static article around them is plain HTML the host owns.
The heatmap island in that example uses g-shadow. Adding g-shadow to a target element tells the bridge to attach an open Shadow DOM root and render the island into it. Host page styles cannot reach inside the island, and island styles cannot leak out. The example deliberately demonstrates this: the host page has a .ghm span rule that would break the heatmap without shadow protection. Shadow DOM is the difference between “this works alongside a page I trust” and “this works alongside a page I don’t fully control.”
That last case is real for me. There’s a Chrome extension at the top of the godom repo that injects godom.js into any page matching a URL pattern you configure. Once injected, a godom server you’re running locally can render islands into the page (a sidebar panel, named regions, whatever the host has slots for). I use this to extend pages I don’t control with my own Go-side state and logic, including some browser-automation flows. The full use case is its own thing; the relevant point here is that “godom can sit in someone else’s page” is not a hypothetical for me, it’s the workflow.
Embedded mode is what keeps godom out of web-framework territory. The moment islands can sit in any host page, the questions a framework has to answer (routing, lifecycle, view trees, prop drilling) are no longer godom’s questions to answer. They belong to whatever serves the host page.
A note on the name
This unit used to be called g-component. The rename to g-island did two things: it matched the wider ecosystem (Astro, Qwik, Marko, Fresh all use “island” for stateful units dropped into an otherwise-static page) and it set the cost expectation honestly. A thing carrying a goroutine, an event channel, and persistent state isn’t a lightweight render primitive; calling it one trained both me and the agents I was working with to reach for it the wrong way.
It also took pressure off godom to grow into a web framework. With “component” framing, the next questions were always “how do components handle routing?” or “what’s the parent-child lifecycle?” With “island,” those questions don’t apply. Islands sit in a host page. The host can be anything.
Closing thought
Two tiers, one slot mechanism, one shared-pointer convention, one pull-based init protocol. That’s the whole composition surface. Keeping the surface small is what lets godom apps stay godom apps instead of slowly becoming small web frameworks.
The next post in this set is about the practical decision boundary in the other direction: when to write plain JavaScript inside godom, when to write a plugin, and when to let the directives handle it.