MEP | Title | Discussion | Implementation |
---|---|---|---|
5 |
State |
This MEP proposes a design for reactive state in which setting state in one cell triggers other cells that read that state to run.
Reactive state:
- Simplifies code that would otherwise rely on manually added refs and error-prone side-effects.
- 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]
- Simplifies existing code
- Enables tying UI elements
- Pythonic API
- No special control flow constructs
- Cannot require running / tracing cells
- Easily explained reactivity rule
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.
- Creation:
state, set_state = mo.state(initial_value)
- Reading:
state.value
- 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.
- 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 ...
- 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.
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
fromstate.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.
- Single state object with a setter method.
Rejected because this would be a substantial deviation from how marimo tracks references.
- Allowing self-loops.
Breaks tying elements.
- 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.
- No state.
Leads to cumbersome code and makes tied elements impossible.