Skip to content

Your first component

A .weft file has two parts: a <template> (well-formed XML — close your tags, quote your attributes) and a <code> block (plain Python, no indentation gymnastics).

<template>
  <div class="hello">
    <h1>Hello, {{ name or "stranger" }}!</h1>
    <input @bind="name" placeholder="your name" />
    <button @click="cheer">cheer</button>
    <p>{{ cheers }} cheers so far</p>
  </div>
</template>

<code>
name: str = ""
cheers: int = 0

def cheer(self):
    self.cheers += 1
</code>

How the code block works:

  • State is declared as annotated class attributes (cheers: int = 0). Mutable defaults like history: list = [] are safe — each component instance gets its own copy.
  • Handlers are methods taking self. After a handler runs, the component re-renders automatically and the DOM is patched.
  • Computed values are ordinary Python properties:
@property
def remaining(self):
    return sum(1 for t in self.items if not t["done"])

How state actually works

There is no change tracking. A variable in the code block is a plain attribute on the component; the template reads it by name, and a handler writes it with ordinary assignment (self.cheers += 1). What makes the UI update is the render cycle:

  1. An event arrives and your handler runs.
  2. The component is marked dirty and re-rendered in full.
  3. The new tree is diffed against the previous one.
  4. Only the differences are applied to the page.

The framework never asks whether cheers changed — it re-renders after every event and lets the differ find out what moved. That is why there are no observables, no useState, no signals, and why a @property is recomputed for free: every render re-evaluates the template.

The one case you must handle yourself is state that changes outside a handler — a timer, a websocket message, a JavaScript callback. Nothing triggers the re-render, so call state_has_changed() after the mutation (async handlers are covered for you; see "Async handlers" below):

def on_message(self, msg):        # invoked by something other than the UI
    self.items.append(msg)
    self.state_has_changed()      # schedule a re-render

The re-render happens right after your code returns, not inside the call, so calling state_has_changed() many times in a row (in a loop, say) still costs one render. It works the same in a child component as in the root.

Functions you import into the code block stay plain functions: from textwrap import shorten lets the template call {{ shorten(title, 40) }} and a method call shorten(self.title, 40), without self being slipped in as the first argument. (An imported function whose first parameter is named self is taken to be a method you want to share, and becomes one.)

State belongs to the instance that declared it. To share data between a parent and its children, pass it down as props and send changes up with emit (section 4). For state that many components use, such as a cart, the signed-in user or settings, use a store.

Lifecycle: when your code runs

Besides handlers, a component can define methods that PyWeft calls at set moments in its life:

Method When it runs async?
on_init(self) once, when the component first appears, before its first render; its props are already set yes
on_after_render(self, first) after every render, once the changes are on the page; first is True the first time yes
on_dispose(self) once, when the component is removed: its <If> turned false, its row left a <For>, you left its page no
an @effect method after the first render, then after any render in which a field it reads changed no
from pyweft import effect

orders = None
query: str = ""

async def on_init(self):
    self.orders = await fetch_orders()   # the part before the await is in the first render

def on_after_render(self, first):
    if first:
        self.focus("search")             # the elements are on the page now

@effect
def remember_query(self):                # runs again whenever self.query changes
    save_setting("query", self.query)

def on_dispose(self):
    close_connection()

What each is for:

  • on_init: loading data and setting things up. An async one shows what it set before its first await straight away (self.loading = True), and the component renders again by itself when it finishes. Child components get theirs the first time a render produces them.
  • on_after_render: anything that needs the component's elements to be on the page, such as measuring them or handing one to JavaScript.
  • @effect: keeping something in step with state, such as saving, syncing or subscribing. It works out which fields it depends on by itself and can return a cleanup function (see effects).
  • on_dispose: letting go of what you opened: timers, connections, JavaScript listeners. Tasks started with self.start_task() and effect cleanups have already been run by then, so it must not be async.

The order in one update. An event arrives and its handler runs; the page renders again, and components appearing for the first time get on_init while vanished ones get on_dispose; the changes are applied to the page; effects run, then on_after_render, children before the root; if any of them changed state, it renders and applies again; finally focus() and scroll_to() requests are carried out.

Good to know:

  • With pages, every address is a fresh page component: its on_init runs on each visit and on_dispose when you leave.
  • Hot reload keeps components alive, so on_init does not run again; the effects of an edited component start over.
  • When the first screen is rendered ahead of time, on_init and on_after_render also run on your machine during pyweft run and build, up to the first await. Keep side effects, such as sending analytics, after an await or in a handler.
  • A change that doesn't come from a handler, such as a timer or a JavaScript callback, is outside this cycle: call self.state_has_changed().

Effects: reacting to state changes

Some code should run because state changed, not because an event fired: keeping the tab title in sync, saving a draft, starting a subscription. Mark a method with @effect:

