Replacement for `this` in cells?

In Observable Javascript, this refers to the previous state of a cell. This is very handy, because this can store the state of a simulation (both the visible state in DOM nodes and hidden data as well), and each time the cell is executed the simulation can advance by one time step. This made it easy to update only those parts of the simulation that changed while leaving everything else alone.

Here is the base of how I was using it:

https://observablehq.com/@akrawitz/simulation-controls

I was, of course, building on the shoulders of giants, as shown here:

https://observablehq.com/@mbostock/pause-a-generator

And here:

https://observablehq.com/@tmcw/untitled/5

As far as I can tell, this doesn’t work the same way in new plain JavaScript/TypeScript cells. What would be the canonical replacement?

The closest analogy is probably Mutable, and that reminds me I need to write some more documentation… you can see the Framework docs on mutables for now.

The behavior of this in Observable JavaScript is another form of mutable state, and I think Mutable is an improvement over this because it makes it more explicit and more flexible. You can also think of Mutable as similar to React’s useState hook.

Adapting one of your simulation control examples, I’d first define the simulation state as a mutable, and then provide a getter (for peeking “non-reactively” at the state) and likewise a setter. The setter is important because it allows another cell (a cell other than the one that declared the mutable) to mutate the state.

const state = Mutable({cycle: 0});
const getState = () => state.value;
const setState = (s) => (state.value = s);
const epochLength = 20;

Then your simulation code can run in another cell:

{
  conSkip;
  let state = getState();
  if (conSkip.reset) {
    state = setState({cycle: 0});
  } else {
    state = setState({cycle: state.cycle + 1});
    if (conSkip.skip && (state.cycle % epochLength) === 0) {
      conSkip.stopSkip();
    }
  }
}

Notebook: https://observablehq.com/d/1b4a0e1ad4bdc170

Another approach you could consider is using event listeners rather than reactive references (“reactions” to the input) to update your simulation state. That’s potentially a little simpler since you can use a single cell to define the simulation state and how it updates, rather than needing two cells.

To do this, you first display your input instead of using view (or do both, but you need to be able to reference the input in order to call addEventListener):

const conSkipInput = display(controls({skip: true, speed: 75}));

Then here’s your simulation state definition:

const state = Mutable({cycle: 0});
const epochLength = 20;

conSkipInput.addEventListener("input", (event) => {
  const conSkip = event.currentTarget.value;
  if (conSkip.reset) {
    state.value = {cycle: 0};
  } else {
    state.value = {cycle: state.value.cycle + 1};
    if (conSkip.skip && (state.value.cycle % epochLength) === 0) {
      conSkip.stopSkip();
    }
  }
});

(Note that this leaks an event listener if you re-run the state cell, so be careful when editing. If you want to clean that up, you can either use the invalidation promise to remove the event listener, or assign to conSkipInput.oninput instead of using conSkipInput.addEventListener.)

Notebook: https://observablehq.com/d/f3c1b2d06555fc77

Thank you for these examples!

It occurs to me that with the addEventListener approach, I don’t even need a Mutable. A “normal” local variable in the cell scope can hold the state for use in the event listener.

Building on this approach, here is a version where the simulation and it’s accompanying visualization are all wrapped up in a single cell:

https://observablehq.com/@akrawitz/simulation-state-replace-top-level-node

However, there is one quirk with this example. When “Reset” is clicked, the output for the simulation cell gets “duplicated” instead of replaced. In other words it goes from:

Cycles: 23, Epoch: 2

to looking like this:

Cycles: 23, Epoch: 2
Cycles: 0, Epoch: 0

The problem seems to be this line, which is executed on reset:

visualization = html`<div>Cycles: <span class="cycle">0</span>, Epoch: <span class="epoch">0</span></div>`;

If I change this to reuse the existing node, then the problem goes away:

https://observablehq.com/@akrawitz/simulation-state-reuse-top-level-node

visualization.replaceChildren(html.fragment`Cycles: <span class="cycle">0</span>, Epoch: <span class="epoch">0</span>`);

But I don’t understand why this matters. Either way I am calling display() to update the cell’s output. Something to do with how display() works maybe?

To address my own question…

Since the cell isn’t being rerun, each additional call to display() with a new DOM node adds to the output! On the other hand calling display() again with the same DOM node seems to be a no-op. Directly changing the internal contents of the DOM node changes it in place with no call to display() needed.

Sounds like you got it!

The reason to use Mutable would be if you want to trigger downstream computation in other cells. That might be nice, for example, to decouple the logic of updating the simulation’s state from the display of that state, letting the display be a pure function of state. (Though sometimes coupling the state updates with the display allow you to update the display more efficiently.)

And yes, display(…) is like console.log(…) in that you can call it multiple times to display multiple values. If you call display on a DOM node that’s already present on the page, it will be removed from wherever it lives and re-added to the page in the new location; that can be a noop when the new and old location are the same.

Any chance there is a separate function or a parameter for display() that would allow one to “replace” or “clear” the cell output instead of add to it?

(I couldn’t help but notice clear() in the Notebook Kit runtime: notebook-kit/src/runtime/display.ts at main · observablehq/notebook-kit · GitHub).

Hmm, no, we don’t have any plans to expand the display API currently. Do you think it’s needed? Normally the display is reset when the cell is invalidated. I guess we could add display.clear() pretty easily but I’m always hesitant to add more surface area until there is more demonstrated demand.