Skip to content

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 — passing null, which is what getElementById returns for a missing id, dies with TypeError: Cannot read properties of null (reading '__k') before anything is logged.

  • pathPrefix: string. Must start and end with /. Concatenated verbatim with componentPath, 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 no pathPrefix at all may fail early with TypeError: 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/". ReactPyMiddleware requires it. ReactPy takes the opposite view and requires it be omitted — see The executors route differently.

  • reconnectInterval: number of ms. 750.

  • reconnectMaxInterval: number of 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):

  1. Picks the websocket scheme from the page: wss: under https:, otherwise ws:.
  2. Builds the URL as scheme // host + pathPrefix + componentPath, then appends path=window.location.pathname and, only if the page has a query string, qs=window.location.search.
  3. Sets jsModulesPath to origin + pathPrefix + "modules/", the base from which JavaScript modules named by an element's importSource are imported.
  4. Constructs the client, which wires up a reconnecting websocket.
  5. 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 path and qs from the live page on every attempt, but never re-reads componentPath — that was baked into the URL at mount time.
  • Messages sent before the socket reports OPEN are buffered rather than dropped, and flushed on the next open. 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
<!doctype html>
<html lang="en">
  <head>
    <title>Counter</title>
    <script type="importmap" id="reactpy-importmap">
      {
        "imports": {
          "react": "/reactpy/static/preact.js",
          "react-dom": "/reactpy/static/preact-dom.js",
          "react-dom/client": "/reactpy/static/preact-dom.js",
          "react/jsx-runtime": "/reactpy/static/preact-jsx-runtime.js"
        }
      }
    </script>
  </head>
  <body>
    <div id="app"></div>
    <script type="module" crossorigin="anonymous">
      import { mountReactPy } from "/reactpy/static/index.js";

      mountReactPy({
        mountElement: document.getElementById("app"),
        pathPrefix: "/reactpy/",
        componentPath: "import_map.counter/",
      });
    </script>
  </body>
</html>
 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
from pathlib import Path

from starlette.applications import Starlette
from starlette.responses import FileResponse
from starlette.routing import Route

from reactpy import component, html, use_state
from reactpy.executors.asgi import ReactPyMiddleware


@component
def counter():
    count, set_count = use_state(0)

    def increment(_event):
        set_count(count + 1)

    return html.div(
        html.h1(f"count is {count}"),
        html.button({"onClick": increment}, "Add"),
    )


async def home(request):
    return FileResponse(Path(__file__).parent / "page.html")


my_app = ReactPyMiddleware(
    Starlette(routes=[Route("/", home)]),
    root_components=["main.counter"],
)
1
# TODO

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
from starlette.applications import Starlette
from starlette.responses import HTMLResponse
from starlette.routing import Route

from reactpy import component, html, use_state
from reactpy.executors.asgi import ReactPyMiddleware

PATH_PREFIX = "/reactpy/"


@component
def counter():
    count, set_count = use_state(0)

    def increment(_event):
        set_count(count + 1)

    return html.div(
        html.h1(f"count is {count}"),
        html.button({"onClick": increment}, "Add"),
    )


async def home(request):
    # pathPrefix and the bundle's URL both have to agree with the server's,
    # so both are interpolated from one value rather than typed twice.
    return HTMLResponse(
        f"""<!doctype html>
        <html>
          <body>
            <div id="app"></div>
            <script type="module" crossorigin="anonymous">
              import {{ mountReactPy }} from "{PATH_PREFIX}static/index.js";

              mountReactPy({{
                mountElement: document.getElementById("app"),
                pathPrefix: "{PATH_PREFIX}",
                componentPath: "main.counter/",
              }});
            </script>
          </body>
        </html>"""
    )


my_app = ReactPyMiddleware(
    Starlette(routes=[Route("/", home)]),
    root_components=["main.counter"],
)
1
# TODO

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".

  • 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 path and qs the client sends.
Authors: Archmonger