Skip to content

LoveCommunity/love.dart

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Love

Build Status Coverage Status Pub

A state management library that is declarative, predictable and elegant.

Why

love has DNA of ReactiveX, Redux and RxFeedback. so it is:

  • Unified - one is all, all is one (System<State, Event>)
  • Declarative - system are first declared, effects begin after run is called
  • Predictable - unidirectional data flow
  • Flexible - scale well with complex app
  • Elegant - code is clean for human to read and write
  • Testable - system can be test straightforward

Table Of Contents

Libraries

Counter Example

// typedef CounterState = int;

abstract class CounterEvent {}
class Increment implements CounterEvent {}
class Decrement implements CounterEvent {}

void main() async {

  final counterSystem = System<int, CounterEvent>
    .create(initialState: 0)
    .add(reduce: (state, event) {
      if (event is Increment) return state + 1;
      return state;
    })
    .add(reduce: (state, event) {
      if (event is Decrement) return state - 1;
      return state;
    })
    .add(effect: (state, oldState, event, dispatch) {
      // effect - log update
      print('\nEvent: $event');
      print('OldState: $oldState');
      print('State: $state');
    })
    .add(effect: (state, oldState, event, dispatch) {
      // effect - inject mock events
      if (event == null) { // event is null on system run
        dispatch(Increment());
      }
    });

  final disposer = counterSystem.run();

  await Future<void>.delayed(const Duration(seconds: 6));

  disposer();
}

Output:


Event: null
OldState: null
State: 0

Event: Instance of 'Increment'
OldState: 0
State: 1

We hope the code is self explained. If you can guess what this code works for. That's very nice!

This example first declare a counter system, state is the counts, events are increment and decrement. Then we run the system to log output, after 6 seconds we stop this system.

The code is not very elegant for now, we have better way to approach same thing. We'll refactor code step by step when we get new skill. We keep it this way, because it's a good start point to demonstrates how it works.

Core

How it works?

State

State is data snapshot of a moment.

For Example, the Counter State is counts:

// typedef CounterState = int;

Event

Event is description of what happened.

For Example, the Counter Event is increment and decrement which describe what happened:

abstract class CounterEvent {}
class Increment implements CounterEvent {}
class Decrement implements CounterEvent {}

Reduce

Reduce is a function describe how state update when event happen.

typedef Reduce<State, Event> = State Function(State state, Event event);

Counter Example:

    ...
    .add(reduce: (state, event) {
      if (event is Increment) return state + 1;
      return state;
    })
    .add(reduce: (state, event) {
      if (event is Decrement) return state - 1;
      return state;
    })
    ...

If increment event happen we increase the counts, if decrement event happen we decrease the counts.

We can make it cleaner:

    ...
-   .add(reduce: (state, event) {
-     if (event is Increment) return state + 1;
-     return state;
-   })
-   .add(reduce: (state, event) {
-     if (event is Decrement) return state - 1;
-     return state;
-   })
+   .on<Increment>(
+     reduce: (state, event) => state + 1,
+   )
+   .on<Decrement>(
+     reduce: (state, event) => state - 1,
+   )
    ...

It's more elegant for us to read and write.

Note: Reduce is pure function that only purpose is to compute a new state with current state and event. There is no side effect in this function.

Then, how to approach side effect?

Effect

Effect is a function that cause observable effect outside.

typedef Effect<State, Event> = void Function(State state, State? oldState, Event? event, Dispatch<Event> dispatch);

Side Effects:

  • Presentation
  • Log
  • Networking
  • Persistence
  • Analytics
  • Bluetooth
  • Timer
  • ...

Below are log effect and mock effect:

    ...
    .add(effect: (state, oldState, event, dispatch) {
      // effect - log update
      print('\nEvent: $event');
      print('OldState: $oldState');
      print('State: $state');
    })
    .add(effect: (state, oldState, event, dispatch) {
      // effect - inject mock events
      if (event == null) { // event is null on system run
        dispatch(Increment());
      }
    });

