Suspense and Lazy Loading¶
Wybthon ships two complementary primitives for asynchronous UI:
Suspenserenders a fallback (e.g. spinner) while a subtree is loading.lazydefers loading a component module until it actually mounts.
Together they let you split big apps into smaller chunks and present a polished loading experience.
When to reach for each¶
| Situation | Use |
|---|---|
| Async data fetching with a loading state | create_resource + Suspense |
| Code-splitting a heavy route or panel | lazy(load=...) inside a route |
| Both at once | lazy inside a Suspense boundary |
Suspense¶
Suspense watches its descendants for any tracked async work (typically a create_resource that hasn't resolved). While anything is pending, it renders fallback. Once everything resolves, it swaps to the resolved tree.
from wybthon import (
Suspense, component, create_resource, create_signal,
)
from wybthon.html import div, p, span
async def fetch_user(id_: int) -> dict:
...
@component
def UserCard(id):
user = create_resource(id, fetch_user)
return div(
p("Name: ", span(lambda: user()["name"])),
p("Email: ", span(lambda: user()["email"])),
)
@component
def Profile():
id_, _ = create_signal(42)
return Suspense(
fallback=lambda: p("Loading…"),
children=lambda: UserCard(id=id_),
)
- The
Suspenseboundary catches any pending resources in its subtree. fallbackaccepts a callable so the placeholder can stay reactive too.- Resources resolve independently;
Suspensewaits for all of them.
Nesting boundaries¶
You can nest Suspense boundaries to refine which parts of the page show fallbacks. The closest enclosing boundary always wins for a given pending resource.
Errors inside a boundary¶
Suspense only handles loading states. Pair it with ErrorBoundary to also catch render errors:
ErrorBoundary(
fallback=lambda err, reset: p("Something went wrong: ", str(err)),
children=lambda: Suspense(
fallback=lambda: p("Loading…"),
children=lambda: UserCard(id=id_),
),
)
Lazy components¶
lazy(load=...) returns a placeholder component. The first time it mounts, it awaits load() (typically an await import or micropip.install) and replaces itself with the real component once ready.
from wybthon import Suspense, lazy
from wybthon.html import p
HeavyChart = lazy(load=lambda: import_module_async("app.heavy_chart"))
@component
def Dashboard():
return Suspense(
fallback=lambda: p("Loading chart…"),
children=lambda: HeavyChart(data=...),
)
loadreturns either a coroutine that resolves to the component, or a module from which an attribute is read.- Pair
lazywithSuspenseso users see a fallback instead of an empty space. - Use
preload_componentto warm the cache (e.g. on hover) before the user actually navigates.
Lazy routes¶
Route accepts lazy components directly, which is the canonical way to code-split:
from wybthon import Route, Router, lazy
routes = [
Route(path="/", component=Home),
Route(path="/settings", component=lazy(load=lambda: import_module_async("app.settings"))),
]
@component
def App():
return Router(routes=routes)
The first time a user visits /settings, the module is fetched and cached.
Patterns and pitfalls¶
- Show something immediately. Suspense fallbacks should be cheap and stable; avoid placing heavy components inside them.
- Don't double-await. A
create_resourcealready integrates withSuspense; you don't need toawaitits value before rendering. - Cache module loads.
lazycaches the resolved component automatically; don't calllazy()inside the render path. - Combine with
ErrorBoundary. Async loads can fail. Always wrap user-facing lazy regions with both boundaries.
Next steps¶
- Read the Async fetch example for an end-to-end resource demo.
- See
create_resourcefor the resource lifecycle. - Read Performance for code-splitting tips.