diff --git a/README.md b/README.md index 5625e4f..3133c93 100644 --- a/README.md +++ b/README.md @@ -7,3 +7,4 @@ Marimo Enhancement Proposals | [0002](mep-0002.md) | Local Variables | | [0003](mep-0003.md) | UI Elements | | [0004](mep-0004.md) | App Configuration | +| [0005](mep-0005.md) | State | diff --git a/mep-0005.md b/mep-0005.md new file mode 100644 index 0000000..cf546f5 --- /dev/null +++ b/mep-0005.md @@ -0,0 +1,242 @@ +--- +MEP: 5 +Title: State +Discussion: https://github.com/marimo-team/meps/pull/5 +Implementation: https://github.com/marimo-team/marimo/pull/42 +--- + +# 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: + + +```python +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()) +``` + +```python +# 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 + +```python +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)) +``` + +```python +# 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: + +```python +state, set_state = mo.state(0) +``` + +```python +slider = mo.ui.slider(0, 10, value=state.value, on_change=set_state) +``` + +```python +number = mo.ui.slider(0, 10, value=state.value, on_change=set_state) +``` + +```python +# 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. + +```python +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: + + +```python +state, set_state = mo.state(initial_value) +``` + +2. Reading: + +```python +state.value +``` + +3. Setting: + +```python +set_state(state.value + 1) +``` + +```python +# 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. + + +4. No redundant runs: + +```python +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 ... +``` + +5. 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 + +```python +s = mo.ui.slider(0, 10, value=state.value, on_change=set_state) +``` + +```python +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 + +- [x] See button example in motivation. Removes manual reference jerry-rigging. + +> Enables tying UI elements + +- [x] 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 + +- [x] 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 + +- [x] State getters are resolved by inspecting refs, not by their execution, + so we don't need to trace/run. + +> Easily explained reactivity rule + +- [x] 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. + +2. Allowing self-loops. + +Breaks tying elements. + +3. 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. + +4. No state. + +Leads to cumbersome code and makes tied elements impossible.