Client API
Overview¶
mountReactPy is the browser-side entry point: one exported function, one websocket, no build step. Everything else in the browser is the server's business.
import { mountReactPy } from "/reactpy/static/index.js";
mountReactPy({
mountElement: document.getElementById("app"),
pathPrefix: "/reactpy/",
componentPath: "main.counter/",
});
The executors emit this call for you — see ReactPy and ReactPyMiddleware. You need to write it yourself only when you serve the document from your own route, or when the defaults do not fit.
Reference¶
mountReactPy(options)¶
import { mountReactPy } from "{pathPrefix}static/index.js";
The bundle is served by ReactPy itself, under {pathPrefix}static/, alongside two chunks it imports by relative path. There is nothing to install and nothing to build: it is preact and the client, pre-bundled.
Parameters¶
A single object.
-
mountElement: The DOM node to render into. Not optional in effect — passingnull, which is whatgetElementByIdreturns for a missing id, dies withTypeError: Cannot read properties of null (reading '__k')before anything is logged. -
pathPrefix:string. Must start and end with/. Concatenated verbatim withcomponentPath, so a missing slash on either side produces a URL that is wrong rather than invalid — which is worse, because nothing rejects it. A value with no leading slash ("reactpy/") or nopathPrefixat all may fail early withTypeError: Failed to construct 'URL': Invalid URL, but only when the page's host includes a port. See The URL is built by concatenation. -
componentPath:string, optional. The dotted path of the root component, with a trailing slash:"main.counter/".ReactPyMiddlewarerequires it.ReactPytakes the opposite view and requires it be omitted — see The executors route differently. -
reconnectInterval:numberof ms.750. -
reconnectMaxInterval:numberof ms.60000. -
reconnectMaxRetries:number.150. -
reconnectBackoffMultiplier:number.1.25.
Returns¶
Nothing.
There is no unmountReactPy
The bundle exports exactly one name. Verified by importing it and listing the keys: mountReactPy, nothing else. mountReactPy returns nothing and takes no callback, so you get no handle to keep and no supported way to stop a component.
Removing the mount element from the DOM does not stop it either. The subtree is detached, not unmounted: mountReactPy calls preact's render(...) into the node and never the matching unmount. Verified in a browser — after removing the node, a click dispatched into the detached subtree still reached the server and incremented its state, and the websocket stayed OPEN in both directions. The component keeps reconciling into an orphan nobody can see.
The only clean teardown is to navigate or reload. If you are building a single-page app that swaps components on the client, the closest available tool is onMessage's return value, which unsubscribes a handler — that stops the client applying updates, but leaves the socket and the server's layout alive.
Reconnect options reject zero, not just wrong types
The defaults are applied with JavaScript's ||, not ??, so any falsy value is replaced by the default. Passing reconnectMaxRetries: 0 to mean "do not retry" gets you the default 150 instead, and reconnectInterval: 0 gets you 750 ms.
To actually stop retrying, pass a negative reconnectMaxRetries. The retry test is retries >= maxRetries and negatives survive ||, so reconnectMaxRetries: -1 connects once and then gives up — measured at exactly one attempt, by running the client's own createReconnectingWebSocket against a stub socket.
The retry counter is also not what it appears to be. It counts consecutive failures, because a successful open resets it to zero along with the backoff interval. Measured against a stub that accepts each connection and closes it 2 ms later, reconnectMaxRetries: 2 with a 1 ms interval produced 342 attempts in 1.5 s: every reconnect succeeds, so the counter never reaches the limit. Only attempts that fail to open at all accumulate.
Which leads to the sharper edge: a handshake that is refused is never retried at all. If the socket never reaches OPEN, the close handler logs Failed to connect! and returns without scheduling anything. So ReactPyMiddleware answering 403 — a wrong componentPath, a wrong path_prefix — gives you one attempt, one line in the console, and a permanently empty element. Nothing recovers without a reload. Retries exist only for connections that opened and then dropped.
What mountReactPy does¶
mountReactPy does five things (static/index.js):
- Picks the websocket scheme from the page:
wss:underhttps:, otherwisews:. - Builds the URL as
scheme // host + pathPrefix + componentPath, then appendspath=window.location.pathnameand, only if the page has a query string,qs=window.location.search. - Sets
jsModulesPathtoorigin + pathPrefix + "modules/", the base from which JavaScript modules named by an element'simportSourceare imported. - Constructs the client, which wires up a reconnecting websocket.
- Renders the layout into
mountElement.
Steps 2 and 3 are the only places the browser's own URL is read. Once the socket is open, the server's view of the URL is whatever those two parameters said — see Use Location.
The URL is built by concatenation¶
Nothing inserts a slash for you, so both halves have to arrive correctly formed. The four ways to get it wrong, each verified from a page with the resulting socket URL (against ReactPyMiddleware on a host with a port):
pathPrefix | componentPath | Socket path | Result |
|---|---|---|---|
"/reactpy/" | "main.counter/" | /reactpy/main.counter/ | Connects. |
"/reactpy" | "main.counter/" | /reactpymain.counter/ | 403, then Failed to connect! |
"/reactpy/" | "main.counter" | /reactpy/main.counter | 403, then Failed to connect! |
"/reactpy/" | omitted | /reactpy/ | 403, then Failed to connect! |
ReactPyMiddleware matches the websocket path against ^{path_prefix}(?P<dotted_path>[a-zA-Z0-9_.]+)/$. Rows two through four fail that pattern, so they fall through to your own ASGI app, which answers 403.
Read the browser's own error line, not ReactPy's. The client emits a bare Failed to connect!; the URL that caused it is on the line above, in the browser's message, without the [ReactPy] prefix.
The || defaults and the URL constructor make this host-dependent
Two of these fail earlier than the table suggests, and only sometimes. new URL(...) throws TypeError: Failed to construct 'URL': Invalid URL for a missing or slashless pathPrefix — but only when the host carries a port, because ws://example.comreactpy/main.counter/ parses happily as host example.comreactpy. Verified with Node's URL, the same implementation Chrome uses:
| Host | pathPrefix | Outcome |
|---|---|---|
127.0.0.1:8842 | "" | TypeError: Invalid URL |
127.0.0.1:8842 | "reactpy/" | TypeError: Invalid URL |
example.com | "" | Connects to host example.commain.counter |
example.com | "reactpy/" | Connects to host example.comreactpy |
So the mistake that fails loudly in development fails silently in production, sending the socket to a hostname that does not exist. Check the host, not just the path.
The executors route differently¶
The last table row is not a universal truth, and this is where the two executors part company. ReactPy — the standalone app — overrides the matcher:
def match_dispatch_path(self, scope):
"""Method override to remove `dotted_path` from the dispatcher URL."""
return str(scope["path"]) == self.dispatcher_path
So the two want opposite things:
componentPath | Unmatched dotted path | |
|---|---|---|
ReactPyMiddleware | required, with trailing slash | Falls through to your app (commonly 403) |
ReactPy | must be omitted — the bare {pathPrefix} is the route | 500, from NotImplementedError: ReactPy app received unsupported request of type 'websocket' |
Verified against both executors on the same build: the middleware accepts /reactpy/main.counter/ and refuses /reactpy/; the standalone app does exactly the reverse. A mount snippet copied from one executor to the other therefore produces a socket that never opens.
The socket does not open immediately¶
Constructing the client does not connect. The websocket waits on a ready promise, and that promise is resolved by the first onMessage registration — nothing else in the codebase resolves it. The layout registers its handler from an effect, so the client connects only after that effect runs, which is why [ReactPy] Starting client... always precedes Connected! and never follows it.
Because onMessage is the sole resolver, a run where the layout's effect never fires produces a fourth console state: no Starting client..., no socket attempt, and no error. Treat that as "the bundle ran but the component never mounted" rather than a network problem — the other four messages below are all post-connect.
Two consequences of the gating:
- Reconnection refreshes
pathandqsfrom the live page on every attempt, but never re-readscomponentPath— that was baked into the URL at mount time. - Messages sent before the socket reports
OPENare buffered rather than dropped, and flushed on the nextopen. They survive a reconnect — but once retries are exhausted the buffer is never drained.
Note that path and qs are query parameters, not path segments. The full form is ws://127.0.0.1:8000/reactpy/main.counter/?path=%2F&qs=%3Ftab%3D1, and the server reads them with strict_parsing=True. When building a test client by hand, copy a real frame from the browser rather than assembling one from the tables on this page.
The import map¶
For a server-rendered component, no import map is needed. The bundle imports its own two chunks by relative path and has preact inlined, so nothing it loads statically is a bare specifier. Verified: a page with no map at all mounted, connected, and the server never received a single request for preact.js.
The map becomes load-bearing only when the model contains importSource. Rendering such an element runs await import("react") and await import("react-dom/client") — dynamic imports, which are exactly what an import map resolves. If they succeed, real React renders the subtree; if they fail, the client falls back to its own preact bindings and says so:
ReactPy will render JavaScript components using internal bindings for 'react'.
Note the level and the prefix: that line is console.debug with no [ReactPy] prefix, so it is invisible in a console filtered to warnings, and it is not caught by filtering on [ReactPy].
The executors' bootstrap injects a map only if none is present — and the check is keyed on one specific id:
if (!document.querySelector("#reactpy-importmap")) { /* inject ReactPy's map */ }
The id is the trigger, not the content. Verified across three pages, all served through the same executor bootstrap:
| Page | ReactPy injects | What you see |
|---|---|---|
| No map | Yes, as <script type="importmap" id="reactpy-importmap"> | ReactPy did not detect a suitable JavaScript import map. Falling back to ReactPy's internal framework (Preact). |
| Your own map, any other id | Yes — a second map appears, alongside yours | The same did not detect line, plus the browser's own An import map rule for specifier 'react' was removed, as it conflicted with an existing rule. |
Your own map with id="reactpy-importmap" | No | Nothing at all. |
Both of those messages come from the Python side of the bootstrap (executors/utils.py, server_side_component_html), emitted as unprefixed console.debug, not from the client bundle.
To control resolution yourself, use that exact id. Two maps is worse than untidy: the browser keeps the first rule it sees for a specifier and discards the later one, so with two maps the winner is decided by document order — which is why the warning above names a rule being removed.
The map ReactPy writes maps all four React specifiers onto its own preact files under {pathPrefix}static/. Save the page and the app under those exact names — the dotted path in componentPath is a Python module path, and main.counter only resolves when this file is called main.py:
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 | |
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 | |
Serve it with uvicorn main:my_app. Verified in a browser: the counter rendered and logged [ReactPy] Connected!, and no request for preact.js reached the server — confirming the client never consulted the map.
The client object¶
mountReactPy builds on two classes from the @reactpy/client source package. Neither is reachable from a page — the shipped bundle exports mountReactPy and nothing else — but you meet them reading the bundle, and they are what a custom binding's sendMessage and onMessage come from.
BaseReactPyClient is the transport-independent half:
| Member | Purpose |
|---|---|
onMessage(type, handler) | Register a handler for a message type. Returns an unsubscribe function. |
handleIncoming(message) | Dispatch to the handlers for message.type. |
ready | Promise, resolved by the first onMessage call. Gates the connection. |
Messages with no type, or with one nobody registered for, are logged and dropped — the connection is never closed over a bad message:
Received message without type {…}
Received message without handler {…}
ReactPyClient adds the websocket, plus sendMessage(message) and loadModule(name). They are not peers. sendMessage is handed to a JavaScript binding as its way to talk back; loadModule is called for the binding, by ReactPy, to pull in a web module named by an importSource whose sourceType is "NAME" — it resolves against jsModulesPath, which is what the server's web modules directory is mounted under.
The console¶
Every message ReactPy writes is prefixed [ReactPy], so filtering the console by that string shows the whole story of a mount. The set is finite:
| Message | Meaning |
|---|---|
Starting client... | The layout registered its handler; a connection attempt is about to start. |
Connected! | The handshake completed. Resets the retry counter and the backoff interval. |
Failed to connect! | The socket closed without ever having opened. Usually a routing miss — see The URL is built by concatenation. |
Disconnected! | The socket closed after at least one successful open. |
Reconnecting in 0.7500 seconds... | A retry is scheduled. The number is the current backoff interval. |
Connection max retries exhausted! | Giving up. The page needs a reload; nothing recovers after this. |
did not detect a suitable JavaScript import map... | <script type="importmap" id="reactpy-importmap"> was absent. Informational — see The import map. |
Failed to connect! and Disconnected! are distinguished by whether the socket ever opened, which makes them useful as a diagnosis: the first means the server never routed the request, the second means it did and then the link dropped. Both are logged at console.info level, so a console set to warnings-only shows nothing at all — check the level before concluding the client is silent.
A hand-written mount¶
Nothing is required beyond an element and the call. Both files below were run to produce the [ReactPy] Connected! and the incremented counter quoted on this page.
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 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 | |
1 | |
The component is registered with root_components, and the componentPath in the page — "hand_written.counter/" — is that dotted path with a slash appended.
The 404 in the console is usually not yours
A page like this logs one Failed to load resource: 404 on every load. It is /favicon.ico, which no browser should be told about by a component framework. Verified: it appears with everything else working. Ignore it; a second 404 naming a /reactpy/... path means the bundle could not be fetched, which does mean the mount will not happen.
Failure modes¶
| Symptom | Cause |
|---|---|
TypeError: Cannot read properties of null (reading '__k') | mountElement is None. The id does not exist, or the script ran before the element was parsed. |
TypeError: Failed to construct 'URL': Invalid URL | pathPrefix missing, or missing a leading or trailing slash. No request is ever made. |
mountReactPy() throws Cannot read properties of undefined | No options object at all. |
Failed to connect!, nothing rendered | The socket URL did not match the server's route. Read the URL in the browser's own error line. |
[ReactPy] Connected!, empty element, nothing in the browser console | The component path is unknown to the server. The failure is server-side: RuntimeError: Attempting to use an unregistered root component <path>. Check the server log, not the browser. |
| Renders once, never updates | Handlers are not firing — usually a snake_case event name such as on_click. See Responding to Events. |
| Component vanishes when you remove it from the DOM, socket still open | Expected. There is no unmount that closes the socket. |
| Two import maps in the document, React warnings about a removed rule | Your map does not use id="reactpy-importmap". |
Related¶
- Protocol Structure — what travels over the socket this function opens.
- ReactPy and ReactPyMiddleware — the executors that generate this call.
- Jinja — emitting mounts from templates, including
{% pyscript_component %}for running Python in the page. - Use Location — reading back the
pathandqsthe client sends.