Skip to content

Convert Between VDOM and HTML

Overview

ReactPy's UI is a plain dictionary. Two functions move it across the boundary to and from ordinary HTML text: reactpy_to_string() renders a VDOM dictionary or a component to an HTML string, and string_to_reactpy() parses an HTML string into a VDOM dictionary. Both live at the top of the package, and both are load-bearing inside ReactPy itself — the executors build the initial document and the PyScript head with them.

You will learn

  • How to turn VDOM into an HTML string, and what that conversion throws away
  • How to parse HTML text into a VDOM dictionary, and the renames it performs on the way in
  • Why className survives parsing but not serialization
  • When to reach for string_to_reactpy() instead of writing html.* calls

The two functions

Function In Out Raises on bad input
reactpy_to_string(root) VdomDict or a called Component str of HTML TypeError on a callable attribute
string_to_reactpy(html, *transforms, strict=True, intercept_links=True) str of HTML VdomDict HTMLParseError, ValueError, TypeError

Both are exported from the package root:

from reactpy import reactpy_to_string, string_to_reactpy

Neither is a serializer for the wire. The socket protocol has its own shape, described in Protocol Structure; these two functions exist so you can hand ReactPy's output to something that speaks HTML, and hand HTML from somewhere else to ReactPy.

VDOM to HTML: reactpy_to_string()

Give it an element, or a component that has already been called:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
todo_list = html.div(
    html.h1("My Todo List"),
    html.ul(
        html.li("Build a cool new app"),
        html.li("Share it with the world!"),
    ),
)

print(reactpy_to_string(todo_list))
# <div><h1>My Todo List</h1><ul><li>Build a cool new app</li><li>Share it with the world!</li></ul></div>
1
# TODO

There is no formatting option, no indent, no doctype. You get the tags and the attributes and nothing else.

What it renders

reactpy_to_string() walks tagName, attributes, and children (utils.py, _add_vdom_to_etree). Everything else in the dictionary is ignored:

  • Event handlers disappear. A {"onClick": handler} produces no attribute at all. The value is a Python function; there is nothing sensible to write into HTML text.
  • Inline JavaScript disappears too. inlineJavaScript is not consulted, so reactpy_to_string(html.button({"onClick": "this.innerText = 1"}, "x")) returns <button>x</button> with the script gone.
  • An importSource disappears. A web-module component renders as its bare tag name, which no browser knows what to do with.
  • A component child is rendered. _add_vdom_to_etree calls component_to_vdom() on any child with a .render attribute, and component_to_vdom() recurses, so nested components come out as HTML — verified two levels down, html.section(mid()) becomes <section><div><p>deep</p></div></section>. The limit is hooks, not depth.

That list is why this function cannot render your average app. It has no Layout, so there is no hook stack: use_state, use_ref, use_effect, use_location all raise RuntimeError: No life cycle hook is active. Are you rendering in a layout? Only hook-free components and plain html.* elements convert. This is the same path ReactPy(prepend_body=...) uses, which is why that argument takes an element and not a stateful component.

Passing an uncalled component raises AttributeError

reactpy_to_string(my_component) fails with AttributeError: 'function' object has no attribute 'render' — the decorator has not run, so there is nothing to render. Call it: reactpy_to_string(my_component()).

Attribute names are lowercased

This is the sharpest edge on the page, and it is silent. On the way out, every attribute name goes through .lower() (utils.py, _react_attribute_to_html), with the exception of data-* and aria-*, which are left exactly as you wrote them:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# HTML in, ReactPy out: camelCase comes back.
print(string_to_reactpy('<label for="email">Email</label>'))
# {'attributes': {'htmlFor': 'email'}, 'children': ['Email'], 'tagName': 'label'}

# ReactPy out: every attribute name is lowercased on the way out.
print(reactpy_to_string(html.label({"htmlFor": "email"}, "Email")))
# <label htmlfor="email">Email</label>

print(reactpy_to_string(html.label({"for": "email"}, "Email")))
# <label for="email">Email</label>

print(reactpy_to_string(html.div({"className": "card"}, "x")))
# <div classname="card">x</div>
1
# TODO

htmlFor becomes htmlfor and className becomes classname — neither of which is a real HTML attribute, so the browser ignores both. The label stops labelling and the class stops styling, with no error anywhere.

Write the HTML name when the string is the destination. {"for": "email"} and {"class": "card"} produce correct markup; {"htmlFor": ...} and {"className": ...} do not. There is no single spelling that satisfies both this and live rendering — see Common Props for why className is what the browser-side path wants.

The one attribute ReactPy does translate is style, which it flattens from a dict into a key:value; string and kebab-cases on the way:

reactpy_to_string(html.div({"style": {"fontSize": "20px", "zIndex": 3}}, "s"))
# <div style="font-size:20px;z-index:3">s</div>