from pyweft import effect

@effect
def sync_title(self):
    import js
    js.document.title = f"{self.remaining} left"
  • When it runs. Once after the component's first render, then after any later render in which a value it read has changed. Effects run after the page has been patched, so the DOM already shows the new state.
  • What it depends on is discovered automatically: every state attribute the method reads, including reads inside @property values and helper methods it calls. Above, remaining reads self.items, so the effect re-runs when the list changes and never when, say, a text input does.
  • In-place changes count. Values are compared against a copy taken after the last run, so self.items.append(x) and item["done"] = True re-run the effect, not only reassignment.
  • Cleanup. Return a function and it runs before the next run and when the component is removed, which is how you cancel timers and listeners. This saves the draft half a second after the user stops typing; each keystroke cancels the previous pending save:
@effect
def autosave(self):
    import js
    from pyodide.ffi import create_proxy
    draft = self.draft
    save = create_proxy(lambda: js.localStorage.setItem("draft", draft))
    timer = js.setTimeout(save, 500)
    def cancel():
        js.clearTimeout(timer)
        save.destroy()
    return cancel
  • Writing state from an effect works, for example deriving one field from another. The component re-renders once the effects have run. An effect writing one of its own inputs does not re-trigger itself; two effects that keep changing each other's inputs raise EffectLoopError after 100 rounds instead of hanging the page. For a value that is purely derived from other state, a @property is still simpler.

Effects run synchronously and only in components that are rendered. For code that must run after every render, whatever changed, use on_after_render (see lifecycle).

Async handlers and background work

Any handler can be async def. Use it for anything that waits: a network request, a delay, a JavaScript promise.

<button :disabled="loading" @click="load_users">Load users</button>
<p>{{ status }}</p>
import asyncio

loading: bool = False
status: str = ""
users: list = []

async def load_users(self):
    import js
    self.loading = True               # shown as soon as you click
    self.status = "Loading…"
    response = await js.fetch("assets/users.json")
    self.users = (await response.json()).to_py()["users"]
    self.status = f"{len(self.users)} users"
    self.loading = False              # shown when the handler finishes
  • When the page updates. Everything up to the first await appears in the same update as the click, so a spinner or a disabled button shows immediately. The component updates again when the handler finishes. To show progress in between (a counter in a long loop, say), call self.state_has_changed() after the change.
  • on_init can be async, which is the usual way to load a component's data. The component renders right away with whatever it has (say, "Loading…") and again when on_init finishes. on_after_render can be async too; on_dispose cannot.
  • Clicking again while it waits runs the handler a second time, alongside the first. Both run on one thread and only take turns at await, so they never corrupt each other's state, but you usually want one at a time: disable the button while loading is true, as above.
  • Removed components stop. If a component is removed while one of its handlers is still waiting (a row deleted mid-save, a dialog closed mid-request), the handler is cancelled at its current await, as an asyncio.CancelledError. Catch it with try/finally to tidy up.
  • Errors raised after an await have nobody to return to, so in development they appear in the error overlay with the file and line, and always in the browser console with the full traceback. Everything the handler changed before failing is still shown.

Starting work yourself. self.start_task(coroutine) runs a coroutine alongside the component, with the same rules: the component updates when it finishes, and it is cancelled if the component is removed. It returns the asyncio.Task. For example, a clock:

async def tick(self):
    while True:
        self.now = time.strftime("%H:%M:%S")
        self.state_has_changed()
        await asyncio.sleep(1)

def on_init(self):
    self.start_task(self.tick())     # stops by itself when the clock is removed

Effects cannot be async (only the reads before the first await could be tracked), but an effect can start a task. Return the task's cancel as the cleanup and only the latest run survives, which is exactly what a search box needs; each keystroke cancels the request still in flight:

@effect
def search_as_you_type(self):
    task = self.start_task(self.search(self.query))
    return task.cancel

async def search(self, query):
    await asyncio.sleep(0.3)          # wait for typing to pause
    response = await js.fetch(f"/api/search?q={query}")
    self.results = (await response.json()).to_py()

Heavy work: run_in_background

Python in the browser runs on the page's main thread, so a long calculation freezes the page: no clicks, no repaints, until it is done. For work like that (parsing a big file, statistics, image processing), run it in the background:

from pyweft import run_in_background

busy: bool = False
report: str = ""

async def analyse(self):
    self.busy = True                                   # shows immediately
    self.report = await run_in_background(build_report, self.rows, title="Q3")
    self.busy = False

def build_report(rows, title=""):     # a plain function: no self
    ...                               # takes seconds; the page stays responsive
    return text

In the browser the call runs in a Web Worker: a second Python on its own browser thread. The page keeps responding while it works, and you await the result like any other async call. Headless (tests), and in a desktop app, the same call runs on a thread instead.

