Skip to content

Creating HTML Tags

Overview

Every element you write in ReactPy is a function call that returns a dictionary. html.div(...) is not a keyword, a macro, or a parser — it is an attribute lookup that manufactures a constructor on first use, and a __call__ that assembles the model. Knowing how that works explains why there is no allow-list to fight, why html.del_ means <del>, and where the handful of hard errors come from.

You will learn

  • How html turns an attribute name into a tag name
  • The rule that decides whether your first argument is attributes or a child
  • Which elements reject children, and why html.script is stricter still
  • How to build fragments, SVG, and tag names that are not valid Python identifiers

html manufactures tags on demand

html is an instance of HtmlConstructor with a single __getattr__ (_html.py). Ask it for anything and you get a Vdom constructor back, cached by name:

1
2
3
4
5
6
# The tag name is whatever you ask for -- there is no allow-list to consult.
print(html.div({"id": "greeting"}, "Hello"))
# {'tagName': 'div', 'attributes': {'id': 'greeting'}, 'children': ['Hello']}

print(html.definitely_not_a_real_tag("still renders"))
# {'tagName': 'definitely-not-a-real-tag', 'children': ['still renders']}
1
# TODO

There is no allow-list. ReactPy does not know or care whether <definitely-not-a-real-tag> exists; the string travels to the browser and the browser decides. The names spelled out on HtmlConstructor exist only so your editor can autocomplete them — the comment in the source says as much. Two practical consequences:

  • A misspelled tag renders as an unknown element with your text inside it, silently. Nothing in ReactPy will tell you that html.hdeader is wrong.
  • Custom elements work with no ceremony: html.my_widget(...) produces {"tagName": "my-widget"} and the browser hands it to whatever registered that name.

The underscore rules

__getattr__ applies exactly two transformations — value.rstrip("_").replace("_", "-") — which is how you write names Python will not let you spell:

1
2
3
4
5
6
7
8
# Underscores become dashes, and a trailing underscore is stripped -- the name
# is translated, not looked up.
print(html.del_("removed")["tagName"])  # del
print(html.data_table()["tagName"])  # data-table
print(html.my_widget()["tagName"])  # my-widget

# The same constructor comes back every time, so identity checks are cheap.
print(html.my_widget is html.my_widget)  # True
1
# TODO

Read the rules literally, because they are applied blindly:

You write Tag name Rule
html.del_ del trailing _ stripped (a keyword)
html.data_table data-table every _ becomes -
html.my_widget my-widget same
html.h1 h1 no underscore, nothing changes
html._private -private leading underscore is not special
html.a__b a--b doubles are not collapsed

Casing is passed through untouched, so html.myWidget gives you myWidget. Because translation happens before the cache lookup, html.data_table is html.data_table holds — but two spellings that translate to the same tag are the same object, and two that do not are different elements.

If a name cannot be written as an attribute at all — a leading digit, or one you are building from a variable — construct it directly:

from reactpy import Vdom

tag = Vdom("2fa-code")
tag("123456")  # {'tagName': '2fa-code', 'children': ['123456']}

Vdom(tag_name, /, allow_children=True, custom_constructor=None, import_source=None) is the same class every html.* name is built from, so you get identical behaviour. It is exported from the package root. Reach for it only for generated or unspellable names; anything else should go through html so it reads consistently.

html is not a namespace of plain functions

html.div is a Vdom object, not a function — html.div("x") works because Vdom.__call__ is the constructor. So html.div.__name__ is the tag name rather than a qualified name, and any name you ask for is fair game: html.allow_children("x") returns a perfectly valid {"tagName": "allow-children"} element rather than tripping over the Vdom attribute of the same name.

The two call shapes

Vdom.__call__ accepts either children only, or an attribute dictionary followed by children (core/vdom.py, Vdom.__call__):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# Two call shapes: children only, or an attribute dict first.
print(html.button("Click Me"))
# {'tagName': 'button', 'children': ['Click Me']}

print(html.button({"id": "save", "disabled": True}, "Save"))
# {'tagName': 'button', 'attributes': {'id': 'save', 'disabled': True}, 'children': ['Save']}

