GithubHelp home page GithubHelp logo

lovecommunity / love.dart Goto Github PK

View Code? Open in Web Editor NEW
5.0 3.0 1.0 304 KB

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

Home Page: https://pub.dev/packages/love

License: MIT License

Dart 100.00%
state-management functional dart declarative predictable flutter

love.dart's Introduction

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

love.dart's People

Contributors

beeth0ven avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar

Forkers

beeth0ven

love.dart's Issues

refactor - rename `areEqual` to `equals` to be consistent with dart

Dart language prefers equals to describe equality:
https://pub.dev/documentation/collection/latest/collection/Equality/equals.html

We are planing to make these changes for consistency:

- typedef AreEqual<T> = bool Function(T it1, T it2);
+ typedef Equals<T> = bool Function(T it1, T it2);
System<State, Event> reactState({
- AreEqual<State>? areEqual,
+ Equals<State>? equals, 
  bool skipInitialState = true,
  required void Function(State state, Dispatch<Event> dispatch) effect,
}) { ... }

break - refactor - rename extension names to have a `x` suffix

As x stands for Extension, for example Rx is short for ReactiveExtension.

We suggest to naming extension with a x suffix:

- extension LogOperators<State, Event> on System<State, Event> { ... }
+ extension LogX<State, Event> on System<State, Event> { ... }

- extension ReactOperators<State, Event> on System<State, Event> { ... }
+ extension ReactX<State, Event> on System<State, Event> { ... }
...
- export 'src/systems/log_on_system.dart';
+ export 'src/systems/log_x.dart';

- export 'src/systems/react_on_system.dart';
+ export 'src/systems/react_x.dart';
...

It's easy to read and write.

refactor - rename parameter `skipFirst*` to `skipInitial*`

We are trying to improve code readbility by rename parameter skipFirst* to skipInitail*:

before:

    .reactState(
      skipFirstState: true,
      effect: (state, dispatch) { ... },
    )

after:

    .reactState(
      skipInitialState: true,
      effect: (state, dispatch) { ... },
    )

Related Operators:

  • system.react
  • system.reactLatest
  • system.reactState

feature - add `system.log` operator for debug

issued from discussion #76.

Before

We have explained how to add log effect from docs:

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

output:

Event: null
State: 0
OldState: null

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

After:

We are proposal to add built in log operator:

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

Output:

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

It not only log when system update state, but also log when systen run and dispose which maybe useful for debugging.

This log operator will provide configuration for customization like this:

  system
    .log(
      onRun: (initialState) {
        customLogger.log('CounterSystem run');
      },
      onUpdate: (state, oldState, event) {
        customLogger.log('CounterSystem update state: $state');
      },
      onDispose: () {
        customLogger.log('CounterSystem dispose');
      },
    )

This will override default log behavior with our custom implementation.

We are trying to design this operator with a simple and flexible API.

feature - add event filter operators like `system.ignoreEvent`, `system.debounceOn`

We are proposal to add event filter operators. these operators can prevent unnecessary event dispatching. Here is initial imaging of these operators, it may change before we implementing them, but we issued the demand first:

  • system.ignoreEvent - ignore candidate event when it met some condition
  • system.debounceOn - apply debounce logic to some events

system.ignoreEvent

Ignore candidate event when it met some condition.

Usage:

  futureSystem
    ...
    .ignoreEvent(
      when: (state, event) => event is TriggerLoadData && state.loading // ignore trigger if already loading
    ) 
    ...

Above code shown if the system is already in loading status, then upcoming TriggerLoadData event will be ignored.
This operator will intercept event if some condition is met based on current state and the candidate event.

system.debounceOn

Apply debounce logic to some events

Usage:

  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 intercept these events to reduce unnecessary dispatching, it will pass (not inercept) event if 1 seconds has passed without dispatch another UpdateKeyword event.

feature - add `system.eventInterceptor` operator to intercept event

This issue is an extension to #84 (add event filter operators).
We are proposal to add low level system.eventInterceptor operaror, this operator can be used to support system.ignoreEvent and system.debounceOn operators as metioned in #84,

Here is initial imaging of low level system.eventInterceptor. The key point for this operator is, we are associating a Context with it:

  system
    .eventInterceptor<SomeContext>(
      createContext: () => ...// create context here,
      updateContext: (context, state, oldState, event, dispatch) {
          // update context here if needed.
      },
      interceptor: (context, dispatch, event) {
         // inercept event base on the context,
         // call `dispatch(event);` if we pass the event,
         // don't call `dispatch(event);` if we ignore the event.
      },
      dispose: (context) {
         // dispose the context if needed.
      }
    );
  }

With this low level operator, we can implement high level operator system.ignoreEvent like this:

class _IgnoreEventContext<State> {
  late State state;
}

extension FilterEventOperators<State, Event> on System<State, Event> {

  ...

  System<State, Event> ignoreEvent({
    required bool Function(State state, Event event) when
  }) {
    final test = when;
    return eventInterceptor<_IgnoreEventContext<State>>( //  <-- call `this.eventInterceptor`
      createContext: () => _IgnoreEventContext(),
      updateContext: (context, state, oldState, event, dispatch) {
        context.state = state; // cache current state in context
      },
      interceptor: (context, dispatch, event) {
        final _shouldIgnoreEvent = test(context.state, event);
        if (_shouldIgnoreEvent) return;
        dispatch(event);
      },
    );
  }
}

Usage of system.ignoreEvent:

  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.

We can treat system.ignoreEvent as a special case of system.eventInterceptor,
As an analogy, if we say system.ignoreEvent is a sqare, then system.eventInterceptor is a rectangle.

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    ๐Ÿ–– Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. ๐Ÿ“Š๐Ÿ“ˆ๐ŸŽ‰

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google โค๏ธ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.