Skip to content

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:

<TodoItem :item="item" title="row" />

In the child, props are just state fields that the parent overwrites on every render — declare them with defaults:

item: dict = {}
title: str = ""

Events: emitting and listening

There are two kinds of @ events, and they are handled differently:

  • @click on a plain element (<button @click="cheer">) is a DOM event. The browser fires it and the component whose template contains the element handles it.
  • @remove on a component tag (<TodoItem @remove="...">) is a custom event. Nothing in the browser fires it — the child raises it by calling emit, 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 @changed does.
  • @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.
  • emit returns whatever the parent's handler returned (None if nobody listened), so a child can ask the parent for a decision.
  • Use lowercase identifier-style event names (item_removed, not itemRemoved or item-removed) so the on_ 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:

from pyweft import load_component

components = {"Row": load_component("widgets/row.weft")}

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>
<DataList :items="fruits"><b>{{ index + 1 }}. {{ item.upper() }}</b></DataList>
  • 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>:

<TextField label="Email" @bind="email" />

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:

<Button variant="primary" class="wide" id="save" aria-label="Save order">Save</Button>
  • Plain values are converted to the field's type, so attributes read naturally: with cols: int = 0, cols="3" sets the number 3; with disabled: bool = False, disabled="true" (or disabled="") sets True and disabled="false" sets False. A value that doesn't fit, such as cols="three", is a template error naming the field.
  • class and style are 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/False leave it out, True writes 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: run serves it and build copies it into dist/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 .weft files (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; an async method 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; call cart.changed() after it, or better, give the store a method.
  • Computed values are @propertys, like count. 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 @effect that reads self.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 .weft files (editing the .py file 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:

<Dynamic :is="EDITORS[shape]" :value="settings" @update="save(event)" />
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_dispose runs, and the new one starts fresh with on_init.
  • :is="None" shows nothing.
  • Attributes, events, @bind and 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.