Creating VDOM Event Handlers
Overview¶
An event handler in ReactPy is any callable 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. 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
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 |
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_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.
None is not a handler. The classic {"onClick": handle_click()} — calling instead of passing — yields {"attributes": {"onClick": None}}. The button renders, looks dead, and logs nothing on either side. If a control "does nothing", check that you passed the function.
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 callables, the shape you want most of the time¶
Pass a function and ReactPy wraps it. The browser sends a list of arguments; a plain callable receives it unpacked positionally (core/events.py, to_event_handler_function), which for a DOM event means one argument:
def handle_click(event):
print(event["target"]["value"])
html.button({"onClick": handle_click}, "Click me")
Sync functions, coroutine functions, and lambdas all work. Name the parameter whatever you like — the value is positional, and its type is reactpy.types.Event, a dict subclass with attribute access into nested dicts, so event["target"]["value"] and event.target.value are the same read. See Responding to Events for the payload surface and Protocol Structure for what the client actually serializes.
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, usually with a lambda:
html.button({"onClick": lambda event: delete(item.id)}, "Delete")
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 | |
1 | |
The failure when you get it backwards is TypeError: list indices must be integers or slices, not str inside the handler — and the layout swallows it into a server-side Failed to execute event handler line. Nothing appears in the browser console, so the UI merely stops responding. Prefer the plain-callable form unless you specifically need to hand-build a handler; @event gives you the same options with the friendlier argument shape.
Setting the four options¶
Four 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 |
debounce | int ms | rate-limits server→client value updates on input elements |
throttle | int ms | rate-limits client→server outgoing events |
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, 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 (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 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 still fires — it is a perfectly good callable — 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. functools.partial is fine — the inspector unwraps .func — and so is a functools.wraps decorator, via __wrapped__.
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' or 'target' 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:
- Did you pass the function or call it?
{"onClick": handler()}givesNone, which becomes an inert attribute. - Is the key a real DOM event name in camelCase?
on_clickandonclicknever fire. - 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, string underonXxx→inlineJavaScript, everything else →attributes. None,True, anddicthandlers all fail silently as attributes.- A plain callable receives the argument list unpacked (so, the event dict); an
EventHandlerreceives the list. - Set options with
@event(prevent_default=..., stop_propagation=..., debounce=..., throttle=...); the bytecode detector is a convenience with real limits, notably bound methods. debouncelimits server→client value updates;throttlelimits client→server events. 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