Skip to content

Latest commit

 

History

History
242 lines (174 loc) · 6.23 KB

mep-0005.md

File metadata and controls

242 lines (174 loc) · 6.23 KB
MEP Title Discussion Implementation
5
State

State

Abstract

This MEP proposes a design for reactive state in which setting state in one cell triggers other cells that read that state to run.

Motivation

Reactive state:

  1. Simplifies code that would otherwise rely on manually added refs and error-prone side-effects.
  2. Enables synchronization of UI elements (and cycles among cells), which is currently impossible.

Less error-prone code. Today, users can maintain state by mutating Python objects when UI elements are updated. By carefully sequencing their DAG, they can make sure that cells pick up the mutated state. This approach lets users build TODO apps, variable length arrays of UI elements with history, and more. However, because these mutations are not tracked by the DAG, this approach is cumbersome and error-prone, resulting in code like:

class Counter:
    def __init__(self, value):
        self.value = value

    def increment():
        ...
    def decrement():
        ...

counter = Counter(0)
add_button = mo.ui.button(on_change=lambda _: counter.increment())
minus_button = mo.ui.button(on_change=lambda _: counter.decrement())
# manually add references to force cell to run, as a proxy
# for counter.value being updated
(add_button, minus_button)

# do something with the state
f(counter.value)

We propose an API that simplifies code like the above to

counter, set_counter = mo.state(0)
add_button = mo.ui.button(on_change=set_counter(counter.value + 1))
minus_button = mo.ui.button(on_change=set_counter(counter.value - 1))
# this cell is automatically triggered when set_counter is called
# no need to add refs to the buttons
f(counter.value)

Tying UI elements. Without reactive state, it is impossible to create two UI elements whose values are synchronized with each other, since this introduces a cycle. With state, this becomes possible:

state, set_state = mo.state(0)
slider = mo.ui.slider(0, 10, value=state.value, on_change=set_state)
number = mo.ui.slider(0, 10, value=state.value, on_change=set_state)
# now synchronized to have the same value
[slider, number]

Criteria

  • Simplifies existing code
  • Enables tying UI elements
  • Pythonic API
  • No special control flow constructs
  • Cannot require running / tracing cells
  • Easily explained reactivity rule

Design

We propose a design that is analogous to UI elements, both in form and in the reactivity rule.

We add a State class with a value attribute holding its value. Every State instance is paired with a setter function that updates its value. Like UI elements, the state instance must be assigned to a global variable for reactivity to take effect.

state, set_state = mo.state(initial_value)

Reactivity rule. Calling the setter function in one cell automatically queues all other cells that reference the state instance to run (unless they already ran after the setter was run).

This happens at runtime, but does not require running or tracing cells. Importantly, self-loops are never made: the setting cell won't trigger execution of itself, even if it references the state object.

  1. Creation:
state, set_state = mo.state(initial_value)
  1. Reading:
state.value
  1. Setting:
set_state(state.value + 1)
# automatically run after set_state is called
state.value

Note that the setter call access state.value, instead of a React-like approach which might use a lambda function. Because we disallow self-loops, and don't have a complicated runtime, it's fine to just directly access the state value; it's also more Pythonic. Unlike Javascript, in Python, objects can be and commonly are callable, so we can't reliably discriminate between values and callables anyway.

  1. No redundant runs:
set_state(...)
x = ...
# this cell runs when the above cell runs because it refs `x`
# therefore, the state update won't queue it to run again
x; state.value ...
  1. No self-loops:

We disallow / don't register self-loops in order to prevent awkward interactions in which a setter undoes an interaction a user made in the frontend. For example, consider the tied elements below

s = mo.ui.slider(0, 10, value=state.value,  on_change=set_state)
n = mo.ui.number(0, 10, value=state.value,  on_change=set_state)

```python
s, n

Interacting with s triggers the setter. If we included a self-loop, the slider would be re-created and pending (unflushed) interactions would be undone, snapping the slider back. This can be mitigated by debouncing but never solved, since the on_change handler could take time, and races would always be possible.

Simply not including the self loop fixes this issue: the number is recreated with the new value but the slider is never recreated on slider change, and vice versa.

Evaluation

Simplifies existing code

  • See button example in motivation. Removes manual reference jerry-rigging.

Enables tying UI elements

  • Yes. However tied elements must be created in separate cells so that an update in one cell propagates to the other (due to the restriction on self-loops)

Pythonic API

  • Yes. Would be better if setter were an attribute on the state object, but that would require disambiguating state from state.value, and marimo doesn't track attrs.

No special control flow constructs Cannot require running / tracing cells

  • State getters are resolved by inspecting refs, not by their execution, so we don't need to trace/run.

Easily explained reactivity rule

  • Single sentence, similar to UI elements.

Alternatives considered

  1. Single state object with a setter method.

Rejected because this would be a substantial deviation from how marimo tracks references.

  1. Allowing self-loops.

Breaks tying elements.

  1. Allowing self-loops but not recreating the UI element that fired an on-change.

Difficult to explain. Assumes that the same elements will be created on cell re-run, which is not necessarily true.

  1. No state.

Leads to cumbersome code and makes tied elements impossible.