Description
Some additional experimentation has highlighted a useful pattern for state management and coordination. I want to collect some feedback on the following to help pin down the API.
Problem No1: Keeping state in sync is hard.
Seed has very straightfoward, sensible and mostly efficient state management.
- A view function renders completely a
Node<Ms>
tree view based on theMdl
. - An event triggers an
update
which mutates theMdl
- This causes a complete new
Node<Ms>
tree to be created via the view function.
That said having monolithic model to hold all state is not hugely expressive when it comes to ensuring state remains in sync, or when one state depends on other state.
For instance consider rendering a filtered list of thousands of elements based on a selected criteria. There are a number of existing ways to do ths.
a) In the view function let items = model.items.iter().filter_map( criteria_based_on model.criteria )
Simple ... but the problem with this is that it has to run every single update, regardless to whether the items in the model or the filter criteria have changed.
b) Manually update a cache of filtered items when either modifying the items themselves or changing the filter criteria. This is more efficent than (a), however it requires that the developer has remembered to correctly update the cache in both scenarios. What happens if additional criterias are added? or there are additional ways to add or remove items from model in update function. Or another state depends upon the filtered_list? At each stage the developer has to carefully ensure that the cached filtered list and any subsequent state is correctly generated.
What would be better is this:
c) The view function contains is a UI snippet that depends on computed state called filtered_list
.
filtered_list
is computed state that depends on the list
state 'atom' and any number of criteria
state atoms.
These atoms are the source of truth and do not depend upon other state.
Then when the list
or criteria
s are mutated, the filtered_list
and then the UI snippet are calculated automatically.
There is no possibilty of showing invalid data because the UI snippet is precisely generated from changes to the list
or critera
s.
Problem No2: As long as Seed's state remains a monolith Model additional optimisations are hard to do.
(This problem is really not an issue at present but is more one for the future)
Seeds monolith is fine for small to medium size apps, however when scaling to large apps with potentially thousands of changing dom elements this is could block optimsation. The reason for this is that there is no way to determine which parts of the UI tree correspond directly to specific mutations in the Mdl
.
For instance consider two deep but separated leaf nodes on either side of the UI that both need to read Model.color
. Maybe one being a background color setting in a preference pane and the other being the background of a highlighted element. Seed currently needs to reconstruct the entire view tree, which could mean parsing hundreds of nodes macros or views (each passing down a reference to Model) before finally allowing the two leaf nodes to access Model.count
.
It might be better if both leaf nodes could be automatically updated without having to reconstruct the entire Node<Ms>
from scratch. This in effect could simply be two mutations in the leaves of a very large tree. Rather than reconstruct the entire tree every update frame.
Potential Solution
As outlined in (a) there is a potential solution if we can create a graph of state dependencies originating in 'state atoms' and terminating in UI elements. This way specific UI elements only ever get updated if the specific state which they subscribe to changes.
How might this work in practise? The following is currently (working) proof of concept code.
We define atoms of state, in this case todos and filter criteria:
#[atom(undo)]
fn todos(idx: i32)-> Vec<Todo>{
vec![]
}
#[atom]
fn filter_critera()-> FilterStatus {
FilterStatus::ShowAll
}
We define a computed state , filtered todos which subscribes to todos
and filter_criteria
:
#[computed]
fn filtered_todos() -> Vec<Todo> {
let filter = link_state(filter_criteria());
let todos = list_state(todos());
match filter {
FilterStatus::ShowAll => todos,
FilterStatus::Complete => todos.iter().filter(|t| t.completed).collect::<Vec<_>>(),
FilterStatus::InComplete => todos.iter().filter(|t| t.completed).collect::<Vec<_>>(),
}
}
Also we define a computed state which renders the UI based on the filtered todos:
[computed]
fn filtered_todos_list() -> Node<Msg>{
let todos = link_state(filtered_todos());
ul![
todos.iter().map(|t|
li![
t.description,
button!["X", mouse_ev(Ev::Click,|_| TodoItemCompleted(t.id))]
]
)
]
}
With the above setup, the computed UI will always by definition show the correct filtered state because it is automatically generated whenver the list state changes.
Additional benefits
Additional benefits from this approach is that implementing scoped undos is trivial, because state atoms can keep a memo log of their previous values. Further one can do partial computation for instance a UI snippet could depend on computed state which fetches remote data. Whist the data is fetching the UI snippet could show a "loading..." status and once fetched the UI snippet would automatically update itself to show the loaded state.
Here is an example of automatic undo on the above list example:
https://recordit.co/Am5hlZE7OC
Good talk demonstating these concepts in React: