Composing components¶
Any capitalized tag is a child component. PyWeft resolves <TodoItem> to
TodoItem.weft or todo_item.weft in the same directory as the file that
uses it, with no import boilerplate.
Resolution is recursive (children can have children) and a circular reference
is reported as an error rather than looping.
Components in folders. Put a folder name in front of the tag, with a dot, the way you would write a Python module path:
| Tag | File |
|---|---|
<TodoItem /> |
todo_item.weft next to the file using the tag |
<widgets.Row /> |
widgets/row.weft |
<forms.inputs.TextField /> |
forms/inputs/text_field.weft |
A dotted tag is always looked up from the app root (the folder holding
app.weft), no matter which file uses it, so <shared.Icon /> means the same
file in app.weft and in forms/inputs/text_field.weft. A plain tag inside a
folder finds its neighbours in that folder: forms/field.weft can use
<Label /> for forms/label.weft.
myapp/
app.weft
widgets/
row.weft <widgets.Row />
forms/
field.weft <forms.Field />, which can use <Label />
label.weft
shared/
icon.weft <shared.Icon />, usable from anywhere
Folder names must be valid Python identifiers (letters, digits, underscores);
only the last part of the tag is capitalized. Two components may share a file
name in different folders; their <style> blocks stay separate.
A complete two-file example, taken from examples/todo/:
<!-- todo_item.weft — the child -->
<template>
<li class="todo-item">
<span :class="'done' if item['done'] else None" @click="emit('toggle', item)">
{{ item['text'] }}
</span>
<button class="remove" @click="emit('remove', item)">✕</button>
</li>
</template>
<code>
item: dict = {}
</code>
<!-- app.weft — the parent -->
<template>
<ul>
<For each="item in items" key="item['id']">
<TodoItem :item="item" @toggle="toggle(event)" @remove="remove(event)" />
</For>
</ul>
</template>
<code>
items: list = []
def toggle(self, item):
item["done"] = not item["done"]
def remove(self, item):
self.items = [i for i in self.items if i["id"] != item["id"]]
</code>
Both files live in the app directory; pyweft run picks them up with no
registration step. The rest of this section explains each piece.
Props go down. Use plain attributes for strings, :attr for typed values:
In the child, props are just state fields that the parent overwrites on every render — declare them with defaults:
Events: emitting and listening¶
There are two kinds of @ events, and they are handled differently:
@clickon a plain element (<button @click="cheer">) is a DOM event. The browser fires it and the component whose template contains the element handles it.@removeon a component tag (<TodoItem @remove="...">) is a custom event. Nothing in the browser fires it — the child raises it by callingemit, and the parent listens.
Emitting, in the child. Call emit(name, payload) from a template
expression or from a method. The payload is optional and can be any Python
value; falsy values like 0, "" and False pass through unchanged.
<!-- todo_item.weft -->
<button @click="emit('remove', item)">✕</button>
<button @click="save">save</button>
def save(self):
...
self.emit("saved") # no payload
self.emit("changed", {"id": self.item["id"], "text": self.draft})
Listening, in the parent. Put @<name> on the child's tag. The value is
evaluated in the parent's scope, with the payload available as event:
<For each="item in items" key="item['id']">
<TodoItem :item="item"
@remove="remove(event)"
@changed="update(item, event)"
@saved="mark_saved" />
</For>
@remove="remove(event)"— any expression works, so you can mix loop variables with the payload, as@changeddoes.@saved="mark_saved"— a bare method name is called directly. It receives the payload if it declares a parameter, and nothing otherwise.
After the parent's handler runs the parent re-renders, so any state it changed flows back down to the child as fresh props in the same cycle.
How it connects. @remove="..." on the tag becomes an on_remove
callback set on the child as a prop, and self.emit("remove", x) simply looks
up on_remove and calls it. Consequences worth knowing:
- Emitting an event the parent did not subscribe to is a no-op.
emitreturns whatever the parent's handler returned (Noneif nobody listened), so a child can ask the parent for a decision.- Use lowercase identifier-style event names (
item_removed, notitemRemovedoritem-removed) so theon_prop name stays a valid Python attribute.
State stays put. A child keeps its internal state across parent
re-renders. Inside a <For>, the loop's key also keys the child instances,
so their state follows the data when rows reorder.
Lifecycle: a child's on_init runs the first time a render produces it,
and its on_dispose when a render no longer does (its <If> went false, its
row left the list). A parent's on_init runs before its children's, since
its render is what creates them. See
lifecycle for all of them.
Overriding the file lookup. A components dict in the code block wins
over the file conventions, which is how you give a component a different tag
name or use one from outside the app folder:
A relative path here is relative to the .weft file that contains it, not to
the folder you started the app from, so it works the same in the browser and
in tests.
From plain Python, component_from_source(src, components={...}) accepts the
same dict.
Passing content: slots¶
A component can take markup between its tags. It marks where that content
goes with <Slot />:
<!-- card.weft -->
<template>
<section class="card">
<h3><Slot name="title">Untitled</Slot></h3>
<div class="body"><Slot /></div>
<If cond="has_slot('footer')"><footer><Slot name="footer" /></footer></If>
</section>
</template>
<!-- using it -->
<Card>
<Fill name="title">Sign up</Fill>
<p>Hello {{ name }}</p>
<Fill name="footer"><button @click="save">Save</button></Fill>
</Card>
- Content outside any
<Fill>goes to the unnamed<Slot />.<Fill name="...">targets a named one. - The slot's own children are the fallback when nothing is passed
("Untitled" above).
has_slot("footer")tells the component whether content was passed, so it can leave out the wrapper when there is none. - Slot content belongs to the component that wrote it.
{{ name }}and@click="save"above are the using component's state and method, including loop variables when the<Card>sits inside a<For>, and its scoped styles apply to that content. - Slots can hand data to the content (handy for lists and tables). Props
on
<Slot>become variables inside the content:
<!-- data_list.weft -->
<ul><For each="item in items" key="item" index="i">
<li><Slot :item="item" :index="i">{{ item }}</Slot></li>
</For></ul>
- Mistakes are errors, not silence: content for a slot the component
doesn't have, two
<Fill>s with one name, or a<Fill>outside a component tag all fail with a message saying what's wrong.
@bind on your own components¶
@bind works on a component tag too, so a form control you write is used
exactly like an <input>:
The component receives the field as its value prop and reports a new value
with emit("update", new_value):
<!-- text_field.weft -->
<template>
<label>{{ label }}
<input :value="value" @input="emit('update', event.value)" />
</label>
</template>
<code>
label: str = ""
value: str = ""
</code>
The using component's field is updated, then its own @update handler runs
if it has one. @bind modifiers (.lazy and so on) apply to plain inputs
only; inside your component, pick the event you emit on.
Attributes on a component tag¶
Attributes a component declares as fields are props: variant and
disabled below set those fields. Any other attribute goes onto the
component's root element, so styling and accessibility attributes just work:
- Plain values are converted to the field's type, so attributes read
naturally: with
cols: int = 0,cols="3"sets the number 3; withdisabled: bool = False,disabled="true"(ordisabled="") setsTrueanddisabled="false"setsFalse. A value that doesn't fit, such ascols="three", is a template error naming the field. classandstyleare added to the root element's own; other attributes replace them.:attr="expr"works the same way: a prop if declared (passed as is, no conversion), otherwise an attribute (None/Falseleave it out,Truewrites it without a value).- The component must render a single root element to receive them; if it renders several, you get an error naming the attribute.
- Events (
@click) are never passed through: the component decides what it emits, typically with@click="emit('click', event)"on its button.
Components from packages¶
A component class can come from an import, and the tag follows the name you imported it under:
# in the <code> block
from pyweft_ui import ui # then <ui.Button>, <ui.Card> ...
from reports import ChartPanel # then <ChartPanel>
Imported names win over files of the same name next to your component.
Writing a component library takes one line in its __init__.py:
# my_widgets/__init__.py
from pathlib import Path
from pyweft import ComponentLibrary
ui = ComponentLibrary(Path(__file__).parent, prefix="ui")
ui.Button then loads button.weft (or Button.weft) from the package
folder on first use. Inside the library, components use each other as usual.
The prefix keeps the library's scoped styles apart from the app's, so a
library .card and an app .card never collide.
Using a library in an app: add it to the app's pyproject.toml like any
dependency (uv add my-widgets, or edit dependencies). How it reaches the
browser:
- If it is installed where you run
pyweft run(published, a local path, or an editable install while you develop it), PyWeft ships it from there:runserves it andbuildcopies it intodist/lib/. The browser gets exactly the version you have, and an unpublished library works. Editing its files reloads the page. Its own dependencies are installed in the browser from PyPI as usual. - Otherwise it is installed from PyPI in the browser like any package,
which works once it is published as a wheel that includes its
.weftfiles (hatchling and uv's build backend include them by default).
A dependency counts as a component library when its package folder contains
.weft files; everything else is an ordinary package.
Sharing state between components: Store¶
Some state belongs to the whole app rather than to one component: a cart
that the header counts and every page adds to, the signed-in user, settings.
Put it in a store, a class in one of your app's .py modules:
# cart.py, next to app.weft
from pyweft import Store
class Cart(Store):
items: list = []
@property
def count(self):
return len(self.items)
def add(self, product):
self.items.append(product)
def clear(self):
self.items = []
cart = Cart()
Any component imports it and uses it like its own state:
<!-- header.weft -->
<template>
<header>Cart ({{ cart.count }})</header>
</template>
<code>
from cart import cart
</code>
<!-- pages/product.weft -->
<template>
<button @click="cart.add(name)">Add {{ name }} to cart</button>
</template>
<code>
from cart import cart
name: str = "Teapot"
</code>
- Everything showing it updates. Clicking the button changes the cart, and the header's count follows, though neither component knows the other.
- Changes from anywhere work: a timer, an async task, a JavaScript
callback. Setting a field (
cart.items = [...]) or calling one of the store's methods (cart.add(...)) re-renders the page; anasyncmethod does it when it finishes. Inside a handler you don't notice the difference, since the page renders after every handler anyway. - Change it through its methods when you change something in place.
cart.items.append(x)from a timer, outside the store, isn't seen; callcart.changed()after it, or better, give the store a method. - Computed values are
@propertys, likecount. Methods are for changes. - Fields work like a component's: declare them with a default, and a
mutable default (
items: list = []) is copied for each store you create.Cart(items=["mug"])starts with values of your own. - Effects see stores too: an
@effectthat readsself.cart(or reads it through a property) runs again when the cart changes. - One store object, everywhere. It lives in a module, so every
component that imports it gets the same object, and it keeps its contents
while you navigate between pages and through hot reloads of
.weftfiles (editing the.pyfile itself reloads the page).
A store is the right place for state that outlives one component. For state
that only a component and its children use, props and emit stay simpler.
Choosing a component while the app runs: Dynamic¶
When which component to show is decided by your state, write <Dynamic>
and give it the class with :is:
from pyweft import load_component
EDITORS = {"circle": load_component("circle_editor.weft"),
"square": load_component("square_editor.weft")}
shape: str = "circle"
- Switching the class replaces the component: the old one's
on_disposeruns, and the new one starts fresh withon_init. :is="None"shows nothing.- Attributes, events,
@bindand slot content work as on any component tag.:props="mapping"passes props by name too, for when which ones apply depends on the class; attributes written out win over it. - A
key="..."on it makes a new key mean a new instance even when the class stays the same.
Pages and navigation¶
An app with several screens keeps them in a pages/ folder, one .weft file
per page, and shows the current one with a router:
myapp/
app.weft
pages/
index.weft /
orders.weft /orders
order.weft /orders/7 (its route says so, below)
settings/
user_profile.weft /settings/user-profile
not_found.weft any address without a page
<!-- app.weft -->
<template>
<div>
<nav>
<Link to="/">Home</Link>
<Link to="/orders">Orders</Link>
</nav>
<main><Pages /></main>
</div>
</template>
<code>
from pyweft import Link, router
Pages = router("pages")
</code>
<Pages /> shows the page for the current address; everything around it
(the nav here) stays put. The address is the part after # in the browser
(http://localhost:8642/#/orders), so Back, Forward, bookmarks and reloading
all work, and a built app needs no special server setup.
URLs come from file names. index.weft is its folder's address, a
folder adds a segment, and _ in a name becomes -. Files and folders
starting with _ are not pages. Keep components that pages share somewhere
else, such as next to app.weft or in a components/ folder (used as
<components.OrderRow>), since every file in pages/ is a page.
Values from the address arrive as fields. A page can set its own
address with route, with {name} for a part that varies:
<!-- pages/order.weft -->
<template>
<section>
<h1>Order #{{ id }}</h1>
<If cond="order is None"><p>Loading…</p></If>
<Else><p>{{ order['customer'] }}</p></Else>
</section>
</template>
<code>
route = "/orders/{id}"
id: int = 0
order = None
async def on_init(self):
self.order = await fetch_order(self.id)
</code>
id is converted to the type the page declares, so /orders/7 gives the
int 7 and /orders/abc is not this page at all (it shows not_found).
Query parameters fill fields too: /orders?status=paid sets the orders
page's status: str = "all" field to "paid". When two routes could match,
the fixed one wins: pages/orders/new.weft takes /orders/new before
/orders/{id} sees it.
Every address is a fresh page. Going from /orders/7 to /orders/8, or
from ?status=paid to ?status=pending, creates a new page component, so
on_init loads its data again and fields not in the address start at their
defaults.
Links: <Link to="/orders"> is a link (<a href="#/orders">) that gets
class="active" and aria-current="page" while its page, or one below it,
is showing; add exact="true" to match only that address. Ctrl- and
Cmd-clicks open a new tab as usual. A plain <a href="#/orders"> works too,
just without the active marking.
From code:
from pyweft import navigate
from pyweft.routing import back, location
navigate("/orders/7") # go there
navigate("/orders", query={"status": "paid"}) # /orders?status=paid
navigate("/login", replace=True) # Back won't return here
back() # the Back button
location().path, location().query # "/orders", {"status": "paid"}
Calling navigate() in a page's on_init redirects: the new page replaces
it before anything is shown. Use replace=True there so Back doesn't land on
the redirect again.
Page titles: call set_title(f"Order #{self.id} - Acme") in a page's
on_init to name the browser tab or window after it (see
the app's title and icon).
Breadcrumbs: give a page a crumb in its code block (crumb = "Orders",
or crumb = "Order #{id}" to fill in route parameters), and
pyweft.routing.trail() gives the trail to the current page: one
Crumb(to, label) per level, so /orders/7 is Home, Orders, Order #7. A
page without a crumb is named after its URL segment (order-items
becomes "Order items"), and a level with no page of its own (/settings
when only /settings/profile exists) comes with to=None.
pyweft-ui's <ui.Breadcrumbs auto="true" /> shows it.
The page for unknown addresses is pages/not_found.weft; declare
path: str = "" in it to show the address that was asked for. Without the
file, a plain "There is no page at …" message is shown.
examples/shop/ is a small app using all of this:
uv run pyweft run examples/shop.