Contents
GoUI ships a small push-notification ("toast") system built directly on top of the WebSocket transport described in earlier chapters. There is no separate HTTP endpoint or polling involved — a toast is just another frame type on the same connection a component already uses for rendering.
Module path used throughout this document: github.com/zatrano/goui.
1. The moving parts
| Piece | Location | Role |
|---|---|---|
core.BaseComponent.Toast / ToastT |
core/component.go |
Component-side API to fire a toast for the current session. |
ws.PushMessage |
ws/frame.go |
Wire payload: {Kind, Text}. |
ws.FrameTypePush |
ws/frame.go |
Frame type ("push") carrying a PushMessage. |
Session.EnqueuePush |
ws/session.go |
Puts a push frame on the session's outbound channel. |
Hub.Push |
ws/hub.go |
Sends a toast to one session, by session ID. |
Hub.Broadcast |
ws/hub.go |
Sends a toast to every registered session. |
client/modules/toast.js |
client runtime | Renders the toast DOM and handles auto-dismiss. |
1.1 core.BaseComponent
Every component embeds core.BaseComponent, which holds an internal pusher
callback that the session wires up automatically when a component is
mounted or activated (see Session.injectPusher in ws/session.go). You never
set this yourself.
// core/component.go (excerpt)
// Toast sends a push notification to the current session (no-op if no pusher).
func (b *BaseComponent) Toast(kind, text string) {
if b.pusher != nil {
b.pusher(kind, text)
}
}
// ToastT translates key then sends a toast for the current session.
func (b *BaseComponent) ToastT(kind, key string, args ...any) {
b.Toast(kind, b.T(key, args...))
}
Because Toast is a no-op when pusher is nil, it is always safe to call —
including from unit tests that construct a component directly without a
Session, or from a component's Mount/HandleEvent before it has been
attached to a live connection.
Toast(kind, text string)— send a literal string.ToastT(kind, key string, args ...any)— resolvekeythrough the component's injectedi18n.Translator(same mechanism asT()), then send the translated string. PreferToastTin application code so toast copy lives in your locale files next to every other user-facing string.
1.2 Kinds
The Kind field is a free-form string, but the client and the default
stylesheet only give special treatment to four values:
success | error | warning | info
Anything else (including an empty string) is normalized to info by the
client (normalizeKind in client/modules/toast.js). The server does not
validate Kind — pick one of the four kinds above unless you also ship
matching CSS.
1.3 Timings
Toasts auto-dismiss on the client. The default lifetime is longer for errors so users have more time to read them:
// client/modules/toast.js
const DEFAULT_MS = 5000; // success / warning / info
const ERROR_MS = 8000; // error
A close button (×) is always rendered so the user can dismiss a toast early
regardless of kind.
2. Sending a toast from a component
The common case: a component finishes handling an event and wants to confirm success (or report a failure) to the user who triggered it.
func (c *ContactForm) HandleEvent(ctx context.Context, event string, payload map[string]any) error {
switch event {
// ...
case "save":
if !forms.ValidateAll(&c.Name, &c.Email, &c.Country, &c.Message, &c.Subscribe) {
c.MarkDirty()
return nil
}
c.Submitted = true
c.ToastT("success", "contact.submit_success")
c.MarkDirty()
}
return nil
}
This is the exact pattern used by examples/contact-form/main.go. ToastT
looks up "contact.submit_success" in the component's locale (c.Locale,
set by the session from the WS ?locale= query parameter) and pushes it as a
success toast to the same connection that sent the save event. The render
that follows (session.sendRender) is a completely separate frame — a toast
never replaces or blocks the normal diff/patch render cycle.
You can call Toast/ToastT from Mount, HandleEvent, or any helper
method the component calls into; it always targets the session the
component belongs to, whichever session that happens to be at the time.
3. Pushing from outside a component: Hub
Sometimes the notification has nothing to do with the component that is
currently rendering — a background job finished, another user triggered a
side effect, an admin wants to broadcast a maintenance notice. For that you
go through the *ws.Hub you already created when wiring up your adapter
(Options.Server).
3.1 Targeted: Hub.Push
// Send a toast to one specific session by ID.
if err := wsHub.Push(sessionID, ws.PushMessage{
Kind: "info",
Text: "Your export is ready.",
}); err != nil {
// ws.ErrSessionNotFound if the session is gone (disconnected past grace period).
log.Printf("push failed: %v", err)
}
Hub.Push looks the session up by sessionID (the same ID the client
persists in sessionStorage and sends back on reconnect) and calls
Session.EnqueuePush on it. If the session isn't registered anymore, it
returns ws.ErrSessionNotFound.
3.2 Broadcast: Hub.Broadcast
// Send a toast to every currently registered session.
wsHub.Broadcast(ws.PushMessage{
Kind: "warning",
Text: "Scheduled maintenance in 10 minutes.",
})
Broadcast snapshots the current session list under a read lock and enqueues
the push frame on each one. It never fails — sessions that are momentarily
disconnected (within their grace period) simply buffer the frame in their
outbound channel and receive it once a connection reattaches; sessions with a
full outbound buffer silently drop the frame (see the enqueue behavior below).
3.3 A real example: admin broadcast route
examples/contact-form/main.go exposes a plain HTTP endpoint that broadcasts
to every connected client — useful for a quick "announce to everyone online"
admin action without building any additional UI:
wsHub := ws.NewHub()
server := ws.NewServer(wsHub, registry, tr)
gouifiber.Register(app, gouifiber.Options{Server: server})
app.Get("/admin/broadcast", func(c fiber.Ctx) error {
text := c.Query("text")
if text == "" {
text = "Sunucudan duyuru"
}
kind := c.Query("kind")
if kind == "" {
kind = "info"
}
wsHub.Broadcast(ws.PushMessage{Kind: kind, Text: text})
return c.JSON(fiber.Map{"ok": true, "kind": kind, "text": text})
})
Try it against the running example:
GET http://localhost:3001/admin/broadcast?text=Hello+everyone&kind=success
Every browser tab currently connected to the GoUI WebSocket endpoint pops a
toast immediately, with no page reload and no relation to any particular
component's render cycle. This is the pattern to copy for real admin tooling:
gate the route behind auth, then call hub.Broadcast (or hub.Push for a
single user) from wherever your business logic decides a notification is
warranted — a webhook handler, a cron job, another goroutine, etc.
4. Wiring the client
The client runtime (client/goui.js) already routes incoming "push" frames
to an onPush callback you provide when constructing GoUIClient. Wire that
callback to the toast module's showToast function:
import { GoUIClient } from '/client/goui.js';
import { enhanceToast, showToast } from '/client/modules/toast.js';
enhanceToast(); // creates the .goui-toast-host container in <body>
const client = new GoUIClient('/goui/ws', 'contact', {
locale: 'en',
onPush: showToast,
onError: (msg) => console.error('[goui]', msg),
});
client.connect();
enhanceToast(root) lazily creates a single <div class="goui-toast-host">
(with aria-live="polite") the first time it's needed, so it's safe to call
once at startup even before any toast has fired. showToast(payload):
- Normalizes
payload.kindto one ofsuccess | error | warning | info(defaultinfo). - Skips rendering entirely if
payload.textis empty. - Creates a
<div class="goui-toast goui-toast-<kind>">with a message span and a close button, and prepends it to the host (newest on top). - Sets
role="alert"for error toasts androle="status"otherwise, so screen readers announce it appropriately. - Schedules removal after
ERROR_MS(8000ms) forerror,DEFAULT_MS(5000ms) for everything else — cancelable by clicking the close button.
5. Styling
Toast colors come from the same design tokens the rest of GoUI forms use
(see 12-theming-and-tailwind.md). The relevant
rules live in forms/style.css under the Toast / push notifications
section:
.goui-toast-host { position: fixed; top: 1rem; right: 1rem; z-index: 1000; /* ... */ }
.goui-toast { border: 1px solid var(--color-goui-border); background: var(--color-goui-surface); /* ... */ }
.goui-toast-success { border-color: ...var(--color-goui-success)...; background: ...var(--color-goui-success)...; }
.goui-toast-error { border-color: ...var(--color-goui-error)...; background: ...var(--color-goui-error)...; }
.goui-toast-warning { border-color: ...var(--color-goui-warning)...; background: ...var(--color-goui-warning)...; }
.goui-toast-info { border-color: ...var(--color-goui-info)...; background: ...var(--color-goui-info)...; }
Override the --color-goui-* custom properties in your own stylesheet (loaded
after forms/style.css) to rebrand toasts without touching any Go or JS code.
6. Delivery semantics you should know
- Fire-and-forget on the server.
EnqueuePushdoes a non-blocking send on the session's buffered outbound channel (capacity 32). If the channel is full — an unusually backed-up client — the frame is silently dropped rather than blocking the caller. Toasts are for transient UX feedback, not a guaranteed delivery/audit log. - Survives brief disconnects. If the target session is disconnected but still within its grace period (see 09-prefetch.md and 13-project-integration.md for grace period details), the push frame queues up and is delivered as soon as the WebSocket reattaches — the reader isn't "listening" while offline, but the channel buffer holds the frame.
Hub.Pushtargets a session, not a component. A session can host several mounted/prefetched components at once; a toast is not scoped to any one of them. It always surfaces at the top-level.goui-toast-hostfor that browser tab.- No persistence. Toasts are ephemeral by design. If you need users to see a notification they missed while offline, model that as application data (e.g. a notifications list rendered by a component on next Mount), not as a toast.