The worker is a separate copy of Python, which sets the rules:

  • Pass a plain function defined at the top level of the <code> block, without self (or one from an importable module, such as run_in_background(statistics.median, values)). It is found in the worker by name, so methods, lambdas and nested functions are refused with a clear error. Other functions and imports from the same <code> block are available to it.
  • It cannot see the component or the page. Pass in the data it needs and return the result; change state after the await, as above. js in the worker is the worker's own scope, with no document.
  • Arguments and the result must be picklable: numbers, strings, lists, dicts, tuples, sets, dates, and objects from installed packages (NumPy arrays, for example). Instances of classes defined in a .weft file are not; define the class in one of your app's .py modules instead (section 5). Everything is copied in and out, so a list the function changes in the worker is not changed on the page.
  • Exceptions come back: ValueError in the worker raises ValueError at the await, and the error overlay shows both lines, the await and the line in the background function that failed.
  • The top level of the <code> block runs again in the worker (that is how it finds the function), so keep page-only code such as js.document... inside methods, where it belongs anyway.
  • It must be a regular def, not async def. Do any waiting (a download, say) in your handler, then pass the data in:
async def load_report(self):
    from pyodide.http import pyfetch
    rows = await (await pyfetch("assets/sales.json")).json()   # waiting: on the page
    self.report = await run_in_background(build_report, rows)  # computing: in the worker
  • Blocking is fine inside it. time.sleep() or a long loop blocks only the worker, never the page.
  • print() and js.console.log() work inside it and show up in the browser's console as usual, which is handy for checking what it received.

How it behaves:

  • The first call starts the worker, which loads Python and your dependencies from pyproject.toml: a few seconds, mostly cached after the first visit. Later calls start immediately. To start it early, call pyweft.background.warm_up() in on_init.
  • One worker, one call at a time, in the order they were made. Calls made while one runs wait their turn.
  • Cancelling (the component is removed, or an effect's cleanup cancels the task) drops a call that has not started yet. A call already running finishes in the worker and its result is discarded; Python in a worker cannot be interrupted. For search-as-you-type, that means at most one stale search runs before the latest one.
  • Hot reload reaches the worker too: edit the function, save, and the next call runs the new code.

Use it for work measured in hundreds of milliseconds or more. Each call copies its data both ways, so many tiny calls are slower than doing the work directly.

Seeing that it works. Add a counter next to the button that starts the work. While the background call runs, the counter should keep counting:

<button :disabled="busy" @click="analyse">Analyse</button>
<p>{{ report }}</p>
<button @click="clicks += 1">clicks while it runs: {{ clicks }}</button>

Common mistakes, with the error each one gives:

You wrote You see Fix
def work(self): ... self.number ... AttributeError: 'int' object has no attribute 'number': self received the first argument you passed Take the data as parameters: def work(number), called as run_in_background(work, self.number)
run_in_background(self.work, ...) TypeError: run_in_background needs a plain function, not the method ... Pass work, not self.work, and remove self from it
run_in_background(lambda: ...) TypeError: ... lambdas and nested functions cannot be Give it a name at the top level of the <code> block
async def work(...) BackgroundError: the result of work could not be pickled: cannot pickle 'coroutine' object Make it a plain def; await things in the handler first
An argument such as an open file or a lock TypeError: the arguments to run_in_background(...) must be picklable Pass the data itself (the file's text, a list)
Changing a list inside the function, expecting the page to see it Nothing changes on the page Return the new value and assign it after the await
(with pyweft run) the background worker could not start: worker.js was not found (HTTP 404) pyweft run is older than PyWeft's files: restart it
(on a deployed site) the same message Upload worker.js with the rest of pyweft build's output

Threads

In the browser, Python has no threads. It runs on the page's main thread, the same one that draws the page and handles clicks:

In the browser What happens
threading.Thread(...).start(), ThreadPoolExecutor RuntimeError: can't start new thread
threading.Lock() and friends work (harmless with one thread)
time.sleep(), a long calculation work, but the page is frozen until they finish
await asyncio.to_thread(work) works, but runs work right there on the main thread

So, in practice:

  • For waiting (network, delays, timers), use async handlers and await, as above. No thread is needed.
  • For heavy computation, use run_in_background (above). It is the portable choice: a Web Worker in the browser, a thread elsewhere.
  • For long work that needs the component as it goes, split it up and give the page a turn every so often:
async def crunch(self):
    for i, row in enumerate(self.rows):
        process(row)
        if i % 500 == 0:
            self.progress = i
            self.state_has_changed()
            await asyncio.sleep(0)      # the page repaints and handles clicks here
  • Never change component state from another thread. Rendering happens on the main thread, and a thread writing self.items halfway through a render can corrupt it. Return the result and assign it after the await, as run_in_background makes you do. (In a desktop app, real threads exist, so this matters most there.)