# Only an *exact* dict counts as attributes -- a dict subclass is a child.
print(html.div(VdomDict(tagName="span")))
# {'tagName': 'div', 'children': [{'tagName': 'span'}]}
1
# TODO

The discriminator is exact-type, not isinstance: type(values[0]) is dict (core/vdom.py, separate_attributes_and_children). Two consequences that bite:

  • A VdomDict — which is a dict subclass — in first position is treated as a child, which is what you want when nesting models you built earlier.
  • Any other dict subclass in first position is also a child, so a custom mapping you meant as attributes becomes a rendered object instead. Pass a plain dict literal for attributes.

A dict in any later position is always a child. There is one slot for attributes and it is the first.

Attribute values are forwarded verbatim

ReactPy renames nothing. className, class, htmlFor, and for all reach the DOM as whichever string you typed, and unknown names like data-* and aria-* pass straight through. Nothing validates names against a list, so a typo is a silent no-op — {"class_name": "card"} renders <div class_name="card">, an attribute nobody reads.

There are no exceptions to this rule on the live path. class_name is not a thing. See Common Props for the full picture, including the one case where numeric style values behave differently between executors.

Attributes must be JSON-serializable, but you may not be told

Everything in the attribute dict is JSON-encoded on its way to the browser. ReactPy checks that with json.dumps only when REACTPY_CHECK_JSON_ATTRS is on — and that option's parent is REACTPY_DEBUG, so turning debug on turns this on too:

from reactpy import config

config.REACTPY_DEBUG.current = True

With the check on, html.div({"data": object()}) raises TypeError: Object of type object is not JSON serializable at the tag call, pointing at your line. With it off, the model is accepted and the failure surfaces much later, inside the websocket send, as an orjson error in a server log. If a page renders nothing and the browser console is empty, this is why.

Fragments

html.fragment(...) and the shorter html(...) build a child list with no element of its own — tagName is the empty string, and the client renders it as nothing:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# Fragments carry no tag of their own: tagName is the empty string.
print(html.fragment(html.b("bold"), html.i("italic")))
# {'tagName': '', 'children': [{'tagName': 'b', ...}, {'tagName': 'i', ...}]}

# html(...) is html itself: HtmlConstructor.__call__ is the fragment call.
print(html(html.b("bold"))["tagName"])  # ''
print(html()["tagName"])  # '' -- an empty fragment

# A fragment may carry a key -- and nothing else.
print(html.fragment({"key": "row-1"}, html.b("x"))["attributes"])  # {'key': 'row-1'}

try:
    html.fragment({"className": "wrap"}, html.b("x"))
except TypeError as error:
    print(error)  # Fragments cannot have attributes besides 'key'
1
# TODO

A fragment accepts key and nothing else: a className, a style, or an event handler all raise TypeError: Fragments cannot have attributes besides 'key'. There is no node to put them on, so ReactPy refuses rather than dropping them quietly.

Use a fragment when a component must return siblings and a wrapper <div> would break the layout or the CSS. When the children are already in a list, pass the list — you do not need a fragment for that, though you do need keys, covered in Rendering Lists.

Void elements

html.br("x") is a hard error, not a warning that scrolls past:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
# Void elements reject children -- loudly, at the call site.
try:
    html.br("oops")
except TypeError as error:
    print(error)  # 'br' nodes cannot have children.

print(html.br())  # {'tagName': 'br'}

# script is stricter still: one string child, no handlers, an inferred key.
print(html.script("var answered = 42;"))
# {'tagName': 'script', 'children': ['var answered = 42;'],
#  'attributes': {'key': 'var answered = 42;'}}

print(html.script({"src": "/chart.js"}))
# {'tagName': 'script', 'attributes': {'src': '/chart.js', 'key': '/chart.js'}}

try:
    html.script({"onLoad": lambda event: None}, "var x = 1;")
except ValueError as error:
    print(error)  # 'script' elements do not support event handlers
1
# TODO

The rejecting set is NO_CHILDREN_ALLOWED_HTML_BODY (_html.py): area, base, br, col, command, embed, hr, img, input, iframe, keygen, link, meta, param, portal, source, track, wbr. The check happens after the model is built, so the message names the tag rather than your mistake — read the name in the error.

iframe is on that list, which surprises people who put fallback content inside one. ReactPy will not let you, and the browser-side renderer would not have shown it anyway.