Then, what about async stuff like networking effect or timer effect:

    ...
    .add(effect: (state, oldState, event, dispatch) {
      // effect - log update
      ...
    })
+   .add(effect: (state, oldState, event, dispatch) async {
+     // effect - auto decrease via async event
+     if (event is Increment) {
+       await Future<void>.delayed(const Duration(seconds: 3));
+       dispatch(Decrement());
+     }
+   })
    ...

We've add a timer effect, when an increment event happen, we'll dispatch a decrement event after 3 seconds to restore the counts.

We can also add persistence effect:

    ...
    .add(effect: (state, oldState, event, dispatch) async {
      // effect - auto decrease via async event
      ...
    })
+   .add(effect: (state, oldState, event, dispatch) {
+     // effect - persistence
+     if (event != null  // exclude initial state
+       && oldState != state // trigger only when state changed
+     ) {
+       print('Simulate persistence save call with state: $state');
+     }
+   },)
    ...

This persistence save function will be called when state changed, but initial state is skipped since most of time initial state is restored from persistence layer, there is no need to save it back again.

Run

We've declared our counterSystem:

final counterSystem = System<int, CounterEvent>
  ...;

It dose nothing until run is called:

final disposer = counterSystem.run();

When run is called, a disposer is returned. We can use this disposer to stop system later:

// stop system after 6 seconds

await Future<void>.delayed(const Duration(seconds: 6)); 

disposer();

Effect Details

Since effect plays an important role here, let's study it in depth.

Effect Trigger

We've added timer effect and persistence effect. For now, Instead of thinking what effect is it, let's focus on what triggers these effects:

    ...
    .add(effect: (state, oldState, event, dispatch) async {
      // effect - auto decrease via async event
      if (event is Increment) {
        await Future<void>.delayed(const Duration(seconds: 3));
        dispatch(Decrement());
      }
    })
    .add(effect: (state, oldState, event, dispatch) {
      // effect - persistence
      if (event != null  // exclude initial state
        && oldState != state // trigger only when state changed
      ) {
        print('Simulate persistence save call with state: $state');
      }
    },)
    ...

It's not hard to find the first timer effect is triggered on increment event happen, the second persistence effect is triggered by react to state changes.

Here, We have two kind of Effect Trigger:

  • Event Based Trigger
  • State Based Trigger

Event Based Trigger

Event Based Trigger will trigger effect when event meet some condition.

We have a series of operators (methods) that has prefix on to approach this better:

    ...
-   .add(effect: (state, oldState, event, dispatch) async {
-     // effect - auto decrease via async event
-     if (event is Increment) {
-       await Future<void>.delayed(const Duration(seconds: 3));
-       dispatch(Decrement());
-     }
-   })
+   .on<Increment>(
+     effect: (state, event, dispatch) async {
+       // effect - auto decrease via async event
+       await Future<void>.delayed(const Duration(seconds: 3));
+       dispatch(Decrement());
+     },
+   )
    ...

We can even move effect around reduce when they share same condition:

    ...
    .on<Increment>(
      reduce: (state, event) => state + 1,
+     effect: (state, event, dispatch) async {
+       // effect - auto decrease via async event
+       await Future<void>.delayed(const Duration(seconds: 3));
+       dispatch(Decrement());
+     },
    )
    .on<Decrement>(
      reduce: (state, event) => state - 1,
    )
    ...
-   .on<Increment>(
-     effect: (state, event, dispatch) async {
-       // effect - auto decrease via async event
-       await Future<void>.delayed(const Duration(seconds: 3));
-       dispatch(Decrement());
-     },
-   )
    ...

There are special cases. for example, we want to dispatch events on system run:

    ...
    .add(effect: (state, oldState, event, dispatch) {
      // mock events
      if (event == null) { // event is null on system run
        dispatch(Increment());
      }
    },);

