State management with atomic module

Following @owen.campbell announcement:

New Project - Anvil Labs

Here’s a whistle-stop tour of a feature that we’ve added to the library.

example app:

  • todos with indexed_db storage
  • counter
  • server fetch
  • submission form

(To see an individual example in isolation, set the example as the startup form)
https://anvil.works/build#clone:IN4YLWJBNNS2HHA6=KS6RVKNVD5IVN3MSKUMBMYCF


Motivation:
State management is challenging, particularly when apps get large.


An Example:

# create an atom that holds state
from anvil_labs.atomic import atom, action, selector

@atom
class CountAtom:
    value = 0

    @selector
    def get_count(self):
        return self.value

    @action
    def update_count(self, increment):
        self.value += increment

count_atom = CountAtom()
# the form to display the count
from anvil_labs.atomic import render
from ..atoms.count import count_atom

class Count(CountTemplate):
    def __init__(self):
        self.display_count()

    @render
    def display_count(self):
        # I get called any time the get_count return value changes
        self.count_lbl.text = count_atom.get_count()

    def neg_btn_click(self, **event_args):
        count_atom.update_count(-1)

    def pos_btn_click(self, **event_args):
        count_atom.update_count(1)

Dui6eTt8Ng

Discussion
In this example, whenever a button is clicked,

  • the button event handler calls an action on the atom,
  • which updates the state of the atom,
  • which then updates any selectors that depend on that state change,
  • and finally, any render methods that depend on those updates are re-rendered.
  • …repeat.

ActionState changeRe-compute selectorsCall render methods

This approach leads to separation of concerns:
A form’s job is to:

  • display the UI for the state; and
  • hook up component event handlers to actions.

An atom’s job is to:

  • handle the state

One benefit is that atoms are typically global objects, and any form can depend on them. i.e. no need to pass state up and down the parent form hierarchy.
You also don’t need to trigger updates of components - it happens automatically.


Limitations

  • It’s only been tested on toy examples from the clone link above.
  • So - not ready for production!
  • Currently, we don’t support Row object updates as part of the render cycle.
  • Documentation hasn’t been written yet

bindings and writebacks
Anvil’s data bindings and writebacks don’t play nicely with this library, so we’ve added our own.

We could write the above example as:

from anvil_labs.atomic import bind

class Count(CountTemplate):
    def __init__(self):
        bind(self.count_lbl, "text", count_atom.get_count)
        # or bind it to an attribute of an atom
        bind(self.count_lbl, "text", count_atom, "value")

writebacks work in a similar way:

# call signature:
writeback(component, prop, atom, atom_prop, events)
writeback(component, prop, selector, action, events)
# e.g.
writeback(self.check_box, "checked", self.item, "completed", events=["change"])

Final thoughts

If you (want to) try this as a dependency, please share your experience/problems/suggestions in the anvil-labs discussion page so that we can improve it.

3 Likes

Much of the naming seems intuitive to me, but the exception is selector. Any insights on how that word relates to its meaning in this context?

Selectors are basically clever getters. They cache their return values.
They only update their return values if an attribute they depend on changes.

The word is taken from redux, but I guess you could think of it as - a selector selects attributes and combines them in some way.

Mobx uses the word compute, as in you re-compute the value if an attribute changes.

In most cases a selector decorator isn’t required (eg in the toy example above it’s not worth caching the value for get_count). It’s most useful for an expensive getter.

1 Like

Puzzled by the way atoms.todo.update_db is defined, I was trying to understand what the writeback function call in Todo.ItemTemplate does. (It seemed to me that self.item in that writeback call was neither an atom nor a selector, but then I realized it is a DictAtom.)

So I guess my question is: when that DictAtom's `setitem is called, could/should the update requested propagate upward to any parent atoms, such as the ListAtom that contains it, in this case? (If that doesn’t make sense, I can try to explain more fully.)

edit: Answering my own question: No, probably not. But maybe it could work the other way. When update_db is registered as depending on the todos attribute, maybe it would make sense for it to be recursively registered as depending on any “child” atoms of todos: i.e., the “todo” DictAtom's here?

I’ve moved this discussion to the anvil-labs discussion page: How does the atomic module work · Discussion #13 · anvilistas/anvil-labs · GitHub.

2 Likes

This looks really interesting. Do you have any insight as to when this will be moved to the Anvil Extras module?

1 Like

Not really. There’s some work to do first - docs need writing (and need to be good, this thing ain’t simple), we’d like to at least know how we’re going to support a data tables persistence backend, preferably have implemented it, possibly more…

4 Likes

I should probably add the usual rider…

PRs are welcome!

2 Likes