Skip to content

Commit

Permalink
mep-0005: reactive state (#5)
Browse files Browse the repository at this point in the history
* mep-0005.md: reactive state

* edit

* update readme
  • Loading branch information
akshayka authored Aug 31, 2023
1 parent 2626fb8 commit 2f31060
Show file tree
Hide file tree
Showing 2 changed files with 243 additions and 0 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
242 changes: 242 additions & 0 deletions mep-0005.md
Original file line number Diff line number Diff line change
@@ -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.

0 comments on commit 2f31060

Please sign in to comment.