Note what did not happen to the 3: no unit is inferred. z-index:3 is fine because it takes no unit, but font-size:30 would be invalid CSS where the browser-side renderer would have applied it as 30px. Always put the unit in the string.

Some boolean attributes obey lxml, not HTML

ReactPy renders the string through lxml, which carries its own list of known boolean attributes. For a name on that list the value is discarded and only presence matters — so False still emits the attribute:

1
2
3
4
5
6
7
8
# lxml knows a fixed list of boolean attributes. For those, the value is
# dropped and presence is all that survives -- False included.
print(reactpy_to_string(html.input({"type": "checkbox", "checked": False})))
# <input type="checkbox" checked>

# For everything else the value is str()-ed, so False leaks into the page.
print(reactpy_to_string(html.div({"hidden": False}, "still hidden")))
# <div hidden="False">still hidden</div>
1
# TODO

checked renders as <input type="checkbox" checked> even though you said False. Names outside that list get str(value) instead, which leaks the word False into your markup as in <div hidden="False">. Which names fall in which group depends on your lxml build, so test the attribute you care about. The safe form for a server-rendered string is to omit the key entirely rather than set it to False.

HTML to VDOM: string_to_reactpy()

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
model = string_to_reactpy('<div class="card"><h1>My Todo List</h1></div>')

print(model["tagName"])  # div
print(model["attributes"])  # {'className': 'card'}
print(model["children"])  # [{'tagName': 'h1', 'children': ['My Todo List']}]

# Nested children are full models too, which is easiest to see with pprint:
# pprint(model, sort_dicts=False)
# {'children': [{'children': ['My Todo List'], 'tagName': 'h1'}],
#  'attributes': {'className': 'card'},
#  'tagName': 'div'}
1
# TODO

Parsing is done by lxml.html, then each node is rebuilt through the normal html.* constructors (utils.py, _etree_to_vdom), which means what comes back is a genuine VdomDict you can render, patch, and send over the socket.

What parsing renames and rewrites

The parser does not give you a mirror of the input. It runs a table of renames and a handful of node rewrites (transforms.py, attributes_to_reactjs and RequiredTransforms):

In the HTML In the VDOM Why
class="card" {"className": "card"} React-style prop name
for="email" {"htmlFor": "email"} React-style prop name
checked {"defaultChecked": ...} Controlled-vs-uncontrolled
accept-charset {"acceptCharset": ...} React-style prop name
http-equiv {"httpEquiv": ...} React-style prop name
style="font-size:20px" {"style": {"fontSize": "20px"}} Style strings become dicts
<input value="ab"> {"defaultValue": "ab"} A value prop makes an input controlled
<textarea>hi</textarea> {"defaultValue": "hi"}, no children Same reason
<option selected> defaultValue on the parent <select> React puts the selection on the parent
<a href="/x"> an onClick handler with prevent_default Keeps a client router in charge
<div id="uniq"> {"id": "uniq", "key": "uniq"} A key is inferred from id, then name

The last two deserve a second look, because they change behaviour rather than spelling.

Every anchor gets a click handler. By default intercept_links=True installs a preventDefault-only onClick on every <a> (transforms.py, intercept_link_clicks). The link looks dead: clicking it does nothing, because ReactPy has no router to hand the navigation to. Pass intercept_links=False to keep real links.

An id becomes a key. infer_key_from_attributes copies id — or name on <input>, <select>, <textarea> — into attributes["key"]. That is usually what you want, since a stable id is a stable identity, but it also means two elements parsed with the same id collide: the layout rejects them with ValueError: Duplicate keys [...].

Whitespace you did not ask for

lxml is configured to drop comments, processing instructions, and blank text — but remove_blank_text only removes whitespace that is purely formatting. The newline and indentation between tags in a pretty-printed document survive as text children:

string_to_reactpy("<ul>\n  <li>a</li>\n  <li>b</li>\n</ul>")["children"]
# [{'tagName': 'li', 'children': ['a']}, '\n  ', {'tagName': 'li', 'children': ['b']}, '\n']

Those strings render as visible whitespace. If you parse an indented template literal, expect stray spaces and newlines in the layout — write the markup flat, or strip the text children in a transform.

Errors, and when to turn strictness off

string_to_reactpy() raises in four distinct ways:

string_to_reactpy(12)          # TypeError: Expected html to be a string, not int
string_to_reactpy("no tags here")  # ValueError: Expected html string to contain HTML tags, but no tags were found.
string_to_reactpy("<div <p>x</p>")  # HTMLParseError (a subclass of lxml.LxmlSyntaxError)
string_to_reactpy("<div <p>x</p>", strict=False)  # parses; '<p' becomes an attribute name

strict=True reaches lxml as recover=False, so a document lxml refuses to parse cleanly becomes an HTMLParseError whose message tells you to turn strictness off. Do that deliberately: recovery invents attribute names out of the malformed text rather than complaining, so you trade a loud failure for a quietly wrong model.

