xr 0.0.2
xr: ^0.0.2 copied to clipboard
XR is a simple state management library based on fundamental ideas of Computer Science
XR #
XR is a simple state management library. Idea is to go back to the fundamentals. It is XR because it is the opposite of Rx which a lot of "reactive" libraries use.
Why "state management"? #
Why do we need state management? When some piece of data changes, we want the relevant UI to also change; more generally: when something happens, we want to be able to "react" to it. It may be making an API call or updating the shared preferences or whatever or producing new state that the rest of the code uses.
Why yet another state management library #
I wanted to build a library that -
- Is based on fundamental ideas of computer science
- Has 0 dependencies
- Is declarative as far as possible
- Ideally a single file that can be copied and pasted into the codebase if required.
- For fun?
Fundamental Theory #
In any application, fundamentally what we are doing is reacting to events. When button is clicked, show this text, when this other button is clicked, open this page. Sometimes, it is a chain, when user clicks on login call the API. When the API is successful, navigate to the home page, and so on.
If we see, what we have is a Graph. Each "event" is a node, and the adjacent nodes are what we want to execute when the event happens. Then, reactivity is just the ability to traverse this graph from a starting node when something changes. But we dont want arbitrary graphs. What we want is a directed acyclic graph
It is directed because if Node A depends on B, does not imply Node B has any relation with A. And it should be acyclic because if there are cycles, the traversal will never end.
The keen eyed folks must have noticed, what we want is an even special case of graphs. What we want is a Mealy Machine (except cycles because we do want the computation to terminate :P). The new state is computed with the current state and the input together.
What happens when something changes? #
When an event triggers i.e. something changes, we schedule a microtask (if not already scheduled). The microtask creates a topological sort of the graph and allows the updated nodes to get their new value in this sorted order.
Features #
TODO: List features
Getting started #
TODO: Add Getting Started
Usage #
XR has two fundamental concepts -
- Events and
- Reactions
Ideally, we would only have one but I wanted very different APIs for the two.
Event #
Events represent a value that can be changed. This can be things like current user, theme, etc.
/// 0 is initial value. Second parameter is the optional debug name.
/// Useful only for debugging. If provided, will be used to implement toString()
///
/// With 2nd param toString: 'Event{debugName: $debugName, value: $value}'
/// Without 2nd param toString: 'Event{value: $value}'
final event = Event(0, 'event1');
/// check if value has changed. If so, notify reactors that something has changed
event.setValue(1);
/// prints 1
print(event.value);
/// Override value and notify reactors something is changed without checking
/// if value really changed or not.
///
/// Can be useful to manually re-trigger reactors
event.forceSetValue(1);
NOTE: Even though setValue immediately changes the value, the reactors are notified some time later in a microtask
VoidEvent
There is a special case of Event. Something, you just want to trigger some computation. It might not have associated data with it.
/// the parameter is an optional debugName. Similar to Event
final event = VoidEvent('event1');
/// trigger all the reactors
event.happend();
NOTE: Just like Event, the reactors are notified some time later in a microtask
Reactor #
This is what makes the library reactive and declarative. Reactor is a pair of two things - a function that returns the current value and a set of dependencies.
Reactions are lazy. The function is only called when either -
- at least one dependency changes.
- Someone tries to access
reactor.valuefor the first time.
Dependency can be either other Reactors, Events or VoidEvents. Most of
your code should ideally be a Reactor.
final event = Event(0);
/// Reactor has 3 parameters -
///
/// 1. A generator function - This function takes the reactor itself as a first
/// parameter (we will see its use shortly) and returns a new value.
/// This makes reactors powerful. Not only can they listen to changes to events
/// but they can also return new values that other reactors can listen to.
///
/// 2. A set of dependencies - Dependency can be either Reactor, Event or VoidEvent.
/// The order does not matter. Hence the use of Set and not a list. When any dependency
/// changes, the generator function is called.
///
/// 3. Optional debug name. Again, used for toString implementation
final reactor = Reactor((_) {
print('reactor fired!');
return event.value + 1;
}, {event}, 'reactor');
event.setValue(1);
/// the reactor is updated in a microtask asynchronously. If we don't
/// wait, the program will just exit without printing anything
await Future.value(); // prints 1
/// also prints 1
///
/// value is a getter. If the generator function is never called before,
/// the value will call the generator function to initialize itself. Otherwise
/// it will return the current value. This has an interesting consequence.
/// Will discuss more about this later
print(reactor.value)
/// rawValue is also like value. But it is an monadic optional. The difference
/// is if it is not initialized, we can use the empty callback to do something
/// about it. In this case, if rawValue is empty, we are returning the string
/// 'empty' otherwise, we are returning the actual value
print(
reactor.rawValue.when(
empty: () => 'empty',
value: (value) => value
)
);
Example #
Counter App #
Let's start with the classic counter (for full app, look at example/counter.dart).
final counter = Event(0);
/// somewhere in the UI
Column(
children: [
ReactorBuilder(
node: counter,
builder: (_) => Text('${counter.value}'),
),
ElevatedButton(
child: Text('Increment'),
onClick: () => counter.setValue(counter.getValue() + 1);
),
]
);
While the above code works, it is very imperative. Let's try again
/// VoidEvent because buttonClick does not need any data
final buttonClicked = VoidEvent();
/// Here we are defining a reactor. And in the generator function, I told
/// you, we get the counter itself. Well, now you will see why that is useful.
///
/// As a rule of thumb, you cannot use <current reactor>.value in the generator function,
/// because it will not be initialized the first time the counter is run. Which means
/// in this example, we cannot use `counter.value`. So, we use the rawValue.
///
/// if value is not initialized, the empty callback is called. So we return 0.
/// if value is initialized, the value callback is called and we return whatever
/// value was there.
final counter = Reactor((counter) {
int count = counter.rawValue.when(
empty: () => 0,
value: (value) => value,
);
return count + 1;
}, {buttonClicked});
/// somewhere in the UI
Column(
children: [
ReactorBuilder(
node: counter,
builder: (_) => Text('${counter.value}'),
),
ElevatedButton(
child: Text('Increment'),
onClick: () => counter.setValue(counter.getValue() + 1);
),
],
),
DAG and Mealy Machine #
The above example is good to showcase the library but not good enough for formalism. Consider the following exmaple
void main() async {
final one = Event(0);
final two = Reactor((_) => one.value + 1, {one});
final three = Reactor((_) => one.value + 2, {one});
final four = Reactor((_) => two.value + three.value, {two, three});
one.setValue(1);
/// Again, NOTE: the reactors are not called immediately. So we use this future
/// to wait for two and three to be updated
await Future.value();
expect(two.value, equals(2));
expect(three.value, equals(3));
/// Again, changes in two and three causes four to be marked as changed.
/// (and again, it is **NOT** updated synchronously. It again, creates a new
/// microtask. this future awaits for four to be updated
await Future.value();
expect(four.value, equals(5));
}
Do you see the Directed Acyclic Graph?
Mealy machines are finite automate that can update state based on
not just on the change but also current state. Remember the first argument
in the generator function is the reactor itself? Just like the previous example,
because you can access the current reactor and read its value using rawValue,
and compute new state, hence, this library is also an implementation of the Mealy
Machine.