anywidgets All the Way Down
By Trevor Manz
The anywidget community is growing! Join us on Discord
TL;DR: anywidget v0.11 expands the AFM with three additive primitives: an
AbortSignal for lifecycle cleanup, an exports object returned from
initialize, and a host API that lets one widget render and talk to another.
All existing widgets keep working without changes.
pip install --upgrade anywidget
export default {
initialize({ model, signal }) {
return {
// exports: the widget's programmatic JS-side interface
getValue: () => model.get("value"),
};
},
async render({ model, el, signal, host }) {
let child = await host.getWidget(model.get("control"));
let div = document.createElement("div");
el.appendChild(div);
await child.render({ el: div, signal });
},
};
anywidget v0.11
This release implements the widget composition RFC shared earlier this year. The changes described below are all additive: existing widgets should keep working without modification.
For the past year, anywidget has predominantly been in maintenance mode. The library works, and the community has been expanding on both ends (more host platforms and more widgets shipped on top of it.
A stretch of stability also tends to surface what’s still missing. One pattern that kept coming up was a different kind of widget than the AFM had been designed around (#28, #193, #855).
Prior to v0.11, anywidget was a good fit for widgets that own a self-contained piece of the screen (a chart, a map, a control). Two limitations got in the way of anything else.
Composition was left to the host. A widget that wants to lay out other widgets had to be expressed using whatever container the host platform provides:
ipywidgets.HBox([widget_a, widget_b]) # Jupyter / Lab
mo.hstack([widget_a, widget_b]) # marimo
This is fine for end users, but it means a widget author who wants to ship their own layout primitive (a tabbed container, a draggable panel, a kanban board) had no way to do it within anywidget itself. Container widgets, widgets agnostic to their children that perform a layout, were not expressible in the AFM.
Shared interfaces were limited to model state. The only thing one widget
could know about another was what was exposed on the synced model. A widget
that wanted to call panTo(lat, lng) or highlight(rowId) on another widget
on the page had to round-trip through a synced state. That works for state, but
it forces every JS-side capability behind a Python round-trip.
v0.11 closes both gaps. There are three additions to the front-end contract:
signal: anAbortSignaloninitializeandrenderfor lifecycle cleanup.initializeMAY return an exports object: the widget’s programmatic JS interface, made available to other widgets on the page.host.getWidget/host.getModelonrender: resolve a widget reference passed in from Python, render it inside your view, or talk to it via its exports.
Together these changes enable a new pattern: widgets that compose other widgets, with a clear contract for how parent and child share lifecycle, rendering, and programmatic surface.
Lifecycle via AbortSignal
Both initialize and render now receive an
AbortSignal
on their props. The host aborts it when the widget is destroyed (or during
HMR). This is the preferred way to manage cleanup going forward since it
composes with the broader web platform (addEventListener, fetch, child
widgets) and collapses setup and teardown into a single block of code:
// before
export default {
render({ model, el }) {
let handler = () => { /* ... */ };
el.addEventListener("click", handler);
return () => {
el.removeEventListener("click", handler);
}
},
};
// after
export default {
render({ model, el, signal }) {
let handler = () => { /* ... */ };
el.addEventListener("click", handler, { signal });
},
};
Returning a cleanup callback from render (or initialize) still works so
existing widgets need no changes. New code is encouraged to prefer signal.
initialize Returns Exports
initialize runs once per widget instance, before any view is rendered. In
v0.11, it MAY return an arbitrary object: the widget’s exports. The host
stores that object and makes it available to other widgets that resolve this
one as a reference (next section).
export default () => ({
initialize({ model, signal }) {
return {
getValue: () => model.get("value"),
setValue: (v) => {
model.set("value", v);
model.save_changes();
},
onChange: (cb) => model.on("change:value", cb),
};
},
render({ model, el, signal }) {
/* ... */
},
});
Returning a plain object is opt-in. Returning a function is still
treated as a cleanup callback (existing behavior). Returning nothing is
still fine. The host distinguishes the three cases via typeof.
The naming mirrors VS Code’s Extension.exports and ES module semantics:
exports is what your widget makes available for others to use. There is no
schema, no decoration, no validation. Widget authors define their own
interfaces and consumers duck-type at the boundary.
Composition: host.getWidget / host.getModel
render now receives a host prop. It exposes two async methods that resolve
a widget reference (more on what those are below):
interface Host {
getWidget<T>(ref: string): Promise<{
exports: T;
render(opts: { el: HTMLElement; signal?: AbortSignal }): Promise<void>;
}>;
getModel<T>(ref: string): Promise<AnyModel<T>>;
}
host.getWidget waits for the child’s initialize to complete, then returns
its exports and a render function bound to that child. host.getModel is a
lower-level escape hatch that returns the child’s underlying AnyModel for
direct event subscriptions and get/set/send access without participating
in rendering.
Passing the parent’s signal through to the child’s render ties their
lifecycles together. When the parent’s signal aborts (because the parent view
is being torn down or HMR is replacing it), the child’s view tears down with
it.
On the Python side, you pass widgets directly. The serialization layer
auto-detects anything that adheres to the anywidget protocol (objects with
a MimeBundleDescriptor at _repr_mimebundle_) or extends
anywidget.AnyWidget, and replaces it with a wire-format string of the form
"anywidget:<model_id>" at its position in the state. This works at any depth
(top-level traits, values inside dicts, items inside lists).
import anywidget
import traitlets
class Slider(anywidget.AnyWidget):
_esm = "slider.js"
value = traitlets.Float(0.0).tag(sync=True)
min = traitlets.Float(0.0).tag(sync=True)
max = traitlets.Float(100.0).tag(sync=True)
class Dashboard(anywidget.AnyWidget):
_esm = "dashboard.js"
control = anywidget.WidgetTrait().tag(sync=True)
slider = Slider(value=50)
Dashboard(control=slider) # just pass the widget (no wrapper, no children list)
anywidget.WidgetTrait is a new traitlet that validates the assigned object is
anywidget-compatible (or None). For dataclass / pydantic / msgspec users,
anywidget.Widget is exported as a type alias for annotations.
Putting It Together
A Dashboard that renders a Slider as a child widget, reacting to changes
the slider exposes through its exports. The Python side passes the slider
directly:
import anywidget, traitlets
class Slider(anywidget.AnyWidget):
_esm = "slider.js"
value = traitlets.Float(0.0).tag(sync=True)
min = traitlets.Float(0.0).tag(sync=True)
max = traitlets.Float(100.0).tag(sync=True)
class Dashboard(anywidget.AnyWidget):
_esm = "dashboard.js"
control = anywidget.WidgetTrait().tag(sync=True)
Dashboard(control=Slider(value=50, min=0, max=100))
slider.js returns its programmatic interface from initialize and uses
signal for cleanup:
export default () => ({
initialize({ model }) {
return {
getValue: () => model.get("value"),
onChange: (cb) => model.on("change:value", cb),
};
},
render({ model, el, signal }) {
let input = Object.assign(document.createElement("input"), {
type: "range",
min: model.get("min"),
max: model.get("max"),
value: model.get("value"),
});
input.addEventListener(
"input",
() => {
model.set("value", parseFloat(input.value));
model.save_changes();
},
{ signal },
);
let onChange = () => {
input.value = model.get("value");
};
model.on("change:value", onChange);
signal.addEventListener("abort", () => model.off("change:value", onChange));
el.appendChild(input);
},
});
dashboard.js resolves the slider via host.getWidget, reads its exports,
and renders it inside its own DOM:
export default {
async render({ model, el, signal, host }) {
let slider = await host.getWidget(model.get("control"));
if (typeof slider.exports?.onChange === "function") {
slider.exports.onChange(() => {
console.log("value:", slider.exports.getValue());
});
}
let div = document.createElement("div");
el.appendChild(div);
await slider.render({ el: div, signal });
},
};
The full version, including a TypeScript type guard at the consumer boundary, is in the RFC.
A Richer Host Contract
The pattern these primitives establish is that more responsibility now sits
with the host runtime. The host maintains a registry of widget bindings keyed
by model, parses ref strings, waits for initialize to complete before
resolving host.getWidget, and creates an AbortController per view that
cascades through the composition tree.
For host implementations that don’t (yet) support composition, the AFM’s guidance is simple: fail loudly and early. The reference implementation shipping in this release does this in three places:
parseWidgetRefrejects malformed refs synchronously with the offending value in the message.host.getWidgetthrows when the child model has no registered binding ([anywidget] No binding found for widget <id>) instead of returning a silently broken handle.- A 10-second timeout on
child.readyrejects with[anywidget] Timed out waiting for widget <id> to initialize, so a child whoseinitializenever resolves becomes a clear runtime error rather than a hung promise.
These are small details, but they matter for ecosystem health: hosts that
choose not to implement composition can drop in a host that throws
descriptively, and widget authors will see the same shape of error everywhere
instead of one notebook environment failing in a different way than another.
Migration
To start using anywidget v0.11:
pip install --upgrade anywidget
There is nothing required to migrate. Existing widgets will continue work
unchanged. New widgets can opt into signal, exports, and host
incrementally, hook by hook.
If you maintain a host runtime (a notebook front end, a custom embed) and want
to opt into the new APIs, the four host requirements are documented at the end
of the
RFC.
The reference implementation in packages/anywidget is small enough to read
end-to-end if it helps.
Happy composing.