xr 0.0.4 copy "xr: ^0.0.4" to clipboard
xr: ^0.0.4 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 -

  1. Is based on fundamental ideas of computer science
  2. Has 0 dependencies
  3. Is declarative as far as possible
  4. Ideally a single file that can be copied and pasted into the codebase if required.
  5. 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 don't 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.

P.S. XR is not just a Mealy Machine anymore. It is a full blown turing machine. But more about it later.

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.

Getting started #

flutter pub add xr

Usage #

XR has two fundamental concepts -

  1. Events and
  2. 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);
/// does not do anything because 1 == 1
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);

/// Event also stores the list of all previous values in order
/// prints [0, 1, 1] - first is initial value. second is first setValue.
/// third is forceSetValue. second setValue is not considered because it 
/// has the same value hence nothing is updated. forceSetValue always adds
/// a value to `values`.
/// 
/// **Note:** Just like `value`, updates to `values` is synchronous. All the
/// reactors might not have gotten the latest values
print(event.values)

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();
event.happend();
event.happend(); 

/// void event cant store the values because it does not have any value,
/// but it stores the number of times happened was called in count
/// prints 3
/// 
/// **Note:** count is updated synchronously whenever happened is called.
/// So reactors might be called fewer times than count if happened is called
/// more than once before microtask is executed
print(event.count);

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 -

  1. at least one dependency changes.
  2. Someone tries to access reactor.value for 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
  )
);

/// Reactors also maintain the list of previous generated values accessed using - 
/// `reactor.values`. This is empty the first time generator is called, so make 
/// sure to handle the empty list case. In general, you would likely want to look 
/// at last few values to decide
final reactor2 = Reactor((reactor2) {
  int sum = Random().nextInt(1000); /// add some random number
  
  for (final value in reactor2.values) {
    sum += value;  
  }
  
  return sum;
}, {reactor});

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(
      dependsOn: {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(
      dependsOn: {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 example

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 automata 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.

Turing Machines vs Mealy Machines #

Mealy Machines are Finite State Automata that can only produce new states based on previous states and current inputs. They can be generalized into Push Down Automata which can have memory in the form of a "stack". But XR exposes a list of values. It is random access. Hence, we still need to generalize to Turing Machines

Handling Async Operations #

Most of the apps do some kind of async operations - network, disk (shared prefs, sqlite), etc. While the built in primitives are sufficient, there is a helper provided by the library: AsyncReactor

AsyncReactor is just like a normal Reactor. In fact, it is simply a wrapper over a normal Reactor.

final one = VoidEvent();
int twoCount = 0;
const delay = Duration(milliseconds: 100);

/// AsyncReactor also takes 3 arguments
/// 1. Async function that takes the AsyncReactor returns the current value.
/// This is useful just like reactor. All attributes of AsyncReactor like
/// rawValue or values can be used to generate the next state
/// 2. Set of dependencies that when changed, the value needs to be regenerated.
/// Order does not matter, hence using Set instead of List
/// 3. Optional debug name - Used for toString() implementation if provided
final two = AsyncReactor(
      (_) {
    ++twoCount;
    return Future.delayed(delay, () => twoCount);
  },
  {one},
  'debug-name'
);

/// At any given time, AsyncReactor is in one of 4 states that can be accessed
/// using rawValue - 
/// 1. pending - generator function is not yet called because no dependency has 
/// changed and no one has tried to access value.
/// 2. loading - some data is getting loaded
/// 3. error - operation threw an exception. In this case, we get error and stacktrace
/// as parameters
/// 4. success - data was loaded successfully
two.rawValue.when(
  pending: () => {},
  loading: () => {},
  error: (error, stacktrace) => {},
  success: (data) => {},
);

/// value is basically same as rawValue. Except, if rawValue is in pending
/// state, value will call the generator function and set status to loading.
/// 
/// Just like if reactor is not initialized, calling value will call the
/// generator and set status to loading
two.value.when(
  pending: () => {},
  loading: () => {},
  error: (error, stacktrace) => {},
  success: (data) => {},
);

/// Just like reactor, values is a list of all the previous states of the 
/// AsyncReactor. Difference is, for a single trigger, there might be upto 3 values.
/// values[0] is always pending state.
/// 
/// The below code might print the following for success case - 
/// Pending -> Loading -> Success
/// The below code might print the following for error case - 
/// Pending -> Loading -> Error
print(two.values)

/// Just like reactor, once dispose is called, the generator function stops
/// getting called when a dependency is changed.
two.dispose();

Note:

  1. AsyncReactor is just a small wrapper on top of Reactor and Event. You can check the implementation here
  2. AsyncReactor SHOULD NOT be used as a dependency for reactors. If you need to depend on AsyncReactor, use AsyncReactor.event. Like -
final ar1 = AsyncReactor((_) => 0, {});
final reactor1 = Reactor((_) => 42, {ar1.event});
0
likes
130
points
34
downloads

Publisher

unverified uploader

Weekly Downloads

XR is a simple state management library based on fundamental ideas of Computer Science

Repository (GitLab)
View/report issues

Documentation

API reference

License

MIT (license)

Dependencies

flutter

More

Packages that depend on xr