We can use onRun operator instead:

    ...
-   .add(effect: (state, oldState, event, dispatch) {
-     // mock events
-     if (event == null) { // event is null on system run
-       dispatch(Increment());
-     }
-   },);
+   .onRun(effect: (initialState, dispatch) {
+     // mock events
+     dispatch(Increment());
+     return null;
+   },);

We have other on* operators for different use cases. Learn more please follow the API Reference:

  • on
  • onRun
  • onDispose

State Based Trigger

State Based Trigger will trigger effect by react to state change.

We have a series of operators that has prefix react to approach this:

    ...
-   .add(effect: (state, oldState, event, dispatch) {
-     // effect - persistence
-     if (event != null  // exclude initial state
-       && oldState != state // trigger only when state changed
-     ) {
-       print('Simulate persistence save call with state: $state');
-     }
-   },)
+   .react<int>(
+     value: (state) => state,
+     effect: (value, dispatch) {
+       // effect - persistence
+       print('Simulate persistence save call with state: $value');
+     },
+   )
    ...

This effect will react to state change then trigger a save call. Since it react to whole state (not partial value) change, we can use a convenience operator reactState instead, then we don't need a value map function here:

-   .react<int>(
-     value: (state) => state,
-     effect: (value, dispatch) {
-       // effect - persistence
-       print('Simulate persistence save call with state: $value');
-     },
-   )
+   .reactState(
+     effect: (state, dispatch) {
+       // effect - persistence
+       print('Simulate persistence save call with state: $state');
+     },
+   )

There is another important effect which use this trigger. Can you guess what is it?

Hit: Flutter or React.

Yes, it's presentation effect. With declarative UI library like Flutter or React, build (render) is triggered by react to state change. We'll discuss this soon.

There are other react* operators for different use cases. Learn more please follow API Reference:

  • react
  • reactLatest
  • reactState

Presentation Effect (With Flutter)

We've mentioned presentation effect is triggered by react to state change with declarative UI library:

  .reactState(
    effect: (state, dispatch) {
      print('Simulate presentation effect (build, render) with state: $state');
    },
  )

Since Flutter is full of widgets. How can we make react* operators work together with widget?

Is this possible:

  // bellow are just imagination that only works in our mind
  .reactState(
    effect: (state, dispatch) {
      return TextButton(
        onPressed: () => dispatch(Increment()),
        child: Text('$state'),
      );
    },
  )

Yeah, we can introduce React* widgets, they are combination of react* operators and widget:

Widget build(BuildContext context) {
  return ReactState<int, CounterEvent>(
    system: counterSystem,
    builder: (context, state, dispatch) {
      return TextButton(
        onPressed: () => dispatch(Increment()),
        child: Text('$state'),
      );
    }
  );
}

Happy to see Flutter and React works together ^_-.

Learn more please visit flutter_love.

Log Effect

We've introduced how to add log effect:

    ...
    .add(effect: (state, oldState, event, dispatch) {
      print('\nEvent: $event');
      print('OldState: $oldState');
      print('State: $state');
    })
    ...

Output:

Event: null
OldState: null
State: 0

Event: Instance of 'Increment'
OldState: 0
State: 1

Log is a common effect, so this library provide built-in log operator to address it:

    ...
-   .add(effect: (state, oldState, event, dispatch) {
-     print('\nEvent: $event');
-     print('OldState: $oldState');
-     print('State: $state');
-   })
+   .log()
    ...

Output becomes:

System<int, CounterEvent> Run
System<int, CounterEvent> Update {
  event: null
  oldState: null
  state: 0
}
System<int, CounterEvent> Update {
  event: Instance of 'Increment'
  oldState: 0
  state: 1
}
System<int, CounterEvent> Dispose

As we see, log operator can do more with less code, it not only log updates, but also log system run and dispose which maybe helpful for debug.

