A counter clicked in one tab ticks up in every other tab. Mount opts each
connection into peer fan-out with ctx.Subscribe(ctx.SelfTopic()); Increment
bumps the controller's mutex-guarded counter and then calls
ctx.Publish(ctx.SelfTopic(), "RefreshCounter", nil), so every subscribed peer
runs RefreshCounter and reloads the shared value. The publish is explicit — the
counter only converges because the action fans the refresh out.
Multi-User Refresh
Mount opts each connection in via ctx.Subscribe(ctx.SelfTopic()); Increment updates the shared counter, then explicitly calls ctx.Publish(ctx.SelfTopic(), "RefreshCounter", nil) so peer connections that subscribed reload from the controller's mutex-protected state.
Counter: 0
Try: Open this page in a second tab. Click Increment in either tab — both stay in sync because the action explicitly publishes a peer refresh via ctx.Publish(ctx.SelfTopic(), "RefreshCounter", nil).
Template
One button and one rendered counter — all the synchronization happens server-side.
{{define "content"}}
<article>
<h3>Multi-User Refresh</h3>
<p><small>Mount opts each connection in via <code>ctx.Subscribe(ctx.SelfTopic())</code>; <code>Increment</code> updates the shared counter, then explicitly calls <code>ctx.Publish(ctx.SelfTopic(), "RefreshCounter", nil)</code> so peer connections that subscribed reload from the controller's mutex-protected state.</small></p>
<p>Counter: <strong>{{.Counter}}</strong></p>
<form method="POST">
<button name="increment">Increment</button>
</form>
<p><small><strong>Try:</strong> Open this page in a second tab. Click Increment in either tab — both stay in sync because the action explicitly publishes a peer refresh via <code>ctx.Publish(ctx.SelfTopic(), "RefreshCounter", nil)</code>.</small></p>
</article>
{{end}}
Mount subscribes and seeds the initial count; Increment mutates and publishes;
RefreshCounter is the action peers run to converge.
type MultiUserSyncController struct {
mu sync.RWMutex
counter int
}
// Mount runs on every initial render. Subscribing the self-topic wires this
// connection to receive RefreshCounter from peer Publishes. Without the
// initial-render counter read, a tab that opens AFTER other tabs have
// incremented would render Counter:0 and only converge on the next peer
// publish. Same fix as PresenceController.
func (c *MultiUserSyncController) Mount(state MultiUserSyncState, ctx *livetemplate.Context) (MultiUserSyncState, error) {
if err := ctx.Subscribe(ctx.SelfTopic()); err != nil {
return state, err
}
c.mu.RLock()
state.Counter = c.counter
c.mu.RUnlock()
return state, nil
}
// RefreshCounter is the action peer connections run when Increment publishes
// to SelfTopic(). The state arg is the peer's local state; we replace its
// Counter from the shared controller value so all tabs converge.
func (c *MultiUserSyncController) RefreshCounter(state MultiUserSyncState, ctx *livetemplate.Context) (MultiUserSyncState, error) {
c.mu.RLock()
state.Counter = c.counter
c.mu.RUnlock()
return state, nil
}
func (c *MultiUserSyncController) Increment(state MultiUserSyncState, ctx *livetemplate.Context) (MultiUserSyncState, error) {
c.mu.Lock()
c.counter++
state.Counter = c.counter
c.mu.Unlock()
if err := ctx.Publish(ctx.SelfTopic(), "RefreshCounter", nil); err != nil {
return state, err
}
return state, nil
}
func multiUserSyncHandler() http.Handler {
tmpl := newLayoutTmpl("templates/layout.tmpl", "templates/realtime/multi-user-sync.tmpl")
return tmpl.Handle(&MultiUserSyncController{}, livetemplate.AsState(&MultiUserSyncState{
Title: "Multi-User Sync",
Category: "Real-Time & Multi-User",
}))
}