Repository navigation
EventHandler return values: stream yielded/returned values to the frontend #7608
Description
Summary
Let a frontend caller dispatch a backend event and consume every value the handler yields or returns as an async iterator:
const stream = addEvents([ReflexEvent("state.search", { query })]);
for await (const hit of stream) {
render(hit);
}
// or, for a handler that simply returns one value:
const rows = await addEvents([ReflexEvent("state.load_rows", { page })]);Async-iterator style is used for every dispatch because it is the superset of the Python handler shapes Reflex already supports: a plain return x handler is a stream of one value, a sync generator yields N values (plus an optional return), an async generator yields N values.
Each dispatched event carries a client-generated request id so that several in-flight requests on one connection can tell their results apart.
Motivation
Today the only value round-trip is in the opposite direction: rx.call_script / rx.call_function evaluate JS and feed the result to a Python handler via a callback. There is no way for JS to call a Python handler and get a value back. That blocks a whole class of React integrations whose props expect an async callback that resolves with data, for example:
- async option loaders (
loadOptionson select/autocomplete components) - server-side row models / infinite scroll datasources (
getRows) - validation callbacks that must resolve to an error message
- upload pre-flight hooks that must resolve with a URL or a reject reason
- enterprise helpers that today fake it with
rx.call_script+fetch()and a global promise chain (see need a way to serialize frontend events #6766)
It also gives JS a clean way to await completion of a backend handler (await addEvents(...) resolves when the handler finishes), which is the missing primitive behind the frontend serialization problem in #6766.
#6223 already anticipates this ("Return values are sent back in the final delta — eventually allowing event calls to return a promise on the frontend"); this issue is the design for that part.
Current behavior
addEvents(events, args, event_actions)(useEventLoop,packages/reflex-base/src/reflex_base/.templates/web/utils/state.js) returns the promise fromqueueEvents, which resolves once the event has been emitted on the socket, not when the backend has processed it. When the dispatch is debounced or throttled away,applyEventActionsreturnsundefined.- Wire shape frontend -> backend:
{name, payload?, event_actions?, handler?, router_data}.EventNamespace.on_event(reflex/app.py) keeps only_EVENT_FIELDS(name,router_data,payload) when building theEvent, so unknown wire fields are ignored by the backend (reflex-otel relies on that fortraceparent). - Backend -> frontend: one socket message type,
"event", carrying a serializedStateUpdate{delta?, events?}(reflex/state.py).StateUpdate.finalis deprecated and unused. process_event(packages/reflex-base/src/reflex_base/event/processor/base_state_processor.py) drives coroutine / sync / generator / async-generator handlers. Every yielded value (and a sync generator'sreturnvalue) goes throughchain_updates, which flushes the delta and then_check_valid_yieldraisesTypeErrorfor anything that is notNone, anEvent,EventHandler,EventSpec, or a sequence of those (tests/units/test_state.py::test_state_with_invalid_yield).- The backend already has a per-event id:
EventContext.txid(packages/reflex-base/src/reflex_base/event/context.py), generated server-side, used as the key forEventProcessor._futures/_tasksand inherited asparent_txidby chained events.EventFutureresolves when the handler task finishes. default_backend_exception_handler(reflex/app.py) already defines what a handler failure may tell the browser:"{Type}: {message}"outside prod mode,"Contact the website administrator."in prod mode.
Proposed design
Frontend API
addEvents(...) returns a result stream object:
- implements
[Symbol.asyncIterator]: yields each value the handler yielded/returned, in order, interleaved with (and ordered after) the state deltas that preceded each value on the backend; - is thenable:
await streamresolves with the last value the handler produced (undefinedwhen it produced none) and rejects when the handler raised. "Last value" is the one rule that fits every handler shape: a Python async generator cannotreturna value, and a sync generator'sreturn xis by construction its last value. Promise resolution auto-unwraps thenables, so a compiled event chain can be passed straight to a React prop expecting(args) => Promise<T>; - completes when the invoked handler finishes (success, exception, or cancellation). It is scoped to the invoked handler only: values produced by events the handler chained (
yield State.other) are not included, and the stream does not wait for them. Nowait_allvariant in this item.
When addEvents is called with several events, the stream merges their values in arrival order and completes when all of them have completed. Call addEvents once per event to get separate streams.
Debounce and throttle. A debounced call attaches the caller to the dispatch that eventually fires: every call that lands in the same debounce window gets the same stream object, which is bound to the real stream when the timer fires, so an autocomplete that debounces keystrokes can await from any keystroke and receive the result of the one that was sent. A throttled-out call attaches to the most recent dispatch that fired for the same action key while it is still open; if none is open it gets an already-completed empty stream. Frontend-only events (_redirect, _call_script, ...) and REST-dispatched uploads complete immediately with no values.
Wire protocol
Frontend -> backend: add one field to the event object, id (string), generated per dispatched event in applyEvent right before socket.emit. Cheap and unique per tab: a random per-page-load prefix plus an incrementing counter (a reload wipes the pending map, so a late result from a pre-reload background handler must not collide with a new id).
Backend -> frontend: add a results field to StateUpdate, a list of per-request records, so results ride the existing "event" socket message and inherit its ordering, its lost-and-found routing under RedisTokenManager, and its disconnect handling:
{"results": [{"id": "ab12:7", "value": 42}]}
{"results": [{"id": "ab12:7", "done": true}]}
{"results": [{"id": "ab12:7", "error": "ValueError: bad page"}]}Where possible the done record is merged into the StateUpdate that carries the handler's final delta flush, so the common case (a handler that changes state and returns nothing) costs no extra socket message. A no-op handler costs one small done message it did not send before.
The error text follows the same rule as default_backend_exception_handler: "{Type}: {message}" outside prod mode, a fixed generic message in prod mode. The stream rejects either way; the backend exception handler (and its toast) still runs as today.
Backend
-
Eventgains an optionalid: str | Nonewire field (added to_EVENT_FIELDS; validated as a bounded-lengthstrinon_event). It is never set on events the backend creates, so chained events report nothing. -
EventContextgainsrequest_id: str | None, copied fromevent.idbyEventProcessor.enqueueand not propagated byfork(). The client-chosen id is deliberately kept separate fromtxid:_futuresand_tasksare keyed globally bytxid, so a client must not be able to pick it. -
EventContextgains anemit_result_implchannel next toemit_delta_impl/emit_event_impl, wired inEventProcessor.configuretoemit_update(StateUpdate(results=[...])). Handlers never call it directly;chain_updatesdoes. -
chain_updates/_check_valid_yieldclassify each yielded object:None: delta flush only (unchanged);Event/EventHandler/EventSpec/ lambda, or a sequence of those: events (unchanged);- anything else: a result value, emitted after the delta flush through
ctx.emit_result(value)whenrequest_idis set, dropped otherwise; - a sequence mixing events and non-events stays a
TypeError.
This is a strict relaxation: every input that becomes a result today raises
TypeError. -
A sync generator's
returnvalue and a coroutine's return value go through the same path (they already reachchain_updates). -
Completion is emitted from the future-done path (
EventProcessor._finish_task/_resolve_future), so every request with an id terminates exactly once:doneon success,erroron exception (after the backend exception handler ran),donewithcancelled: truewhen superseded or cancelled. For background handlers that is when the background task ends. -
Values are serialized with the same serializer registry as deltas, so dataclasses, pydantic models, datetimes etc. work; a non-serializable value raises on the backend and surfaces as an
errorrecord.
Interaction with #6223 (awaitable EventHandler). Backend consumption of results is out of scope here: nothing outside the processor can obtain an EventFuture today, and #6223 is where self-queuing handlers will return one. To keep that work cheap, results are produced through the context channel (ctx.emit_result) rather than written straight to the socket, so a future can later subscribe to the same channel; request_id is the only thing that ties a result to a socket. The two items should agree on await semantics: await handler in #6223 resolving with the last value (and iteration giving each value) mirrors the frontend thenable defined above.
Frontend runtime
pending = Map<id, stream>instate.js;applyEventregisters the stream before emitting.socket.on("event"): after dispatchingupdate.deltaand before queueingupdate.events, route eachupdate.resultsrecord to its stream (value-> push,done-> close,error-> fail). Unknown ids (stale after reload) are ignored.socket.on("disconnect"): fail every pending stream with a connection error; a reconnect re-hydrates state anyway, and results emitted while disconnected are dropped byemit_updatetoday.queueEvents/addEventsreturn the stream (merged for batches).applyEventActions: the debounce path keeps one deferred stream per debounce key (the helper's${name}__${delay}key) and hands it to every call in the window;invokeTargetbinds it to the real stream when the timer fires. The throttle path records the last open stream per key and returns it to throttled-out calls.
Compatibility
- Old frontend + new backend: no
idon the wire, so no results are emitted; handlers that yield values just run (previously they raised). - New frontend + old backend:
idis filtered out by_EVENT_FIELDS; streams never complete. Acceptable only during dev; the frontend is compiled against its backend, so this pairing does not happen in a built app. - No change to the compiled JS of ordinary event triggers beyond the id.
- No component changes, so no
.pyiregeneration.
Out of scope (follow-ups)
- Typed results: honor a handler's return annotation (
-> int,-> AsyncIterator[Row]) so the compiler can type the stream /Var. - A Python-side helper to pass a handler as an async callback prop with the result type declared, once typed results exist.
- Forwarding the eval result of
_call_script/_call_functioninto the stream of a frontend-only dispatch. - Backend consumption of results (EventHandler redesign: awaitable semantics #6223).
wait_allsemantics (stream completes when the whole chain settles).- Using the completion promise to serialize frontend events (need a way to serialize frontend events #6766).
Implementation plan
- Backend protocol (
reflex-base,reflex):Event.id,EventContext.request_id+emit_result_impl,StateUpdate.results, classification inchain_updates, completion/error records from the future-done path. Unit tests intests/units/reflex_base/event/processor/test_base_state_processor.py,tests/units/test_state.py(updatetest_state_with_invalid_yield),tests/units/test_app.py(on_eventaccepts/validatesid). - Frontend runtime (
state.js): id generation, pending map, stream object (async iterator + thenable), routing insocket.on("event"), disconnect handling, return value ofaddEvents, debounce/throttle attachment inapplyEventActions. - Integration test (Playwright, extend an existing app under
tests/integration/tests_playwright/): a handler that yields three values then returns one; JS driven viapage.evaluateconsumes the stream withfor awaitandawait, asserts ordering against a state delta emitted between yields, asserts rejection on a raising handler, asserts two concurrent requests do not cross results, and asserts two calls inside one debounce window share one result. - Docs + news: new section in
docs/events/yield_events.md("Yielding values to the frontend"), a note indocs/api-reference/browser_javascript.md, a wrapping-react recipe for async callback props;news/+event-results.feature.mdandpackages/reflex-base/news/+event-results.feature.md.
Activity
linear-code commentedon Oct 20, 2026
ENG-13447