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 likehistory: 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:
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:
- An event arrives and your handler runs.
- The component is marked dirty and re-rendered in full.
- The new tree is diffed against the previous one.
- 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. Anasyncone shows what it set before its firstawaitstraight 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 withself.start_task()and effect cleanups have already been run by then, so it must not beasync.
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_initruns on each visit andon_disposewhen you leave. - Hot reload keeps components alive, so
on_initdoes not run again; the effects of an edited component start over. - When the first screen is rendered ahead of time,
on_initandon_after_renderalso run on your machine duringpyweft runandbuild, up to the firstawait. Keep side effects, such as sending analytics, after anawaitor 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
@propertyvalues and helper methods it calls. Above,remainingreadsself.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)anditem["done"] = Truere-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
EffectLoopErrorafter 100 rounds instead of hanging the page. For a value that is purely derived from other state, a@propertyis 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.
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
awaitappears 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), callself.state_has_changed()after the change. on_initcan 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 whenon_initfinishes.on_after_rendercan be async too;on_disposecannot.- 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 whileloadingis 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 anasyncio.CancelledError. Catch it withtry/finallyto tidy up. - Errors raised after an
awaithave 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, withoutself(or one from an importable module, such asrun_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.jsin the worker is the worker's own scope, with nodocument. - 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
.weftfile are not; define the class in one of your app's.pymodules 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:
ValueErrorin the worker raisesValueErrorat theawait, and the error overlay shows both lines, theawaitand 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 asjs.document...inside methods, where it belongs anyway. - It must be a regular
def, notasync 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()andjs.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, callpyweft.background.warm_up()inon_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
asynchandlers andawait, 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.itemshalfway through a render can corrupt it. Return the result and assign it after theawait, asrun_in_backgroundmakes you do. (In a desktop app, real threads exist, so this matters most there.)