log is a scene focused operator which scale the log demand followed with a detailed solution. If we are repeatedly write similar code to solve similar problem. Then we can extract operators for reusing solution. log is one of these operators.

Other Operators

There are other operators may help us achieve the goals. We'll introduce some of them.

ignoreEvent

Ignore event based on current state and candidate event.

  futureSystem
    .ignoreEvent(
      when: (state, event) => event is TriggerLoadData && state.loading
    )
    ...

Above code shown if the system is already in loading status, then upcoming TriggerLoadData event will be ignored.

debounceOn

Apply debounce logic to some events.

  searchSystem
    ...
    .on<UpdateKeyword>(
      reduce: (state, event) => state.copyWith(keyword: event.keyword)
    )
    .debounceOn<UpdateKeyword>(
      duration: const Duration(seconds: 1)
    )
    ...

Above code shown if UpdateKeyword event is dispatched with high frequency (quick typing), system will drop these events to reduce unnecessary dispatching, it will pass event if dispatched event is stable.

Appendix

Code Review

We've refactored our code a lot. Let's review it to increase muscle memory.

Old Code:

final counterSystem = System<int, CounterEvent>
  .create(initialState: 0)
  .add(reduce: (state, event) {
    if (event is Increment) return state + 1;
    return state;
  })
  .add(reduce: (state, event) {
    if (event is Decrement) return state - 1;
    return state;
  })
  .add(effect: (state, oldState, event, dispatch) {
    print('\nEvent: $event');
    print('OldState: $oldState');
    print('State: $state');
  })
  .add(effect: (state, oldState, event, dispatch) async {
    if (event is Increment) {
      await Future<void>.delayed(const Duration(seconds: 3));
      dispatch(Decrement());
    }
  })
  .add(effect: (state, oldState, event, dispatch) {
    if (event != null
      && oldState != state
    ) {
      print('Simulate persistence save call with state: $state');
    }
  },)
  .add(effect: (state, oldState, event, dispatch) {
    if (event == null) { 
      dispatch(Increment());
    }
  });

New Code:

final counterSystem = System<int, CounterEvent>
  .create(initialState: 0)
  .on<Increment>(
    reduce: (state, event) => state + 1,
    effect: (state, event, dispatch) async {
      await Future<void>.delayed(const Duration(seconds: 3));
      dispatch(Decrement());
    },
  )
  .on<Decrement>(
    reduce: (state, event) => state - 1,
  )
  .log()
  .reactState(
    effect: (state, dispatch) {
      print('Simulate persistence save call with state: $state');
    },
  )
  .onRun(effect: (initialState, dispatch) {
    dispatch(Increment());
    return null;
  },);

Testing

Test can be done straightforward:

  1. create system
  2. inject mock events and mock effects
  3. record states
  4. run the system
  5. expect recorded states
test('CounterSystem', () async {

  final List<State> states = [];

  final counterSystem = System<int, CounterEvent>
    .create(initialState: 0)
    .on<Increment>(
      reduce: (state, event) => state + 1,
    )
    .on<Decrement>(
      reduce: (state, event) => state - 1,
    );

  final disposer = counterSystem.run(
    effect: (state, oldState, event, dispatch) async {
      states.add(state);
      if (event == null) {
        // inject mock events
        dispatch(Increment());
        await Future<void>.delayed(const Duration(milliseconds: 20));
        dispatch(Decrement());
      }
    },
  );

  await Future<void>.delayed(const Duration(milliseconds: 60));

  disposer();

  expect(states, [
    0, // initial state
    1,
    0,
  ]);
  
});

Credits

Without community this library won't be born. So, thank ReactiveX community, Redux community and RxSwift community.

Thank @miyoyo for giving feedback that helped us shape this library.

Special thank to @kzaher who is original author of RxSwift and RxFeedback, he shared a lot of knowledge with us, that make this library possible today.

Last and important, thank you for reading!

License

The MIT License (MIT)

End is Start