Contents
This page documents the core package (github.com/zatrano/goui/core) in
full: the Component contract, every BaseComponent field and method, the
Registry, and the HTML template cache.
import "github.com/zatrano/goui/core"
1. The Component interface
type Component interface {
Mount(ctx context.Context) error
Render() (string, error)
HandleEvent(ctx context.Context, event string, payload map[string]any) error
Unmount(ctx context.Context) error
}
Every GoUI view — from a two-line counter to a full form — implements these
four methods. core.BaseComponent (below) provides shared state and helpers
but deliberately does not implement Component itself: you always write
your own Render, HandleEvent, and lifecycle methods (or delegate to a
Tier 1/Tier 2 field's own implementation).
Mount(ctx context.Context) error
- When it is called: exactly once, right after the component is
instantiated by the
Registry(or constructed manually) and before the first render.ws.Session.MountComponent,Session.Prefetch, andSession.Activate(fresh path) all callMountas part of preparing a component for a session. - Do: initialize any state that depends on runtime context — e.g.
TreeSelect.Mountlazily allocates itsExpandedmap,Cascader.MountseedsLevels[0]fromItems,MarkdownEditor.Mountrenders the initial preview HTML. - Don't: perform the first render here — rendering is a separate step
driven by the session (
sendFullRender), and most Tier 1 fields simplyreturn nil. - Returning a non-nil error aborts session setup (the WebSocket handler
writes an
errorframe and does not proceed).
Render() (string, error)
- When it is called: after
Mountfor the very first render, and again after everyHandleEventcall that does not returncore.ErrSkipRender. The session diffs the previous rendered tree against the new one and sends only the patches that changed (see04-sessions-and-websocket.md). - Do: return a single well-formed HTML fragment (one root element is
strongly recommended — the session wraps multi-root output in an extra
<div>sodata-goui-componenthas somewhere to live). CallResetDirty()once you have produced fresh output, if you track dirtiness. Usecore.RenderTemplate(or hand-built strings viaforms.Attrs) and route all translatable text throughT. - Don't: mutate component state inside
Render— state changes belong inHandleEvent/Mount;Rendershould be a pure projection of the current fields. Rendermust be safe to call more than once with unchanged state — the session calls it once per event and once again on reconnect (SendInitialRenders).
HandleEvent(ctx context.Context, event string, payload map[string]any) error
- When it is called: once per inbound
eventframe from the browser (g-click,g-change,g-inputafter debounce,g-submit).eventis the string from thez-*attribute;payloadis whatevercollectPayload/collectFormPayloadgathered client-side (typically{"value": "..."},{"checked": true, "value": "..."}, or{"fields": {...}}for form submits). - Do: mutate your component's fields based on
event/payload, callMarkDirty()if you track dirtiness manually, and returncore.ErrSkipRenderfor events whose UI is client-owned (rich text, code editors — see07-forms-tier2.md). - Don't: call
Render()yourself — the session does that immediately afterHandleEventreturns (unless you returnedErrSkipRender). - Returning any other non-nil error causes the session to send an
errorframe back to the client instead of arenderframe.
Unmount(ctx context.Context) error
- When it is called: when the session closes (
Session.Close, e.g. tab closed and the grace period elapsed, or the server shuts down the hub) — for every component still tracked, both active and prefetched-but-not- activated. It is also called immediately on a prefetched component that loses a race to a duplicate prefetch eviction. - Do: release resources you opened in
Mount(file handles, timers, subscriptions). Most Tier 1/Tier 2 fields have nothing to release andreturn nil. - Don't: assume
Unmountruns on every page navigation — it only runs when the session is torn down, not on every re-render.
2. BaseComponent
type BaseComponent struct {
ID string
Children map[string]Component
// dirty bool // private
Locale string
// translator, pusher are private
}
Embed it by value as the first field of your component struct:
type MyComponent struct {
core.BaseComponent
// ... your fields
}
Fields
| Field | Type | Purpose |
|---|---|---|
ID |
string |
The session-assigned component instance ID. Set automatically by ws.Session via reflection when a component is mounted/activated (looks for a field named BaseComponent and sets its ID/Locale sub-fields) — you normally never set this yourself. |
Children |
map[string]Component |
Optional slot for composing sub-components by name. The core package does not populate or consume this automatically; it's provided as a convention for components that manage nested components manually. |
Locale |
string |
The active locale for this component instance (e.g. "tr", "en"), set from the WebSocket ?locale= query parameter when the session mounts/activates the component. Read by T/ToastT. |
Methods
| Method | Signature | Behavior |
|---|---|---|
MarkDirty |
func (b *BaseComponent) MarkDirty() |
Sets the internal dirty flag to true. Call this from HandleEvent whenever a change should trigger a re-render. |
IsDirty |
func (b *BaseComponent) IsDirty() bool |
Reports the current dirty flag. Useful if your own Render/dispatch logic wants to skip work when nothing changed — the ws.Session itself always calls Render after a successful HandleEvent regardless of this flag, so IsDirty/ResetDirty are a convention for your own code, not enforced by the session. |
ResetDirty |
func (b *BaseComponent) ResetDirty() |
Clears the dirty flag. Call at the end of Render once you've produced up-to-date HTML (see the Counter example in 01-getting-started.md). |
SetTranslator |
func (b *BaseComponent) SetTranslator(t *i18n.Translator) |
Injects the shared *i18n.Translator. Called automatically by ws.Session (via an interface{ SetTranslator(*i18n.Translator) } type assertion) when a component is mounted or activated. You also call it manually for sub-fields you construct yourself (see the ContactForm example in 01-getting-started.md and 06-validation.md) since the session only reflects into the top-level component's own BaseComponent. |
SetPusher |
func (b *BaseComponent) SetPusher(fn func(kind, text string)) |
Injects a callback used by Toast/ToastT. ws.Session.injectPusher wires this automatically to Session.EnqueuePush, so pushes end up as push frames on the wire. |
Toast |
func (b *BaseComponent) Toast(kind, text string) |
Sends a push notification (kind + already-translated text) through the injected pusher. A no-op (does not panic) if no pusher was injected — safe to call in tests or standalone components. |
ToastT |
func (b *BaseComponent) ToastT(kind, key string, args ...any) |
Translates key via T first, then calls Toast with the result. Use this for user-facing notifications so they respect Locale. |
T |
func (b *BaseComponent) T(key string, args ...any) string |
Translates key for b.Locale using the injected translator. If Locale is empty, falls back to i18n.BaseLocale ("tr"). If no translator was injected, returns the raw key wrapped as "[[" + key + "]]" — this makes missing wiring obvious in the rendered HTML rather than silently blank. See 03-i18n.md for the full lookup/fallback algorithm. |
T and ToastT in practice
func (c *MyComponent) HandleEvent(_ context.Context, event string, _ map[string]any) error {
if event == "save" {
c.ToastT("success", "contact.submit_success") // translated + pushed
}
return nil
}
func (c *MyComponent) Render() (string, error) {
label := c.T("form.submit") // "Gönder" (tr) or "Submit" (en)
return "<button>" + html.EscapeString(label) + "</button>", nil
}
3. Registry
type Registry struct { /* ... */ }
func NewRegistry() *Registry
func (r *Registry) Register(name string, factory func() Component) error
func (r *Registry) Create(name string) (Component, error)
The registry maps a string name (the value passed in
?component=name on the WebSocket URL, or to Session.Prefetch/Activate)
to a factory function that returns a fresh Component instance. It is
safe for concurrent use (guarded by an internal sync.RWMutex).
Register
registry := core.NewRegistry()
err := registry.Register("counter", func() core.Component { return &Counter{} })
- Registers
name→factory. - Error: returns
core.ErrComponentAlreadyRegisteredifnameis already registered. Registration is typically done once at startup, so most code simply doesif err := registry.Register(...); err != nil { log.Fatal(err) }.
Create
c, err := registry.Create("counter") // c is a fresh *Counter
- Looks up
nameand calls its factory, returning a brand-newComponentinstance every time (the factory closure is responsible for allocating a new struct — registries never share instances between sessions). - Error: returns
core.ErrComponentNotRegisteredifnamewas never registered. This is the error you see surfacing as anerrorframe when a client connects with?component=typo, or callsPrefetch/Activatewith an unknown name.
Both sentinel errors are defined in core/errors.go:
var (
ErrComponentNotRegistered = errors.New("component not registered")
ErrComponentAlreadyRegistered = errors.New("component already registered")
)
4. Template cache: core.RenderTemplate
func RenderTemplate(tmplStr string, data any) (string, error)
RenderTemplate wraps Go's html/template, but caches the parsed
template keyed by an FNV-64a hash of the template string itself (not the
component or file). The first call with a given template string pays the
parse cost; every subsequent call with the identical string — from any
component, any session — reuses the cached *template.Template and only
pays for Execute. This is why it's idiomatic to write templates as string
literals directly inside Render(): the string is identical across calls,
so the cache hits every time after the first.
func (c *Counter) Render() (string, error) {
html, err := core.RenderTemplate(`<span>{{.Count}}</span>`, c)
if err != nil {
return "", err
}
c.ResetDirty()
return html, nil
}
The {{call .T "key"}} rule
Because RenderTemplate uses html/template, you cannot call a method
directly on the piped value inside conditionals or when passing the receiver
explicitly — instead you pass the T function value in the data map (or
rely on it being a field/method reachable via .) and invoke it with
{{call .T "key"}}. Two supported forms:
// 1. Pass the whole component (T is a method value on BaseComponent, so
// {{.T "key"}} works too, but {{call .T "key" .}} is the safe general form
// when you need to pass placeholder data alongside the key):
core.RenderTemplate(`<p>{{call .T "welcome_message" .}}</p>`, c)
// requires the template's top-level data to have both a .T method and the
// fields referenced by the translation string (e.g. .Name for "{{.Name}}").
// 2. Pass a map with T explicitly, when the component itself isn't the
// convenient top-level data value:
data := map[string]any{"Name": "GoUI", "T": bc.T}
core.RenderTemplate(`<p>{{call .T "welcome_message" .}}</p>`, data)
// Without placeholders you can omit the trailing "." argument:
core.RenderTemplate(`<button>{{call .T "form.submit"}}</button>`, map[string]any{"T": bc.T})
Rules of thumb:
.Tmust resolve to a function of shapefunc(key string, args ...any) string—BaseComponent.Tmatches this exactly, so embeddingcore.BaseComponentand passingc(ormap[string]any{"T": c.T, ...}) as the template data is enough.- Use
{{call .T "key" .}}(passing the whole data value as the singleargselement) when the translation string contains Go template placeholders like{{.Name}}— the translator re-parses the translated string as a template and executes it against that one argument. - Use
{{call .T "key"}}(no trailing argument) for plain strings with no placeholders. html/templateauto-escapes the translated string as HTML, so you do not need to callhtml.EscapeStringyourself when going throughRenderTemplate— but you do need to escape manually when hand-building HTML with string concatenation (as most Tier 1/Tier 2 fields in theformspackage do, via the standard library'shtml.EscapeString).
See 03-i18n.md for how Translate/T resolve keys, and
component_i18n_test.go for runnable
examples of both forms above.