A few things you might expect strict to catch, and it does not:

  • Unclosed or mismatched tags — <div><span>x</div> parses fine and the span is closed for you.
  • Orphan fragments — <td>orphan</td> and <li>x</li> parse into bare td/li models, which then render somewhere HTML forbids them.
  • Unknown entities — &notreal; becomes the literal text ¬real;.

Wrapping, empty input, and multiple roots

lxml wants a single well-formed root, so it adds one for you:

  • <p>a</p><p>b</p> comes back wrapped in a div.
  • <body>hi</body> comes back as a span, not a body — lxml.html's document scaffolding leaks through.
  • <meta http-equiv="refresh"> is likewise pulled into an html/head pair.
  • Empty or whitespace-only input returns {"tagName": ""} — a fragment, not an error.

Do not write code that indexes children by position on parsed input; the wrapper is lxml's decision, not yours.

Transforming every node: the *transforms argument

string_to_reactpy() takes any number of one-argument callables and applies each to every node after parsing (utils.py, _etree_to_vdom). This is the supported way to post-process the model rather than editing it after the fact:

def add_card_class(vdom):
    if vdom["tagName"] == "div":
        vdom.setdefault("attributes", {})["className"] = "card"
    return vdom


string_to_reactpy("<div>x</div>", add_card_class)
# {'tagName': 'div', 'attributes': {'className': 'card'}, 'children': ['x']}

Mutate and return the same dictionary; the return value replaces the node, assigned unconditionally (utils.py, _etree_to_vdom). A transform that returns None deletes the node — for a child it leaves None in children, for the root it makes string_to_reactpy() itself return None. Always return the model.

Transforms run after the built-in ones, so a style value is already a dict by the time yours sees it, and an <a> already has its onClick. Use this hook to strip attributes you do not trust from third-party HTML — but note that removing an attribute is not the same as making untrusted HTML safe:

Never parse untrusted HTML without filtering

string_to_reactpy() does not sanitize. An <img src=x onerror="alert(1)"> comes back with the payload in inlineJavaScript, not in attributes — verified: {'attributes': {'src': 'x'}, 'inlineJavaScript': {'onError': 'alert(1)'}, 'tagName': 'img'} — and that is the more dangerous of the two outcomes: the client turns each inlineJavaScript entry into an event-handler prop whose body is eval(...) (static/index.js, the inline-JavaScript wrapper), so the code executes when the event fires. An <img src=x> fails to load, onError fires, and the payload runs. An <script> comes back as a script element with its content intact. ReactPy's own html.script refuses event handlers and injects an inferred key, but it does not inspect the JavaScript inside, and neither guard applies to what string_to_reactpy() produces. If the string came from a user, run it through a sanitizer first and pass intercept_links=False only once you have decided links are wanted.

Using the result in a component

A parsed model is an ordinary element, so a component can return it directly:

1
2
3
4
5
6
7
@component
def card():
    return string_to_reactpy('<div class="card"><b>rendered from a string</b></div>')


print(reactpy_to_string(card()))
# <div class="card"><b>rendered from a string</b></div>
1
# TODO

That is exactly how ReactPy builds its own document head: ReactPy(pyscript_setup=True) parses a block of PyScript markup with string_to_reactpy() and splices it into html_head (executors/asgi/standalone.py).

Do not parse inside the render body

The parse is a full lxml document build, and it runs on the server on every render. Parse once at module level and return the stored model, or memoize it with use_memo. Re-parsing per render is pure waste, and if you put the model in state you also hand yourself the mutation hazard covered in VDOM Mutations.

Choosing a direction

Reach for these when the HTML exists somewhere other than in your Python code:

  • string_to_reactpy() when markup arrives from a CMS, a markdown renderer, an email body, or a template you do not control. It is also the shortest path to pasting a snippet of HTML from a design mock into a component.
  • reactpy_to_string() when you must hand ReactPy's output to something that is not the ReactPy client: server-rendered <head> content, an <noscript> fallback, an email or PDF generator, a test assertion.

If the markup is yours to write, write html.* calls. The constructors validate at the call site, keep their arguments JSON-serializable, and support handlers — none of which the string path gives you.

Summary

  • reactpy_to_string() takes a VdomDict or a called component and returns HTML text; it drops event handlers, inline JavaScript, and import sources, and can only render components that use no hooks.
  • It lowercases every attribute name except data-* and aria-*, so write "class" and "for" when the string is the destination.
  • string_to_reactpy() parses HTML into a real VdomDict, renaming to React-style prop names, converting style strings to dicts, and moving value/<textarea> content to defaultValue.
  • It intercepts link clicks and infers a key from id unless you tell it not to.
  • strict=False turns a loud HTMLParseError into a guessed model; lxml never complained about unclosed tags anyway.
  • Neither function is the wire format — that is Protocol Structure.
Authors: Archmonger