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.

So, is it fair to say that in new Observable, if you want to maintain state across repeated “executions” you can either:

  1. Use two cells and rely on “cell execution” (declaring the state in one cell and updating it in the other)
  2. Use one cell but necessarily rely on “internal execution” (declaring the state and then relying on an event listener to update without ever re-executing the cell)

I’ll admit, in old Observable, I was really hooked on the elegance of being able to maintain state in a single cell and still rely on “cell execution” for updating via the use of this. But that may make me an outlier.

I understand the appeal, but I think the vanilla JavaScript approach is preferable because it’s more explicit what’s happening (where the mutation is occurring, where the state is stored), and the code is more easily adapted to a non-Observable environment. The behavior of this in Observable JavaScript is idiosyncratic and surprising since it co-opts a core JavaScript concept.

(Also, more practically speaking, even if we wanted to support this in vanilla JavaScript, it’s not clear what the behavior should be because vanilla JavaScript cells don’t have a singular value; they can declare zero or more top-level variables. We could perhaps have it represent an object whose properties are the top-level variables, but then it wouldn’t be usable for expression cells…)

What’s the pattern that you settled on?

Looking at it again, I think I’d be tempted to fold the simulation state logic into the cell that declares the controls. Then the state and its controls are fully self-contained in a single cell. And then you can construct whatever “pure” displays of simulation state in other cells as you see fit.

Notebook: https://observablehq.com/d/68e2928594561c3a

I ended up going for a version of your second suggestion (a single cell using addEventListener for updates). The simplicity of being able to instantiate a simulation with a single cell won out over any reservations about needing to use events instead of cell execution.

I like what you did here by moving the simulation into the cell with the controls, leaving only the visualization in a separate cell. However, I want to be able to run multiple simulations with one set of controls, so that makes me want to keep the controls separate from any particular simulation.

Here is a fork of my updated simulation controls for new Observable (a fork because I’m still playing around with the main version):

https://observablehq.com/d/f9f9d3cf36d7c3c8

And here is a fork of an application to Conway’s Game of Life (also updated for new Observable, and note that this is a small excerpt from a much larger notebook about Life):

https://observablehq.com/d/929faaa950f579e3

It demonstrates the “one control, many visualizations” approach. And it shows how I like to wrap the simulation and the visualization each in a function so they can be reused. I don’t demonstrate it here, but an altered simulation (for example, new cell update rules) can be paired with the existing visualization, or an altered visualization (for example, different colors) can be paired with the existing simulation.

Actually, I’ve found what seems like it might be an even better solution, Generators.observe!

If I understand correctly, it provides a natural way to define state in the initialize function, which can then be maintained and updated throughout the life of the generator. Execution of each additional “step” can be tied to the 'input' event from a “control” cell, and the returned value gets implicitly displayed. Something like this:

const runner = function(control, start = 0) {
  return Generators.observe((step) => {
    // State maintained across invocations
    let count = start;

    const listener = () => {
      // Visualization and simulation code that gets executed on each invocation
      step(html`<div>Count: ${count}</div>`);
      count++;
    };

    // Initial invocation
    listener();
    // Set up subsequent invocations based on external event
    control.addEventListener('input', listener);
    // Cleanup
    return () => {
      control.removeEventListener('input', listener);
    }
  });
}

Notebook: https://observablehq.com/d/0d65b1332201676f

Looks good to me! :+1: