A state management library that is declarative, predictable and elegant.
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
- love - dart only state management library
- flutter_love - provide flutter widgets handle common use case with love
- flutter_love_provider - provide flutter widgets to support solution based on love and provider
// 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.
How it works?
State is data snapshot of a moment.
For Example, the Counter State is counts:
// typedef CounterState = int;
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 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 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.
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();
Since effect plays an important role here, let's study it in depth.
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 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 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?
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
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.
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.
There are other operators may help us achieve the goals. We'll introduce some of them.
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.
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.
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;
},);
Test can be done straightforward:
- create system
- inject mock events and mock effects
- record states
- run the system
- 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,
]);
});
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!
The MIT License (MIT)