Communicating Data Between Server and Client
Overview¶
ReactPy connects a Python process and a browser tab with one websocket and one message type in each direction. That single constraint decides how every kind of data moves between the two: values the server knows flow down as rendered models, values the user produces flow up as event payloads, and anything else needs a deliberate detour. This page is a map of those routes — what crosses, what cannot, and the honest workaround for each.
You will learn
- Which channels actually exist, and the one direction that is not supported
- How to push a server-side value into a view without the client asking
- What an event payload really contains — including why file uploads do not work the way people expect
- How to get real file bytes across, and how to keep blocking code off the loop
The channels that exist¶
| Direction | Mechanism | Your control |
|---|---|---|
| server → client | the rendered model, sent as layout-update patches | total — return it from the component |
| client → server | an event handler, receiving layout-event | total — read event |
| server → client, arbitrary message | none | not supported |
| shared mutable state | none | the two sides hold different objects |
The server-to-client push row is the one people look for first, and its absence is a design choice, not an omission. A component's Connection has no send, no deliver, no close (User Connection), and the backend is the only code allowed to write to the socket. That keeps the model the single source of truth: if a component could scribble on the wire, the client's tree and the server's model would drift apart with nothing to reconcile them.
A hand-written message is dropped by the client, silently
A custom backend can write anything to the socket, so it is tempting to invent a message type. Verified against a real browser: a {"type": "my-custom", ...} frame arrives, and the client logs
[ReactPy] Received message without handler Object
and discards it. The client dispatches on message.type against a fixed registry — "layout-update" is the only one registered by the mount path — and an unregistered type warns and returns. Nothing throws, the socket stays up, and the UI never changes. The client bundle exports exactly one function and offers no onMessage hook to register against, so there is no supported way to add a handler. Do not build on this path; use one of the routes below.
Down: push a value the client never asked for¶
Anything you can await on the server becomes a render, and a render reaches the client. An use_async_effect that loops is the whole pattern:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | |
1 | |
Verified end to end: the first patch carried ["loading..."], and about a second later a second patch arrived with ["BTC: 42.10"] — no client event in between. The client applied it like any other update.
Three things make or break this:
- Use
use_async_effect, notuse_effect. Anasync defpassed touse_effectraisesTypeError: \use_effect` does not support async functions. Use `use_async_effect` instead.The effect takes **zero arguments** — the#!python async def effect(stop)shape from 1.x no longer works. Note where that error surfaces:#!python use_effectraises during the **render**, not inside the effect task, so you get a blank region — the model is#!python {"tagName": "", "error": ""}with debug off, and carries theTypeErrortext with#!python REACTPY_DEBUGon — and noError in effect` log line at all. That distinguishes it from a raising effect body, which is silent everywhere until unmount (Lifecycle of Reactive Effects). - Loop and honour cancellation. A long-lived effect is
while True:withexcept asyncio.CancelledError: break. ReactPy cancels the task on unmount;shield=Trueopts out if the work must finish. - The dependency array is not React's. Omitted means inferred from the closure's free variables,
dependencies=[]starts once,dependencies=Nonerestarts every render. Never omit the array expecting "run after every render". See Use Async Effect.
Polling is fine for a demo and wrong at scale. For real work, prefer a server-sent queue that the effect awaits — the effect suspends on await queue.get() and the render happens the moment an item lands, with no timer at all.
Up: read what the client sends¶
An event handler receives a dict, not a live event — the browser serialized it and your code is now on the other side of a network boundary. The client walks the target and copies enumerable properties, plus value, checked, files, type, name read off the element even though they are accessors, plus dataset, plus — on submit — the form's named controls. Functions, DOM nodes, and cycles are skipped rather than raising.
Two consequences to internalize:
An <input> arrives as the whole element. A measured onChange payload carried roughly ninety keys — accept, formAction, validity, validationMessage, and the rest of HTMLInputElement — of which you read value. Index what you need; do not count on a specific key surviving, and do not iterate the payload assuming it is small.
On submit, event["target"] is a list, not a dict. The named controls are serialized as list entries, so the read shape differs from every other event:
def handle_submit(event):
event.preventDefault()
controls = event["target"] # list of control dicts
payload = next((c["value"] for c in controls if c.get("name") == "payload"), "")
This is verified: submitting a form with a hidden name="payload" input produced [{"name": "", "type": "file", ...}, {"name": "payload", "type": "hidden", "value": "hello world\n"}, {"name": "", "type": "submit", ...}]. The full working example is in Files below. The protocol behind this is documented in Protocol Structure.
Files: what actually crosses the wire¶
A file input does not send file contents. It sends file metadata. Verified with a real file chooser against a real browser — this is the entire files entry that arrived:
{
"name": "upload.txt",
"lastModified": 1791523914968,
"lastModifiedDate": {},
"webkitRelativePath": "",
"type": "text/plain",
}
No bytes. File exposes its content through methods, which are functions, and functions are skipped by the walk. There is no FormData in the client and no chunked upload path. If you expected event["target"]["files"][0]["content"], it does not exist and never will from this mechanism.
Reading real bytes¶
The route is: read the file in the browser with JavaScript, put the text into a normal form field, and let an ordinary submit carry it. This works — verified in Chrome, with a 12-character file arriving server-side as 'hello world\n':
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 | |
1 | |
The mechanics, each of which matters:
- The
onChangevalue is a string, which routes it toinlineJavaScriptinstead ofeventHandlers— that is what makes it run in the page (Creating VDOM Event Handlers). file.text()returns a promise, so the assignment happens in a callback — which is why a submit button is required. Sending immediately from the change handler races the promise and ships an empty string.- The hidden input needs both
id(for the JavaScript to find it) andname(for the form walk to include it). event.preventDefault()must appear literally in the handler's body. ReactPy detects it by bytecode inspection; calling a helper that calls it is not detected, and the page navigates away. See the bound-method caveat in Creating VDOM Event Handlers.
Untrusted content and eval
The inline-JavaScript string is eval'd. Build it from constants only — f"...{user_input}..." is an injection point. And anything you read off a user's file is untrusted data: cap the size before you buffer it, and never render it back without escaping.
Binary uploads work the same way via file.arrayBuffer() plus base64, but base64 costs a third of your bandwidth on top of a JSON string over a socket. For anything large, upload to a normal HTTP endpoint and pass the resulting id through state instead.
Blocking code and other threads¶
Everything a component does runs on the connection's event loop. A synchronous, CPU-bound call inside a render or an effect freezes that connection — and the handlers on it. Move blocking work with asyncio.to_thread:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | |
1 | |
Verified: the first patch says rendering..., the next says rendered 300px, and the loop stayed responsive across the 0.4 s call. Note that the setter is called from inside the effect, which is already on the loop.
Calling a state setter from another thread loses the update
Handing set_value to a raw threading.Thread is the natural mistake, and it fails with no user-visible symptom. Verified — the setter raises on the worker thread, and no render ever arrives:
Failed to schedule render via <function _make_life_cycle_state.<locals>.<lambda> ...>
RuntimeError: no running event loop
RuntimeWarning: coroutine 'Layout._create_layout_update' was never awaited
Nothing appears in the browser console; the component simply never updates. The fix is to capture the loop while you are on it and marshal back:
loop = asyncio.get_running_loop() # inside the component, on the loop
def deliver_from_thread(value):
loop.call_soon_threadsafe(set_value, value)
Verified working — the update arrived as a normal patch. Prefer asyncio.to_thread when you can, since it removes the bookkeeping entirely.
Do not hang state on the component function
my_component.started = True as a run-once guard looks harmless and is a cross-user leak: the function object is module-scope, so the first visitor flips it for everyone. Give the component a prop, key the child, or guard with use_ref. The general version of this is in VDOM Mutations.
Client-only data¶
The mirror problem: something in the browser that Python cannot see. Computed style, scroll position, canvas pixels, localStorage, geolocation, the clipboard — none of it is in the payload, because the walk copies data properties and skips everything callable.
Your options, in order of preference:
- Move the computation server-side. If the value depends only on data you already have, compute it in Python. Server-side rendering is the default for a reason.
- Copy it into a form field and submit, exactly as the file example does. Clunky, and it works.
- Use the browser API in inline JavaScript and let it act locally — copy to clipboard, request fullscreen, scroll. Keep it to one call, and never interpolate user input into the string.
- Render the data yourself. A chart is usually better as a server-built
<svg>or a generated image (reactpy.widgets.image) than as JavaScript in the page.
There is no use_ref-style handle to a DOM node, and {"ref": ...} either vanishes or throws in the browser — see Manipulating the DOM with Refs.
Summary¶
- Only two channels exist: the model down, events up. An arbitrary server→client message is logged as
Received message without handlerand dropped. - Server push means
use_async_effectplus a state setter — loop, handleCancelledError, and remember the dependency array infers by default. - Event payloads are serialized snapshots: an input arrives as the whole
HTMLInputElement, andsubmitgives you a list of named controls. - File inputs send metadata only, never bytes. Read the file in the browser, put the text in a named hidden input, and submit.
- Use
asyncio.to_threadfor blocking work. Calling a setter from a raw thread fails withno running event loopand no visible symptom — marshal withcall_soon_threadsafe. - State never lives on the component function object; that is a leak across connections.
Related¶
- Protocol Structure — both message types,
target, andseq - Use Async Effect and Use Effect — the push mechanism, and its traps
- Creating VDOM Event Handlers — payload shape, inline JavaScript, bytecode detection
- User Connection — the read-only connection view
- Manipulating the DOM with Refs — client-only capabilities
- Creating Backends — where the socket actually lives