Skip to content

Jinja

Overview

ReactPyJinja adds three tags to a Jinja2 environment so an existing Python web app can mount ReactPy components inside templates it already owns — one tag for server-rendered components, two for components that run in the browser via PyScript.

{% component "my_app.pages.counter" %}

Install the extra the extension needs:

pip install "reactpy[asgi,jinja]"

It lives at reactpy.templatetags.ReactPyJinja. Despite the templatetags name it is not a Django app — it is a jinja2-simple-tags StandaloneTag, so it works with any Jinja2 environment.


Reference

Registering the extension

Two equivalent forms:

from jinja2 import Environment, FileSystemLoader

from reactpy.templatetags import ReactPyJinja

# at construction
env = Environment(loader=FileSystemLoader("templates"), extensions=[ReactPyJinja])

# or afterwards
env = Environment(loader=FileSystemLoader("templates"))
env.add_extension(ReactPyJinja)

Both register all three tags at once; there is no way to register one alone, and no configuration to pass. Registering ReactPyJinja(...) as an instance and calling .register() does not work — that object has no register method.

Register before a template is compiled

Tags are resolved when a template is parsed. If the environment has already compiled a template that uses them, you get jinja2.exceptions.TemplateSyntaxError: Encountered unknown tag 'component'. Frameworks that cache templates at startup make this a startup-time error, which is easy to attribute to something else.

{% component "dotted.path" %}

Emits the markup that mounts a server-side component: an empty mount element, an import-map fallback, and a module script that calls mountReactPy.

{% component "my_app.pages.counter" %}
{% component "my_app.pages.badge", class="badge" %}
  • The dotted path (required): the import path of the root component function — the same string you give ReactPyMiddleware's root_components. It is embedded verbatim into the emitted JavaScript; ReactPy imports it on the server when the browser asks to connect.
  • class (optional): a CSS class for the generated mount element, producing <div id="..." class="badge"></div>.

safe_output is set on the tag, so Jinja does not escape the returned HTML even under autoescape=True.

Arguments are parsed as Python expressions

The tag uses Jinja's argument parser, so keyword arguments must follow a comma — {% component "m.f" class="x" %} fails at template-compile time with TemplateSyntaxError: expected token ',', got 'class'. Write {% component "m.f", class="x" %}.

The path may be a quoted string (as above) or a Jinja expression that resolves to one; in the latter case it is read from the template context, so an unquoted my_app.pages.counter requires my_app to be a context variable.

Errors this tag raises

Mistake Error
Omitting the path TypeError: component() missing 1 required positional argument: 'dotted_path'
Passing a second positional TypeError: component() takes 1 positional argument but 2 were given
Any other keyword ValueError: Unexpected keyword arguments: id, style
A typo in the tag name TemplateSyntaxError: Encountered unknown tag 'bogus_tag'.

A wrong dotted path is not one of them. Nothing checks that the module or attribute exists: the tag happily emits markup pointing at a path that will never connect, and the only symptom is a permanently empty element plus a failed request in the browser console. Verify the path against what you passed to root_components.

{% pyscript_setup %}

Emits the PyScript bootstrap — stylesheet, module script, and the config that installs ReactPy into the browser.

<head>
    {% pyscript_setup %}
</head>
  • extra_py (optional, positional, repeatable): extra Python packages for the PyScript runtime — {% pyscript_setup "numpy", "rich" %}. They are appended to the package list alongside ReactPy's own wheel.
  • extra_js (optional, keyword): a JSON object mapping module URLs to names, e.g. extra_js='{"./my.js": "mylib"}'. It is merged into the config's js_modules.main. A plain string that is not valid JSON raises json.decoder.JSONDecodeError.
  • config (optional, keyword): a JSON object (or JSON string) merged over the generated config, letting you set anything PyScript accepts.

Put this tag in the document head, once. A page with several pyscript_component tags needs one bootstrap, not one per component.

Packages install in the browser

Anything in extra_py must be installable under Pyodide — pure Python, or a wheel built for it. A package with C extensions that installs cleanly on the server will fail in the browser, and the failure appears only in the browser console.

{% pyscript_component "path/to/file.py" %}

Emits a <div> plus a <script type='py'> that runs the named file's component in the browser.

{% pyscript_component "components/counter.py" initial="Loading..." %}
  • File paths (required, positional, repeatable): paths to Python files, resolved relative to the process's current working directory at template-render time. Several files may be passed; they are concatenated into one script.
  • initial (optional): markup rendered inside the mount element while PyScript boots.
  • root (optional): the name of the root component function, default "root".

