Events
In Doors, DOM events are handled through special attributes that connect a browser event to a Go handler.
Around that handler, you also manage client-side scheduling, pending indication, and follow-up actions.
Start
Most event attrs look like doors.AClick, doors.AInput, or doors.ASubmit[T].
The smallest useful example is:
<button
(doors.AClick{
On: func(ctx context.Context, r doors.RequestEvent[doors.PointerEvent]) bool {
return false
},
})>
Click
</button>
The core shape is: an event attr has an On handler, and that handler returns bool.
Return:
falseto keep the handler activetrueto mark it done and remove it
For normal DOM events, false is the common default.
Attach
Event attributes are regular Go values with names like doors.AClick, doors.AInput, or doors.ASubmit[T].
You can attach them in two ways.
As an attribute modifier:
<button
(doors.AClick{
On: func(ctx context.Context, r doors.RequestEvent[doors.PointerEvent]) bool {
return false
},
})>
Click
</button>
Or as a proxy:
<>
~>doors.AClick{
On: func(ctx context.Context, r doors.RequestEvent[doors.PointerEvent]) bool {
return false
},
} <button>Click</button>
</>
The modifier form attaches directly to the element you are editing.
The proxy form walks through the following subtree until it reaches the real rendered element and attaches there. That is useful when the final element is inside another component.
In general, there is no strict rule that says you should use a proxy only when you need to drill into a component to find the target. Use proxy syntax everywhere if you prefer it.
Handler
For normal DOM events, the handler receives ctx and doors.RequestEvent[T].
That gives you:
r.Event()for the typed event payloadr.Context()for the underlying HTTP request contextr.SetCookie(...)andr.GetCookie(...)r.After(...)to schedule client-side actions after the request succeeds and the DOM changes it triggered are applied
The ctx parameter is the Doors runtime context. Pass that ctx to Doors APIs. r.Context() is separate: it belongs to the HTTP request.
Example:
On: func(ctx context.Context, r doors.RequestEvent[doors.PointerEvent]) bool {
r.After(doors.ActionScroll{Selector: "#top"})
return false
}
Form handlers use:
doors.RequestForm[T]for decoded form datadoors.RequestRawFormfor raw multipart access, includingSetRequestBodyLimit(limit int)for per-request limits
Event and form handler request bodies are bounded by ServerRequestBodyLimit (default 8 MB). For ASubmit, this value is also used as the max memory limit for form parsing. Use SetRequestBodyLimit on a raw form request to override the limit per request, or set a negative value to disable it entirely.
Options
Most event attrs share the same request-lifecycle fields:
On: backend handlerScope: request scheduling rules, covered in ScopesIndicator: temporary client-side feedback, covered in IndicationBefore: client-side actions before the requestOnError: client-side actions if the request failsRace: how overlapping calls to the handler are handled on the backend, covered in RaceBatch: apply the handler's page updates together, covered in Batch
After is different: it is not an attribute field. You schedule it from inside the handler with r.After(...).
Some event families also add browser-event options such as:
PreventDefaultStopPropagationExactTargetKeyExcludeValue
Not every event family supports every one of these options.
Form and custom-hook attrs — ASubmit[T], ARawSubmit, AHook[...], and ARawHook — also accept RequestTimeout, which overrides the configured RequestTimeout for that attr's requests, for example when uploads need longer than the default. Regular event attrs such as AClick do not have it.
Flow
When an event fires, the client/runtime flow is roughly:
- capture the browser event and build the payload
- apply client-side event options such as
PreventDefault,StopPropagation,ExactTarget, or keyboardKeymatching - run client-side scopes
- start indication
- run any
Beforeactions - send the request to the server
- run the Go handler
- run
Afteractions if the request succeeds - run
OnErroractions if the request fails
That is why scopes and indication feel immediate: they start on the client before the server finishes the request.
A handler that hands work to a goroutine can keep the request pending past its return with doors.HoldSettle(ctx): the client keeps the indicator and the scope until the returned release function is called. See Door.
The indicator, the scope, After actions, and the $hook promise do not wait for content passed to deferred Door operations (DeferredInner, DeferredOuter, DeferredStatic).
Pointer
Pointer attributes include:
doors.AClickdoors.APointerDowndoors.APointerUpdoors.APointerMovedoors.APointerOverdoors.APointerOutdoors.APointerEnterdoors.APointerLeavedoors.APointerCanceldoors.AGotPointerCapturedoors.ALostPointerCapture
Example:
<button
(doors.AClick{
PreventDefault: true,
On: func(ctx context.Context, r doors.RequestEvent[doors.PointerEvent]) bool {
x := r.Event().PageX()
y := r.Event().PageY()
_ = x
_ = y
return false
},
})>
Track click
</button>
The pointer payload includes the usual browser pointer fields — button state, pointer type, pressure, and timestamp — plus geometry rectangles. Coordinate accessors such as PageX(), ClientX(), OffsetX(), and ScreenX(), with their Y counterparts, derive the usual coordinates from them.
Keyboard
Keyboard attributes are:
doors.AKeyDowndoors.AKeyUp
Use Key to fire only for specific keys and modifier combinations.
Key takes a single doors.Keys value, and a doors.Key is one on its own. Combine several with .And(...) or doors.JoinKeys(...); the hook fires when the event matches any of them:
<input
(doors.AKeyDown{
Key: doors.Key{Key: "Enter"}.
And(doors.Key{Key: "s", CtrlMod: doors.ModOn}),
On: func(ctx context.Context, r doors.RequestEvent[doors.KeyboardEvent]) bool {
return false
},
})/>
Each doors.Key matches event.key (an empty string matches any key) plus a required state for each modifier. CtrlMod, ShiftMod, AltMod, and MetaMod are doors.Mod values:
doors.ModAny(default) — modifier ignoreddoors.ModOn— modifier must be helddoors.ModOff— modifier must not be held
So doors.Key{Key: "Enter"} matches Enter with any modifiers, and doors.Key{Key: "s", CtrlMod: doors.ModOn} matches Ctrl+S regardless of Shift, Alt, or Meta. To require an exact combination, set the other modifiers to doors.ModOff.
The keyboard payload includes Key, Code, Repeat, and modifier state such as CtrlKey, ShiftKey, AltKey, and MetaKey.
Focus
Focus attributes are:
doors.AFocusdoors.ABlurdoors.AFocusIndoors.AFocusOut
Use AFocusIn and AFocusOut when bubbling behavior matters.
Use AFocus and ABlur for the plain focus events.
Input
Input-related attributes are:
doors.AInputdoors.AChange
AInput fires as the user edits.
AChange fires when the value is committed.
<input
type="text"
(doors.AInput{
On: func(ctx context.Context, r doors.RequestEvent[doors.InputEvent]) bool {
value := r.Event().Value
_ = value
return false
},
})/>
The input and change payloads include browser-style fields such as:
NameValueNumberDateSelectedChecked
AInput{ExcludeValue: true} omits the normal input-derived fields from the payload. Use it when you want the event itself without sending the current input value data.
Forms
Form submission attributes are:
doors.ASubmit[T]doors.ARawSubmit
ASubmit[T] parses the multipart form and decodes it into your Go type.
type LoginForm struct {
Email string `form:"email"`
Code string `form:"code"`
}
<form
(doors.ASubmit[LoginForm]{
On: func(ctx context.Context, r doors.RequestForm[LoginForm]) bool {
data := r.Data()
_ = data
return false
},
})>
<input name="email"/>
<input name="code"/>
<button>Send</button>
</form>
Use ARawSubmit when you want direct multipart access for streaming, custom parsing, or uploads.
For form decoding, Doors uses go-playground/form v4.
Reuse
Use doors.A(ctx, ...) when you want to prepare one activated attribute value and reuse it.
<>
~~
radio := doors.A(ctx, doors.AChange{
On: func(ctx context.Context, r doors.RequestEvent[doors.ChangeEvent]) bool {
return false
},
})
~~
<input type="radio" name="pick" value="a" (radio)/>
<input type="radio" name="pick" value="b" (radio)/>
</>
For a one-off attribute on one element, you usually do not need doors.A(...).
Each activated event attr has its own backend hook instance.
By default, calls to that same instance run one at a time, so rapid repeated events on one active handler do not run concurrently on the backend. Race changes that.
If you reuse one activated attr across several elements, those elements also share the same hook instance and the same execution queue.
Race
Requests to one handler can overlap, and they can overtake each other on the way to the server. Race sets how the backend handles that:
doors.RaceSerial(default): calls run one at a time, in order of arrivaldoors.RaceParallel: calls run concurrentlydoors.RaceStrict: calls run one at a time, and out-of-order calls are canceled
With RaceParallel the handler must synchronize any shared state itself. Returning true stops new calls, and the handler is removed once the calls already running finish.
With RaceStrict a call that arrives after a newer one is canceled instead of run, so an older call never overwrites the result of a newer one. Nothing is reordered, and calls that arrive in order all run. Use it when only the newest input matters: live search, autosave, selection.
<input
(doors.AInput{
Race: doors.RaceStrict,
On: func(ctx context.Context, r doors.RequestEvent[doors.InputEvent]) bool {
return false
},
})/>
A canceled call is silent on event attrs: OnError does not run.
Race acts on the backend, after a request arrives. Scopes act on the client, before it is sent. They combine: ScopeLatest keeps the newest interaction in control of the UI, RaceStrict keeps an older call from running after a newer one.
Batch
Every Door renders on its own, and its update goes to the browser as soon as it is ready. That keeps pages responsive, but a handler that changes several places, or replaces content that takes a moment to produce, changes the page piece by piece. Batch: true groups only the paint: the handler runs and renders as before, and the browser applies its updates together when it finishes.
Use it for an action that changes several stacked or nested components, and for a link to a view that takes a moment to load, see Navigation.
<>
~>doors.AClick{
Batch: true,
On: func(ctx context.Context, r doors.RequestEvent[doors.PointerEvent]) bool {
p.list.Inner(ctx, OrderList{status: status})
p.count.Inner(ctx, OrderCount{status: status})
return false
},
} <button>Show ~(status)</button>
</>
The batch covers everything the handler starts with its context: Door updates, the state propagation they trigger, and path changes. It closes when the handler has finished and everything it started is queued; doors.HoldSettle keeps it open until release, see Door. Deferred operations (DeferredInner, DeferredOuter, DeferredStatic) are not part of the batch: their content lands on its own, after the batch when it targets a Door the batch updates.
The view kept on screen during a batch is display only: interactions with parts that are being replaced are ignored until the new view lands, so put an indicator on the trigger.
Completion channels in a handler report only that an operation was scheduled, batch or not. For a result from the browser, wait in a goroutine of your own with doors.DetachedContext(ctx), whose operations are delivered outside the batch, see Detached Context. To react once the batch is applied, use doors.OnBatch, see Lifecycle Hooks.
Title and meta updates are not batched.
Batches are bounded: when the calls held in the browser reach SolitairePending, the oldest batch is applied early, see Configuration.
Event Presets
Event attrs carry many fields. When the same Scope, Indicator, and OnError appear on every handler, a preset keeps that boilerplate in one place.
A preset is a small struct implementing doors.Attr. It holds only the fields that vary per use and constructs the full event attr in Proxy and Modify:
var _ doors.Attr = SaveClick{}
type SaveClick struct {
On func(context.Context, doors.RequestEvent[doors.PointerEvent]) bool
}
func (p SaveClick) Proxy(cur gox.Cursor, elem gox.Elem) error {
return p.click().Proxy(cur, elem)
}
func (p SaveClick) Modify(ctx context.Context, tag string, attrs gox.Attrs) error {
return p.click().Modify(ctx, tag, attrs)
}
func (p SaveClick) click() doors.AClick {
return doors.AClick{
Scope: &doors.ScopeBlocking{},
Indicator: doors.IndicateAttr("aria-busy", "true"),
OnError: doors.ActionIndicate{
Indicator: doors.IndicateClass("error"),
Duration: 300 * time.Millisecond,
},
On: p.On,
}
}
Use it like any other event attr:
<button (SaveClick{
On: func(ctx context.Context, r doors.RequestEvent[doors.PointerEvent]) bool {
// save logic
return false
},
})>Save</button>
The same pattern works for any event attr family — forms, keyboard, input and links.
Unsupported
If the browser event you need is not supported by the built-in doors.A... event attributes, wire it yourself in JavaScript and call a custom hook.
That is the normal extension path for:
- browser events Doors does not expose directly
- custom DOM integrations
- third-party widgets that already have their own client-side event system
The usual pattern is:
- listen to the event in JavaScript
- call
$hook(...)or$fetch(...) - handle it on the Go side with
doors.AHook[...]ordoors.ARawHook
Example:
<script
(doors.AHook[string]{
Name: "visibility",
On: func(ctx context.Context, r doors.RequestHook[string]) (any, bool) {
println(r.Data())
return nil, false
},
})>
document.addEventListener("visibilitychange", async () => {
await $hook("visibility", document.visibilityState)
})
</script>
See JavaScript.
Rules
- Use the
ctxthat Doors gives you in the handler. - Use
falseas the normal return value unless the handler really should be one-shot. - Use
AInputfor live edits andAChangefor committed values. - Use
ASubmit[T]when typed decoding is enough; useARawSubmitfor upload-heavy or custom multipart flows. - Use scopes for interaction policy instead of rebuilding debounce/blocking logic by hand.
- Use indication when the user needs immediate feedback before the server responds.