html.script is its own thing

html.script uses a custom constructor (_html.py, _script) with three rules and a side effect:

  • At most one child, and it must be a str — ValueError: 'script' nodes may have, at most, one child. and The child of a 'script' must be a string.
  • No event handlers at all — ValueError: 'script' elements do not support event handlers. Attach behaviour to a sibling element instead, or use the inline-JavaScript handler form on a normal tag (Creating VDOM Event Handlers).
  • A key is injected from the script's content, or from its src if it has no content. Upstream documents the purpose as allowing the script "to be run multiple times if its key changes", browser permitting. Pass your own key to control re-execution yourself.

Scripts are an injection surface

The child string reaches the browser as JavaScript. Never interpolate untrusted text into it — ReactPy's own docstring on _script says so, and there is no escaping step to save you. Build the string from your own constants, or pass a src you control.

SVG is a second constructor

html.svg is a separate SvgConstructor, so SVG element names live in their own namespace and cannot collide with HTML names. Children come off it:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# SVG lives on a second constructor, so html.svg.circle() does not collide
# with a hypothetical html.circle().
print(
    html.svg(
        {"width": "100", "height": "100", "viewBox": "0 0 100 100"},
        html.svg.circle({"cx": "50", "cy": "50", "r": "40", "fill": "tomato"}),
    )
)
# {'tagName': 'svg', 'attributes': {...},
#  'children': [{'tagName': 'circle', 'attributes': {...}}]}
1
# TODO

It has its own void set (NO_CHILDREN_ALLOWED_SVG), and that set is broader and less intuitive than the HTML one — it includes text, tspan, title, desc, path, use, and foreignObject. So the element you most need children inside is exactly the one that rejects them:

html.svg.text("hello")  # TypeError: 'text' nodes cannot have children.

Build those nodes with Vdom, which shares all the behaviour minus the void check:

from reactpy import Vdom

Vdom("text")("hello")  # {'tagName': 'text', 'children': ['hello']}

The bundled client creates every element with document.createElementNS(namespace, ...) and picks the namespace from the tag name — static/index-bz53xswt.js sets Q = "http://www.w3.org/2000/svg" when the type is "svg" and the MathML URI for "math". Inside the SVG namespace attribute names are forwarded verbatim rather than lower-cased, so viewBox keeps its capital B — write it that way, since viewbox is a different attribute to the DOM.

Choosing children over attributes

html.div({"children": ["a", "b"]}) does not do what it looks like. The dict is attributes, so you get an attribute literally named children and no children at all. Children are always positional arguments after the attribute dict — that is the only way to supply them:

html.div({"id": "list"}, html.li("one"), html.li("two"))   # correct
html.ul({"children": [html.li("one")]})                     # an attribute named children

Children are flattened fully, not one level at a time, for any iterable that is not a string or a mapping — core/vdom.py, _flatten_children recurses, and verified html.div([[["a"], "b"]]) yields children: ['a', 'b']. A list or tuple comprehension therefore works at any nesting depth, and None is skipped by the layout. Two rules from there are worth internalizing now, because both failures are quiet:

  • Elements inside a list need keys, in debug mode, or you get Key not specified for child in list ....
  • A generator in place of a list skips that check entirely, with Did not verify key-path integrity of children in generator .... Pass a list. The deeper hazard — storing an iterator in state — is covered in Rendering Lists.

Summary

  • html invents a tag for any attribute name; the two rules are trailing _ stripped and every _ turned into -. There is no allow-list and no warning for a typo.
  • The first argument is attributes only if it is exactly a dict; everything else, including VdomDict and dict subclasses, is a child.
  • Attribute names are forwarded verbatim — use the DOM's spelling. Turn on REACTPY_DEBUG to catch un-serializable attribute values at the call site.
  • html.fragment and html() take only a key.
  • The void elements in NO_CHILDREN_ALLOWED_HTML_BODY reject children with a TypeError; html.script additionally allows one string child, forbids handlers, and infers its own key.
  • html.svg.* uses a broader void set that includes text; build those with Vdom.
  • Vdom(tag_name) is the class behind every html.* name — use it for tag names you cannot spell as an attribute, and for the SVG nodes html.svg rejects.
Authors: Archmonger