Because the files are read and inlined, the browser never fetches them and your app serves no route for them.

The file's source ships to the client

Nearly the whole file is embedded in the HTML: executors/pyscript/utils.py, minify_python deletes # comments and inner docstrings before inlining, but the code and every string literal survive. One docstring does escape the filter — the module docstring, because the regex that removes docstrings needs a newline before the opening quotes and that docstring sits at the very start of the file (measured: a module docstring reached the page, a function docstring and both comment styles did not). Treat all of it as published, including any secret read into a string at render time. This is unrelated to debug mode: turning reactpy.config.REACTPY_DEBUG off hides nothing here.

Mistake Error
File does not exist (wrong cwd or path) FileNotFoundError: [Errno 2] No such file or directory: 'components/missing.py'
No root function in the file ValueError: Could not find the root component function 'root' in your PyScript file(s).

Both are raised at render time, so they surface as a 500 on the page — not as a broken widget.

Prefer ReactPyCsr unless you are embedding

For a page built from scratch, ReactPyCsr is an ASGI app with its own URL and handles the setup tag for you. These two tags exist for putting a component inside a template you do not control. See ReactPyCsr.


Usage

A server-rendered component in an existing page

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
templates = Jinja2Templates(
    env=Environment(
        loader=FileSystemLoader("templates"),
        extensions=[ReactPyJinja],
        autoescape=True,
    )
)


async def home(request):
    return templates.TemplateResponse(request, "home.html")


starlette_app = Starlette(routes=[Route("/", home)])

# The dotted path in the template is resolved on the server, not in the
# template: the browser asks ReactPy to render `pages.counter`, and ReactPy
# imports it to satisfy that request. Register exactly the paths the template
# passes to `{% component %}` -- here the `counter` and `badge` functions in
# the sibling `pages.py`.
my_app = ReactPyMiddleware(
    starlette_app, root_components=["pages.counter", "pages.badge"]
)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
<!doctype html>
<html lang="en">

<head>
  <title>Accounts</title>
</head>

<body>
  <h1>Welcome</h1>

  {# One tag per component; ReactPy gives each mount element its own id. #}
  {% component "pages.counter" %}

  {# `class` must follow a comma -- the tag parses its arguments as Python. #}
  {% component "pages.badge", class="badge" %}
</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
from reactpy import component, html, use_state


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

    async def handle_count_click(event):
        set_count(count + 1)

    return html.div(
        html.h1(count),
        html.button({"onClick": handle_count_click}, "Add 1"),
    )


@component
def badge():
    # The tag's `class` argument styles the mount element, not this markup, so
    # the component is free to render whatever it likes inside it.
    return html.div(
        html.span({"class": "badge-label"}, "Status:"),
        html.span({"class": "badge-value"}, "active"),
    )
1
# TODO

The components cannot live in main.py even though that is where the wiring is. The browser reaches them by dotted path, so they must sit in a module the server can import — a component defined next to the view is reachable as main.counter, but then the template has to say exactly that. Whatever you choose, the path in {% component %} and the entry in root_components must agree character for character. Nothing checks it for you: a mismatch emits markup that mounts an element which stays empty forever, and the failed import surfaces only as a server-side traceback on the websocket request.

A client-side component

Setup in the head, the component where it belongs:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
<!doctype html>
<html lang="en">
  <head>
    <title>Counter</title>
    {% pyscript_setup %}
  </head>
  <body>
    {% pyscript_component "components/counter.py" initial="Loading..." %}
  </body>
</html>
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
@component
def root():
    # `root` is the name the tag looks for by default; pass root="other" to
    # point it somewhere else. This file is inlined into the page at render
    # time and runs in the browser, so treat all of it as public.
    count, set_count = use_state(0)

    async def handle_count_click(event):
        set_count(count + 1)

    return html.div(
        html.h1(count),
        html.button({"onClick": handle_count_click}, "Add 1"),
    )
1
# TODO

There is no websocket, so use_connection, use_scope, and use_location have no connection to read and raise — see Use Connection.

Which one to use

{% component %} {% pyscript_setup %} + {% pyscript_component %}
Component runs Server Browser
Needs a websocket Yes No
Component source visible to users No Yes
First paint Server HTML, then it connects initial until PyScript boots
Can read the request Yes (use_connection, use_scope, use_location) No
Needs a route on your app Yes No
Packages must be Pyodide-compatible No Yes
Authors: Archmonger