How Does anywidget Sync State Between Python and JavaScript?

Updated

anywidget syncs state through a traitlets model on the Python side and a model object on the JavaScript side. You declare a trait with .tag(sync=True), and anywidget wires it to the front end: JavaScript reads it with model.get("name"), writes it with model.set("name", value) followed by model.save_changes(), and reacts to changes with model.on("change:name", callback). The render({ model, el }) function in _esm receives that model plus a DOM element to mount into.

This matters if you want a widget whose Python state and browser UI stay consistent without hand-writing a comm protocol or depending on a specific notebook framework. The example below is the CounterWidget from the anywidget site.

The two halves of the sync

Python side: declare a synced trait

import anywidget
import traitlets

class CounterWidget(anywidget.AnyWidget):
    _esm = """
    function render({ model, el }) {
      let count = () => model.get("count");
      let btn = document.createElement("button");
      btn.innerHTML = `count is ${count()}`;
      btn.addEventListener("click", () => {
        model.set("count", count() + 1);
        model.save_changes();
      });
      model.on("change:count", () => {
        btn.innerHTML = `count is ${count()}`;
      });
      el.appendChild(btn);
    }
    export default { render };
    """
    count = traitlets.Int(0).tag(sync=True)

count = traitlets.Int(0).tag(sync=True) is the whole Python-side contract. The trait's default is 0, its type is Int, and sync=True marks it as shared with the front end. anywidget handles serialization and transport; you don't write comm code.

JavaScript side: read, write, and listen

Inside render, the model argument is your handle to that shared state:

Operation Call What it does
Read model.get("count") Returns the current value of the synced trait
Write model.set("count", count() + 1) Stages a new value locally
Commit model.save_changes() Sends staged changes back to Python
Listen model.on("change:count", cb) Runs cb whenever count changes

The set / save_changes split is the key detail. model.set alone updates the JS-side model but does not push to Python; model.save_changes() is what commits it. In the counter, the click handler does both, so a button click increments the Python trait.

How a click flows through the system

  1. The user clicks the button. The listener calls model.set("count", count() + 1).
  2. model.save_changes() commits the new value, which propagates to the Python count trait.
  3. The model.on("change:count", ...) listener fires and rewrites btn.innerHTML to count is N.

The listener is what keeps the DOM in step with the model. Without it, the button label would go stale on changes that originate outside the click handler — for instance, if Python code sets counter.count = 5 directly. Because the listener re-renders from model.get("count") rather than from a local variable, both directions stay consistent.

Mounting: what render receives and returns

render({ model, el }) is called with two things:

  • model — the synced state object described above.
  • el — a DOM element anywidget provides as the widget's container.

You attach your UI to el (el.appendChild(btn)). The function returns nothing; the export default { render } at the end of _esm is how anywidget discovers the entry point. _esm holds the ES module source as a string, so the widget ships its own front-end code with no separate build step shown here.

What this design buys you

The site frames anywidget as "Beyond Frameworks, a Widget Standard" — a talk from SciPy 2024 about setting a standard for portable widgets while keeping ecosystem compatibility. The sync mechanism is the reason that portability works: the Python/JS contract is just synced traits plus a render function, so the same widget definition isn't tied to one front-end framework's state system.

Practical checks before you build

  • Every shared value needs sync=True. A trait without it stays Python-only and model.get won't see it.
  • Call save_changes() after set. Forgetting it is the most common reason a JS-side change never reaches Python.
  • Drive the DOM from listeners, not local copies. Re-read via model.get inside change: handlers so external updates render correctly.
  • Start from the documented install path. The site's Get Started flow is pip install anywidget, then a mywidget/__init__.py module, then use it in a notebook (the example runs at localhost:8888/Demo.ipynb).

If your widget only needs Python to push values into a view, you can skip the write path and use model.on alone. If it needs two-way interaction — a counter, a form, a canvas editor — the full get / set / save_changes / on loop is what keeps both sides in agreement.