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
classNamesurvives parsing but not serialization - When to reach for
string_to_reactpy()instead of writinghtml.*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 | |
1 | |
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.
inlineJavaScriptis not consulted, soreactpy_to_string(html.button({"onClick": "this.innerText = 1"}, "x"))returns<button>x</button>with the script gone. - An
importSourcedisappears. 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_etreecallscomponent_to_vdom()on any child with a.renderattribute, andcomponent_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 | |
1 | |
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 | |
1 | |
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 | |
1 | |
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 thespanis closed for you. - Orphan fragments —
<td>orphan</td>and<li>x</li>parse into baretd/limodels, which then render somewhere HTML forbids them. - Unknown entities —
¬real;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 adiv.<body>hi</body>comes back as aspan, not abody—lxml.html's document scaffolding leaks through.<meta http-equiv="refresh">is likewise pulled into anhtml/headpair.- 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 | |
1 | |
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 aVdomDictor 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-*andaria-*, so write"class"and"for"when the string is the destination. string_to_reactpy()parses HTML into a realVdomDict, renaming to React-style prop names, convertingstylestrings to dicts, and movingvalue/<textarea>content todefaultValue.- It intercepts link clicks and infers a
keyfromidunless you tell it not to. strict=Falseturns a loudHTMLParseErrorinto a guessed model;lxmlnever complained about unclosed tags anyway.- Neither function is the wire format — that is Protocol Structure.
Related¶
- Creating HTML Tags — building the same models with
htmlandVdom - VDOM Mutations — what you may and may not do to a model after you get one
- Creating VDOM Event Handlers — the
eventHandlersslot these functions ignore - Common Props — attribute naming on the live rendering path
- Protocol Structure — what actually goes over the socket