Jupyter Widgets: The Good Parts

This page is largely adapted from “Building a Custom Widget” in the Jupyter Widgets documentation. I’d hoped to avoid separately documenting the Jupyter Widgets API, but their tutorial mixes boilerplate/packaging details with core “concepts” to Jupyter Widgets. Below is an attempt to distill these concepts for anywidget authors.

The Widget Front End

This section frames the Juptyer Widgets documentation in the context of anywidget. Remember that anywidget is just abstraction over traditional Jupyter Widgets that removes boilerplate and packaging details.

Comparison with traditional Jupyter Widgets

anywidget simplifies creating your widget’s front-end code. Its only requirement is that your widget front-end code is a valid JavaScript module and exports a function called render. This render function is similar to the traditional DOMWidgetView.render.

Concretely, custom widgets are traditionally defined like:

import { DOMWidgetModel, DOMWidgetView } from "@jupyter-widgets/base";

// All boilerplate, anywidget takes care of this ...
class CustomModel extends DOMWidgetModel {
	/* ... */
}

class CustomView extends DOMWidgetView {
	render() {
		let view = this;
		let el = this.el;
		let model = this.model;
		/* ... */
	}
}

export { CustomModel, CustomView };

… which must be transformed, bundled, and installed in multiple notebook environments.

In anywidget, the above code simplifies to:

/** @param {{ model: DOMWidgetModel, el: HTMLElement }} context */
export function render(context) {
	let el = context.el;
	let model = context.model;
	/* ... */
}

… which explicity defines the widget view via the render function, and (implicitly) anywidget defines the associated widget model (i.e., CustomModel). anywidget front-end code is often so minimal that it can easily be inlined as a Python string:

class CustomWidget(anywidget.AnyWidget):
    _esm = """
    export function render(context) {
      let el = context.el;
      let model = context.model;
      /* ... */
    }
    """

The render function

Just like DOMWidgetView.render, your widget’s render function is executed exactly one per output cell that displays the widget instance. Therefore, render primarily serves two purposes:

  1. Initializing content to display (i.e., create and append element(s) to context.el)
  2. Registering event handlers to update or display model state any time it changes (i.e., passing callbacks to context.model.on)

Connecting JavaScript with Python

The Jupyter Widgets framework is build on top of the IPython Comm framework (short for communication). It’s worth reading the Low Level Widget Explanation to understand the core of Jupyter Widget’s Model, View, Controller (MVC) architecture, but in short the Comm framework exposes two mechanisms to send/receive data to/from the front end:

1. Traitlets

traitlets are the easiest and most flexible way to synchronize data between the front end and Python. The sync=True keyword argument tells the widget framework to handle synchronizing that value to the front end. Take the following CustomWidget:

class CustomWidget(anywidget.AnyWidget):
    _esm = pathlib.Path("index.js")
    my_value = traitlets.Int(0).tag(sync=True)

It defines an Integer my_value trait, which is synchronized with the front end. The render function now has the ability to:

  • get my_value
// index.js
export function render({ model, el }) {
	let my_value = model.get("my_value");
}
  • set my_value
// index.js
export function render({ model, el }) {
	model.set("my_value", 42);
	model.save_changes(); // required to send update to Python
}
  • listen for changes to my_value (and register event handlers)
// index.js
export function render({ model, el }) {
	function on_change() {
		let new_my_value = model.get("my_value");
		console.log(`The 'my_value' changed to: ${new_my_value}`);
	}
	model.on("change:my_value", on_change);
}

Note: In the snippet above, on_change is called an event handler because it executes any time my_value is updated from either Python or the front-end code (i.e., a change event).

An important aspect traitlets, and their first-class support in Juptyer Widgets, is that it is easy to compose Jupyter Widgets together in Python. For example,

import ipywidgets

# create a custom widget
widget = CustomWidget()

# link a slider widget with our custom widget
slider = ipywidgets.IntSlider()
ipywidgets.link((widget, "my_value"), (slider, "value"))

# log the value of `my_value` any time it changes
output = ipywidgets.Output()
@output.capture()
def handle_change(change):
    """Prints new value to `output` widget"""
    print(change.new)
widget.observe(handle_change, names=["my_value"])

ipywidgets.VBox([slider, widget, output])

It doesn’t matter if our widget is updated from JavaScript or Python, the IPython framework ensures it stays in sync with all the different components.

2. Custom messages

A second mechanism to send data to the front end is with custom messages. Within your render function, you can listen to msg:custom events on the model. For example,

class CustomMessageWidget(anywidget.AnyWidget):
    _esm = """
    export function render({ model, el }) {
      model.on("msg:custom", msg => {
         console.log(`new message: ${JSON.stringify(msg)}`);
       });
    }
    """

widget = CustomMessageWidget()
widget # display the widget
# send message
widget.send({ "type": "my-event", "foo": "bar" })

# Browser console:
# new message: '{ "type": "my-event", "foo": "bar" }'

Warning: Custom messages are only received if your front-end code has executed (i.e., the widget is displayed before sending messages). Calling the snippet above out of order:


widget = CustomMessageWidget()

# send message, but no event listeners!
widget.send({ "type": "my-event", "foo": "bar" })

# displays widget (starts listening for events)
widget

Tips for beginners

anywidget is a minimal layer on top of Jupyter Widgets and explicitly avoids inventing new concepts or widget APIs. Its design allows widget authors to have nearly same low-level control over their Jupyter integrations, but this flexibility can be intimidating and confusing new widget authors.

Here are some general recommendations for being productive with anywidget:

  • Start small. Jupyter Widgets combine many concepts and tools from JavaScript and Python. Unlike traditional widgets, anywidget allows you to learn both ecosystems incrementally. Start with a minimal example, and slowly add new functionality. Change traitlets. Update model state from JavaScript, then update model state from Python. Add an event handler. It’s a great way to learn.

  • Learn to identify ECMAScript modules (ESM). ESM is the core technology used by anywidget, and a deeper understanding will help you discern what is and (perhaps more importantly) is not standard JavaScript. I recommend reading Lin Clark’s ES modules: A cartoon deep-dive to learn more.

  • Prefer Traitlets over custom messages for state synchronization. Widget state can be fully recreated from traits without Python running, whereas custom messages require both an active Python kernel and special ordering of function calls. Write logic that treats your model as the source of truth (see Two-Way Data-Binding Example).

  • Use the browser console. View errors or intermediate values in your front-end code with the browser’s developer tools. Getting comfortable with the console will help demystify the front end and enable you to quickly debug your widgets.

    Jupyter notebook with the browser console open, logging 'Hello from anywidget' from the custom widget
  • Have fun. A primary goal of anywidget is to make it simple and enjoyable to create custom widgets. While it can serve as the foundation for a useful domain-specific integration, anywidget can also be used as a learning tool to poke around with the front end - or yet another way to Rick Roll your friends.