Creating VDOM Event Handlers
Overview¶
An event handler in ReactPy is a coroutine you put in an element's attribute dict. ReactPy inspects the value, not the name, decides what it is, and files it under one of three keys in the model — refusing it outright if it cannot be awaited. Most of the surprises on this page come from that one decision point: what counts as a handler, what counts as JavaScript, and what happens to the options you thought you set.
You will learn
- The three buckets an attribute value lands in, and the rule that sorts them
- Why a handler must be
async def, and what that rules out - Why
preventDefault()sometimes reaches the browser and sometimes does not - The difference in arguments between a plain callable and an
EventHandler - How to set
debounceandthrottle, and what values are rejected
The three buckets¶
When you call html.button({...}, ...), ReactPy walks the attribute dict once and routes every value by kind — callable first, then string-under-onXxx, then everything else (core/vdom.py, separate_attributes_handlers_and_inline_javascript):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | |
1 | |
The rules, exactly:
| Value | Lands in | Note |
|---|---|---|
Anything callable | eventHandlers | under any key name; anything callable which is not a coroutine raises |
A BaseEventHandler | eventHandlers | stored as-is, untouched |
A str under a key matching ^on[A-Z]\w+ | inlineJavaScript | eval'd in the page |
A reactpy.types.InlineJavaScript string | inlineJavaScript | under any key |
| Everything else | attributes | forwarded verbatim |
Three of those rows have consequences worth spelling out.
callable wins over the key name. {"notAnHandler": my_async_function} registers a real listener whose target ends in :notAnHandler. It will never fire, because no DOM event is named that, but it is registered. Conversely {"onClick": True} is not a handler — a bool is not callable, so it becomes an attribute and no listener exists. Neither mistake produces an error. What does raise is a callable ReactPy cannot await: the sort step builds an EventHandler for every callable value, and that is where the coroutine check lives.
Calling instead of passing gives you something that is not a handler. A handler is a coroutine, so {"onClick": handle_click()} hands over a coroutine object, which is not callable and so falls through to the attributes: the button renders, looks dead, and the failure surfaces later as TypeError: Type is not JSON serializable: coroutine when the model is encoded for the wire — plus a RuntimeWarning: coroutine ... was never awaited in the log. Calling something that was never a handler ({"onClick": print("hi")}) is the None case, and that one really is silent on both sides. Either way: pass the function, do not call it. The checklist in What to check when a control does nothing covers all three shapes.
A dict is never a handler. The React-style {"function": fn, "preventDefault": True} becomes an ordinary attribute; no listener is attached. With REACTPY_DEBUG on it fails loudly instead — TypeError: Object of type function is not JSON serializable — which is the better outcome. Build handlers with @event or EventHandler, never with a dict.
Plain coroutines, the shape you want most of the time¶
Pass a coroutine and ReactPy wraps it. The browser sends a list of arguments; a plain coroutine receives it unpacked positionally (core/events.py, to_event_handler_function), which for a DOM event means one argument:
async def handle_click(event):
print(await event.target.value)
html.button({"onClick": handle_click}, "Click me")
The parameter name is yours to choose — the value is positional. What is not optional is async def: EventHandler.__init__ and to_event_handler_function both run the value through check_is_async_handler, and anything that is not awaitable raises there. A coroutine function, a callable object whose __call__ is a coroutine, and a functools.partial or functools.wraps wrapper around either all pass. A plain def and a lambda do not — the latter has no syntax for await, which is why the old inline-handler idiom is gone.
The value handed over is a reactpy.types.Event: not a dict, but a live proxy onto the browser object, where await event.target.value is the read and the property chain matches JavaScript's. See Responding to Events for the payload surface and Protocol Structure for what crosses the wire.
Declare exactly one parameter
The list is unpacked onto your signature, so a handler declaring two required parameters fails at the moment of the click with TypeError: ... missing 1 required positional argument, logged server-side only — the browser stays silent and the button just stops working. Extra context has to be bound in Python, and since a lambda is no longer available that means a named handler or a closure:
def delete_handler(item_id):
async def handle_click(event):
await delete(item_id)
return handle_click
html.button({"onClick": delete_handler(item.id)}, "Delete")
functools.partial does the same job, with one catch: the client still hands over the event, so the underlying function must leave exactly one parameter free — partial(delete, item.id) over async def delete(item_id, event), not over async def delete(item_id). Binding the leading argument also moves the event off the first parameter, which is what the bytecode detector matches, so see Be discovered: bytecode inspection below: a preventDefault() in that handler is not detected, and @event(prevent_default=True) is the fix.
EventHandler: the pre-built form, and its different argument¶
EventHandler is what the @event decorator returns, and you can construct it directly. Two things change when you do.
First, ReactPy stores an EventHandler as-is rather than wrapping it, so the same object can serve every element that uses it. It does not follow that they share a target. The target is derived per element: unless the handler carries an explicit target, core/layout.py builds target = f"{key_path}:{event}", and @event leaves target as None. Verified with one @event handler on two buttons: the targets came out /0/0:onClick and /0/1:onClick — one function object, two targets.
Do not try to force the sharing by setting target= yourself. Two elements registered under the same explicit target overwrite each other in the layout's handler table, and the unmount path then tries to del that one key twice — verified, the second deletion raises KeyError from Layout._unmount_model_states.
Second, and this is the trap: an EventHandler receives the list itself, not the unpacked arguments.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | |
1 | |
The failure when you get it backwards differs by direction, and the layout swallows either one into a server-side Failed to execute event handler line — nothing appears in the browser console, so the UI merely stops responding. Hand the list to a handler written for the event and the first property read raises AttributeError: 'EventArguments' object has no attribute 'target' — the list is an EventArguments, a list subclass, not the event. Hand the unpacked event to a handler written for the list and you get TypeError: 'Event' object is not subscriptable. Prefer the plain-coroutine form unless you specifically need to hand-build a handler; @event gives you the same options with the friendlier argument shape.
Setting the five options¶
Five behaviours run in the browser before an event is sent. They are properties on the handler object, never attributes on the element:
| Option | Type | Effect |
|---|---|---|
prevent_default | bool | calls preventDefault() in the page |
stop_propagation | bool | calls stopPropagation() in the page |
stop_immediate_propagation | bool | also calls stopImmediatePropagation(), blocking sibling listeners |
debounce | int ms | coalesces outgoing events on a trailing edge, so the server sees one event after the user pauses — applied only to <input>, <select>, and <textarea> |
throttle | int ms | rate-limits outgoing events on a leading edge, on any element |
There are two ways to set them, and the difference matters.
Say it: @event¶
from reactpy import event
@event(
prevent_default=True,
stop_propagation=True,
stop_immediate_propagation=True,
debounce=250,
throttle=100,
)
async def handle_key_down(event_data): ...
html.input({"onKeyDown": handle_key_down})
This is explicit and it always works. Reach for it by default.
Be discovered: bytecode inspection¶
ReactPy also reads your function's bytecode looking for three specific statements, so you can write them inline instead (core/_event_inspect.py, inspect_event_handler):
1 2 3 4 5 6 7 8 9 10 11 | |
1 | |
This is a source-parsing hack, not a runtime mechanism, and it has limits you should know before relying on it. Detection requires all of the following:
- The call is written literally in the handler's own body.
event.preventDefault()inside a helper the handler calls is invisible to the inspector — it does not follow calls. - The first parameter is the event. The inspector reads
co_varnames[0]and matches against it. - For
debounce, the assigned value must be resolvable at inspect time: an integer literal, a compile-time-folded expression like100 * 5, a numeric default on a parameter (async def h(event, ms=350)), or a numeric closure cell. It cannot resolve a parameter with no default, a module-level global, or an arbitrary expression — all three inspect toNone. And it stops scanning at the first assignment it can settle — resolved or not — so with two assignments toevent.debouncethe result depends on which one the bytecode reaches first. Verified:if event: event.debounce = 200 / else: event.debounce = len(x)inspects to200, while the same branches written in the other order inspect toNone. Write one assignment and the question does not arise; with more than one, check the value you get.
The second rule produces the sharpest bug, so it deserves its own call-out:
Bound methods are not inspected correctly
For async def on_submit(self, event), the first parameter is self, so the inspector never matches the event variable and reports prevent_default=False. The handler is a coroutine, so it passes the check and still fires — but your event.preventDefault() call is silently discarded:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | |
1 | |
Attach a plain function defined inside the component instead, or pass @event(prevent_default=True) explicitly over the method. A functools.wraps decorator is fine — the inspector follows __wrapped__ to the function you wrote, first parameter and all. A functools.partial is unwrappable too, but only half-fixed: once the leading argument is bound, the first parameter the inspector reads is yours, not the event's, so the same silent False appears. Same escape hatch: declare the option with @event.
An inline-JavaScript handler cannot be inspected at all
Detection runs on Python bytecode. {"onClick": "this.innerText = 'x'"} has none, so there is no preventDefault equivalent in the string form. Call it yourself in the JavaScript: "event.preventDefault(); doThing()".
Inline JavaScript¶
A str under an onXxx key is stored under inlineJavaScript and eval'd in the page; if it evaluates to a function, that function is called with the event:
html.button({"onClick": '(event) => (event.target.innerText = "Thank you!")'}, "Click")
html.button({"onClick": "this.innerText = 'Thank you!'"}, "Click")
This is the way to touch the DOM from ReactPy, and the reason it exists is that the server has no node to hold (Manipulating the DOM with Refs). Keep each snippet to a single call, and never interpolate anything you did not write:
Trusted strings only
The string runs with your application's privileges, in the page. f"this.innerText = '{user_input}'" is an injection point. Build strings from constants, or use a Python handler and pass data through state.
Note also that the pattern is ^on[A-Z]\w+ — a lowercase {"onclick": "..."} matches nothing and becomes a plain attribute that never fires.
Merging handlers¶
reactpy.core.events.merge_event_handlers combines several handlers into one that runs them concurrently in a task group. It is public API but nothing in the installed package calls it — verified by grepping the whole reactpy package, which finds only its own definition — so you will rarely have a reason to call it. Its failure mode is still worth knowing, because it is a ValueError at build time:
merge_event_handlers([a, b])
# ValueError: Cannot merge handlers - 'stop_propagation', 'prevent_default',
# 'debounce', 'throttle', 'target', or 'stop_immediate_propagation' mismatch.
Handlers merge only if every option matches. An empty list raises ValueError: No event handlers to merge.
What to check when a control does nothing¶
Every failure mode on this page is silent in the browser, so work through them in this order:
- Is it declared
async def? Adef, alambda, or a state setter raises while the component renders, and the traceback names the function. - Did you pass the function or call it? A handler is a coroutine, so
{"onClick": handler()}hands over a coroutine object — notcallable, so it falls through to the attributes and dies when the model is encoded:TypeError: Type is not JSON serializable: coroutinefrom the send path. A handler which returns something instead (Nonefrom a helper) ships as an inert attribute and never fires. Either way: pass the function, do not call it. - Is the key spelled the way the DOM spells its event, with a leading
onand a capital letter? ReactPy passes the key straight through, so aon_clickregisters a listener for an event named_click, which nothing ever dispatches. Copy the DOM's camelCase spelling (onClick,onDoubleClick,onKeyDown) rather than guessing. - Does the handler take exactly one parameter? Two required parameters means a server-side
TypeErrorand a dead button. - Is it a bound method? Then
preventDefault/stopPropagation/debounceare not detected — use@event. - Read the server log.
Failed to execute event handleris where swallowed handler errors go; the browser console will show nothing.
Summary¶
- Values are sorted by kind, not name:
callable→eventHandlers(and raises there unless it can be awaited), string underonXxx→inlineJavaScript, everything else →attributes. None,True, anddicthandlers all fail silently as attributes.- A handler is a coroutine — a
defor alambdais refused with aTypeErrorwhen the element is built, not when it is clicked. - A plain coroutine receives the argument list unpacked (so, the event proxy); an
EventHandlerreceives the list. - Set options with
@event(prevent_default=..., stop_propagation=..., stop_immediate_propagation=..., debounce=..., throttle=...); the bytecode detector is a convenience with real limits, notably bound methods. - Both
debounceandthrottlerate-limit client→server traffic:debouncecoalesces outgoing events on a trailing edge and only on user-input elements,throttlefires on a leading edge on any element. The server's echo of a newvalueis reconciled by_reactpy_ack_seq, not bydebounce. Invalid values are dropped with a console warning, not an error. - Handler errors surface in the server log only.
Related¶
- Responding to Events — the tutorial treatment, propagation, and the event object
- Common Props — the same surface from the reference side, including ReactPy's own options
- Protocol Structure — the wire format,
target, and howdatais built - Creating HTML Tags — how the model these handlers live in is built
- Manipulating the DOM with Refs — the inline-JavaScript escape hatch