A light, powerful and reactive state management.
Features
- ⚡️ Build for speed.
- 📏 Reduce boilerplate code significantly.
- 📝 Improve code readability.
- 🪃 Unidirectional data flow.
- ☢️ Reactive state using Signal.
- ♻️ Reuse state creating custom hooks.
- 🪄 No configuration necessary.
- 🎮 Total control to re-render widget tree.
- 💙 Flutter or Dart only, you can use in any Dart project.
Let's see a small and simple example:
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_reactter/flutter_reactter.dart';
// Create a reactive state using `Signal`
final count = 0.signal;
void main() {
// Put on listen `didUpdate` event, whitout use `Stream`
Reactter.on(count, Lifecycle.didUpdate, (_, __) => print('Count: $count'));
// Change the `value` in any time.
Timer.periodic(Duration(seconds: 1), (_) => count.value++);
// And you can use in flutter like this:
runApp(
MaterialApp(
home: Scaffold(
body: Center(
child: ReactterWatcher(
builder: (context, child) {
// This will be re-built, at each count change.
return Text(
"$count",
style: Theme.of(context).textTheme.headline3,
);
},
),
),
),
),
);
}
Clean and easy!
See more examples here!
Contents
- Quickstart
- Usage
- Create a
ReactterContext - Lifecycle of
ReactterContext - Shortcuts to manage instances
- Shortcuts to manage events
- Using
UseContexthook - Using
UseEventhook - Using
Signal - Using
UseStatehook - Different between
UseStateandSignal - Using
UseAsyncStatehook - Using
UseReducerhook - Using
UseEffecthook - Custom hook with
ReactterHook
- Create a
- Usage with
flutter_reactter - Resources
- Documentation
- Examples
- Roadmap
- Contribute
- Authors
Quickstart
Before anything, you need to be aware that Reactter is distributed on two packages, with slightly different usage.
The package of Reactter that you will want to install depends on the project type you are making.
Select one of the following options to know how to install it:
Dart only
Add the package on your project.
-
Using command:
dart pub add reactter -
Or put directly into
pubspec.yamlfile:dependencies: reactter: #add version hereand run
dart pub get.
Now in your Dart code, you can use:
import 'package:reactter/reactter.dart';
Flutter
Add the package on your project.
-
Using command:
flutter pub add flutter_reactter -
Or put directly into
pubspec.yamlfile:dependencies: flutter_reactter: #add version hereand run
flutter pub get.
Now in your Dart code, you can use:
import 'package:flutter_reactter/flutter_reactter.dart';
Usage
Create a ReactterContext
ReactterContext is a abstract class that allows to manages ReactterHook and provides life-cycle events.
You can use it's functionalities, creating a class that extends it:
class AppContext extends ReactterContext {}
You can use the shortcuts to manage instances or using UseContext hook to access it.
RECOMMENDED: Name class with
Contextsuffix, for easy locatily.
NOTE: In flutter, using
ReactterProvider, it's a way to share the state between widgets without having to explicitly pass a property through every level of the tree.
Lifecycle of ReactterContext
ReactterContext has the following Lifecycle events:
-
Lifecycle.registered: Event when the instance has registered byReactterInstanceManager. -
Lifecycle.unregistered: Event when the instance has unregistered byReactterInstanceManager. -
Lifecycle.inicialized: Event when the instance has inicialized byReactterInstanceManager. -
Lifecycle.willMount: Event when the instance will be mount in the widget tree (it use withflutter_reactteronly). -
Lifecycle.didMount: Event when the instance did be mount in the widget tree (it use withflutter_reactteronly). -
Lifecycle.willUpdate: Event when any instance's states will be update. Event param is aReactterState. -
Lifecycle.didUpdate: Event when any instance's states did be update. Event param is aReactterState. -
Lifecycle.willUnmount: Event when the instance will be unmount in the widget tree(it use withflutter_reactteronly). -
Lifecycle.destroyed: Event when the instance did be destroyed byReactterInstanceManager.
You can use the shortcuts or UseEvent hook to listen to these events.
Shortcuts to manage instances
Reactter provides a some shortcuts to manage instances, these are:
-
Reactter.register: Registers abuilderfunction to allows to create the instance usingReactter.get.Reactter.register(builder: () => AppContext()); Reactter.register(id: "uniqueId", builder: () => AppContext()); -
Reactter.unregister: Removes thebuilderfunction to avoid create the instance.Reactter.unregister<AppContext>(); Reactter.unregister<AppContext>("uniqueId"); -
Reactter.get: Gets the previously instance created or creates a new instance from thebuildregistered usingreactter.register.final appContext = Reactter.get<AppContext>(); final appContextWithId = Reactter.get<AppContext>(id: 'uniqueId'); -
Reactter.create: Registers, creates and gets the instance directly.final appContext = Reactter.create(builder: () => AppContext()); final appContextWithId = Reactter.create(id: 'uniqueId', builder: () => AppContext()); -
Reactter.delete: Deletes the instance but still keep thebuilderfunction.Reactter.delete<AppContext>(); Reactter.delete<AppContext>('uniqueId');
NOTE: The registered instances have a global scope. This means that you can access them anywhere in the project just by using
Reactter.getor throughUseContext.
Shortcuts to manage events
Reactter provides a some shortcuts to manage events, these are:
-
Reactter.on: Puts on to listen event. When the event is emitted, thecallbackis called:void _onDidUpdate(inst, state) { print("Instance: $inst, state: $state"); } final appContext = Reactter.get<AppContext>(); Reactter.on(appContext, Lifecycle.didUpdate, _onDidUpdate); // or Reactter.on(ReactterInstance<AppContext>(), Lifecycle.didUpdate, _onDidUpdate); -
Reactter.one: Puts on to listen event only once. When the event is emitted, thecallbackis called and after removes event.void _onDestroyed(inst, _) { print("$inst was destroyed."); } Reactter.one(appContext, Lifecycle.destroyed, _onDestroyed); // or Reactter.one(ReactterInstance<AppContext>(), Lifecycle.destroyed, _onDestroyed); -
Reactter.off: Removes thecallbackof event.Reactter.off(appContext, Lifecycle.didUpdate, _onDidUpdate); // or Reactter.off(ReactterInstance<AppContext>(), Lifecycle.didUpdate, _onDidUpdate); -
Reactter.emit: Trigger event with or without theparamgiven.Reactter.emit(appContext, CustomEnum.EventName, "test param"); // or Reactter.emit(ReactterInstance<AppContext>(), CustomEnum.EventName, "test param"); -
Reactter.emitAsync: Trigger event with or without theparamgiven as async way.await Reactter.emitAsync(appContext, CustomEnum.EventName, "test param"); // or await Reactter.emitAsync(ReactterInstance<AppContext>(), CustomEnum.EventName, "test param");
NOTE: The
ReactterInstancehelps to find the instance for event. This instance must have been created earlier in the Reactter context.RECOMMENDED: Use the instance directly on event methods for optimal performance.
Using UseContext hook
UseContext is a ReactterHook that allows to get ReactterContext's instance when it's ready.
class AppContext extends ReactterContext {
late final otherContextHook = UseContext<OtherContext>(context: this);
// late final otherContextHookWithId = UseContext<OtherContext>(id: "uniqueId", context: this);
late otherContext = otherContext.instance;
AppContext() {
UseEffect(() {
otherContext = otherContextHook.instance;
}, [otherContextHook]);
}
}
NOTE: If you're not sure that you got the instance from the beginning, you need to use the
UseEffectas shown in the example above.NOTE: The context that you need to get, must be created by
ReactterInstanceManager.
Using UseEvent hook
UseEvent is a ReactterHook that manages events.
You can listen to event using on method:
enum Events { SomeEvent };
void _onSomeEvent(inst, param) {
print("$inst's Events.SomeEvent emitted with param: $param.");
}
UseEvent<AppContext>().on(Events.SomeEvent, _onSomeEvent);
Use off method to stop listening event:
UseEvent<AppContext>().off(Events.SomeEvent, _onSomeEvent);
If you want to listen event only once, use one method:
UseEvent<AppContext>().one(Events.SomeEvent, _onSomeEvent);
And use emit method to trigger event:
UseEvent<AppContext>().emit(Events.SomeEvent, 'Parameter');
IMPORTANT: Don't forget to remove event using
offor usingdisposeto remove all instance's events. Failure to do so could increase memory usage or have unexpected behaviors, such as events in permanent listening.RECOMMENDED: If you have the instance, use directly with
UseEvent.withInstance(<instance>)for optimal performance.
Using Signal
Signal is a class that store a value of any type and notify the listeners when the value is updated.
NOTE: In flutter, using
ReactterWatcher, it's a way to keep the widgets automatically updates, accessing the value of signal reactively.
You can create a new Signal, like so:
// usign `.signal` extension
final strSignal = "initial value".signal;
final intSignal = 0.signal;
final userSignal = User().signal;
// or usign the constructor class
final strSignal = Signal<String>("initial value");
final intSignal = Signal<int>(0);
final userSignal = Signal<User>(User());
Signal has value property that allows to read and write its state:
intSignal.value = 10;
print("Current state: ${intSignal.value}");
Or you can use it as a callable function:
intSignal(10);
print("Current state: ${intSignal()}");
Also, you can use toString implict to access its state:
print("Current state: ${intSignal}");
NOTE:
Signalnotifies that its state has changed when the previous state is different from the current state. If its state is aObject, not detect internal changes, only when states is setted anotherObject.
If you want to notify changes after run a set of instructions, use update method:
userSignal.update((user) {
user.firstname = "Leo";
user.lastname = "Leon";
});
If you want to force to notify changes, use refresh method.
userSignal.refresh();
When value is changed, the Signal will emitted the following events:
Lifecycle.willUpdateevent is triggered before thevaluechange orupdate,refreshmethods have been invoked.Lifecicle.didUpdateevent is triggered after thevaluechange orupdate,refreshmethods have been invoked.
NOTE: When you do any arithmetic operation between two
Signals, its return aObj, for example:1.signal + 2.signalreturn3.obj. AObjis like aSignalwithout reactive functionality, but you can convert it toSignalusing.toSignal.
Using UseState hook
UseState is a ReactterHook that manages a state.
You can declarate it into the class, with the context argument(this) to put this hook on listen, like so:
class AppContext extends ReactterContext {
late final count = UseState(0, this);
}
Or add it using the listenHooks method, which is exposed by ReactterContext:
class AppContext extends ReactterContext {
final count = UseState(0);
AppContext() {
listenHooks([count]);
}
}
NOTE: If you don't add context argument or use
listenHooks, theReactterContextwon't be able to react to hook's changes.
UseState has value property that allows to read and write its state:
class AppContext extends ReactterContext {
late final count = UseState(0, this);
AppContext() {
print("Prev state: ${count.value}");
count.value = 10;
print("Current state: ${count.value}");
}
}
NOTE:
UseStatenotifies that its state has changed when the previous state is different from the current state. If its state is aObject, not detect internal changes, only when states is setted anotherObject.
If you want to notify changes after run a set of instructions, use update method:
userState.update(() {
userState.value.firstname = "Leo";
userState.value.lastname = "Leon";
});
If you want to force to notify changes, use refresh method.
userState.refresh();
When value is changed, the UseState will emitted the following events:
Lifecycle.willUpdateevent is triggered before thevaluechange orupdate,refreshmethods have been invoked.Lifecicle.didUpdateevent is triggered after thevaluechange orupdate,refreshmethods have been invoked.
Different between UseState and Signal
Both UseState and Signal represent a state(ReactterState). But there are a few featues that are different between them.
UseState is a ReactterHook. This means that it doesn't work outside of ReactterContext. Instead a Signal can work both outside and inside a ReactterContext. This is good for maintaining a global state or internal state if you use it into a ReactterContext.
With UseState is necessary use value property every time for read or write its state. But with Signal it is not necessary, improving code readability.
In Flutter, to use UseState you need to provide its ReactterContext to the Widget tree, with ReactterProvider or ReactterComponent and access it through of BuildContext. With Signal use ReactterWatcher only, it's very simple.
But it is not all advantages for Signal, although it is good for global states and for improving code readability, it is prone to antipatterns and makes debugging difficult(This will be improved in the following versions).
The decision between which one to use is yours. You can use one or both without them getting in the way. And you can even replace a UseState with a Signal into a ReactterContext.
Using UseAsyncState hook
UseAsyncState is a ReactterHook with the same functionality as UseState but provides a asyncValue method, which will be obtained when resolve method is executed.
class TranslateArgs {
final String to;
final String from;
final String text;
TranslateArgs({ this.to, this.from, this.text });
}
class AppContext extends ReactterContext {
late final translateState = UseAsyncStates<String, TranslateArgs>(
'Hello world',
translate
);
AppContext() {
_init();
}
Future<void> _init() async {
await translateState.resolve(
TranslateArgs({
to: 'ES',
from: 'EN',
text: translateState.value,
}),
);
print("'Hello world' translated to Spanish: '${translateState.value}'");
}
Future<String> translate([TranslateArgs args]) async {
return await api.translate(args);
}
}
NOTE: If you want to send argument to
asyncValuemethod, need to define a type argument which be send throughresolvemethod. Like the example shown above, the argument type send isTranslateArgsclass.
It also has when method that returns a new value depending on it's state:
final valueComputed = asyncState.when<String>(
standby: (value) => "⚓️ Standby: $value",
loading: (value) => "⏳ Loading...",
done: (value) => "✅ Resolved: $value",
error: (error) => "❌ Error: $error",
);
Using UseReducer hook
UseReducer is a ReactterHook that manages state using reducer method. An alternative to UseState.
RECOMMENDED:
UseReduceris usually preferable toUseStatewhen you have complex state logic that involves multiple sub-values or when the next state depends on the previous one.
UseReducer accepts three arguments:
UseReducer(<reducer>, <initialState>, <context>);
- The
reducermethod contains your custom state logic that calculates the new state using current state, and actions. - The
initialStateis a unique value of any type with which you initialize the state. - The
contextrepresents any instance of theReactterContextwhich is notified of any change in state.
UseReducer exposes a dispatch method that allows to invoke the reducer method sending a ReactterAction.
The current state can be access through value property.
Here's the counter example:
class Store {
final int count;
Store({this.count = 0});
}
Store _reducer(Store state, ReactterAction<String, int?> action) {
switch (action.type) {
case 'INCREMENT':
return Store(count: state.count + (action.payload ?? 1));
case 'DECREMENT':
return Store(count: state.count + (action.payload ?? 1));
default:
throw UnimplementedError();
}
}
class AppContext extends ReactterContext {
late final state = UseReducer(_reducer, Store(count: 0), this);
AppContext() {
print("count: ${state.value.count}"); // count: 0;
state.dispatch(ReactterAction(type: 'INCREMENT', payload: 2));
print("count: ${state.value.count}"); // count: 2;
state.dispatch(ReactterAction(type: 'DECREMENT'));
print("count: ${state.value.count}"); // count: 1;
}
}
Also, you can create the actions as a callable class, extending from ReactterActionCallable and use them like this:
class Store {
final int count;
Store({this.count = 0});
}
class IncrementAction extends ReactterActionCallable<Store, int> {
IncrementAction({int quantity = 1}) : super(type: 'INCREEMNT', payload: quantity);
@override
Store call(Store state) => Store(count: state.count + payload);
}
class DecrementAction extends ReactterActionCallable<Store, int> {
DecrementAction({int quantity = 1}) : super(type: 'DECREMENT', payload: quantity);
@override
Store call(Store state) => Store(count: state.count - payload);
}
Store _reducer(Store state, ReactterAction action) =>
action is ReactterActionCallable ? action(state) : UnimplementedError();
class AppContext extends ReactterContext {
late final state = UseReducer(_reducer , Store(count: 0), this);
AppContext() {
print("count: ${state.value.count}"); // count: 0;
state.dispatch(IncrementAction(quantity: 2));
print("count: ${state.value.count}"); // count: 2;
state.dispatch(DecrementAction());
print("count: ${state.value.count}"); // count: 1;
}
}
Using UseEffect hook
UseEffect is a ReactterHook that manages side-effect.
You can add it on constructor of class:
class AppContext extends ReactterContext {
late final count = UseState(0, this);
AppContext() {
UseEffect((){
// Execute by count state changed or 'didMount' event
print("Count: ${count.value}");
Future.delayed(
const Duration(seconds: 1),
() => count.value += 1,
);
return () {
// Cleanup - Execute before count state changed or 'willUnmount' event
print("Cleanup executed");
};
}, [count], this);
}
}
If you want to execute a UseEffect immediately, use UseEffect.dispatchEffect instead of the context argument:
UseEffect(
() => print("Excute immediately or by hook's changes"),
[someState],
UseEffect.dispatchEffect
);
NOTE: If you don't add
contextargument toUseEffect, thecallbackdon't execute on lifecycledidMount, and thecleanupdon't execute on lifecyclewillUnmount(theses lifecycle events are used withflutter_reactteronly).
Custom hook with ReactterHook
ReactterHook is a abstract class that allows to create a Custom Hook.
There are several advantages to using Custom Hooks:
- Reusability: you can use the same hook again and again, without the need to write it twice.
- Clean Code: extracting part of context logic into a hook will provide a cleaner codebase.
- Maintainability: easier to maintain. if you need to change the logic of the hook, you only need to change it once.
Here's the counter example:
class UseCount extends ReactterHook {
int _count = 0;
int get value => _count;
UseCount(int initial, [ReactterContext? context])
: _count = initial,
super(context);
void increment() => update(() => _count += 1);
void decrement() => update(() => _count -= 1);
}
RECOMMENDED: Name class with
Usepreffix, for easy locatily.NOTE:
ReactterHookprovidesupdatemethod which notify tocontextthat has changed.
and use it like that:
class AppContext extends ReactterContext {
late final count = UseCount(0, this);
AppContext() {
UseEffect(() {
Future.delayed(
const Duration(seconds: 1),
count.increment,
);
print("Count: ${count.value}");
}, [count], this);
}
}
Usage with flutter_reactter
Wrap with ReactterProvider
ReactterProvider is a wrapper StatelessWidget that provides a
ReactterContext's instance to widget tree that can be access through the BuildContext.
ReactterProvider<AppContext>(
() => AppContext(),
builder: (appContext, context, child) {
context.watch<AppContext>();
return Text("count: ${appContext.count.value}");
},
)
If you want to create a different ReactterContext's instance, use id parameter.
ReactterProvider<AppContext>(
() => AppContext(),
id: "uniqueId",
builder: (appContext, context, child) {
context.watchId<AppContext>("uniqueId");
return Text("count: ${appContext.count.value}");
},
)
IMPORTANT: Dont's use
ReactterContextwith constructor parameters to prevent conflicts.NOTE:
ReactteProvideris a "scoped". So, thebuildercallback will be rebuild, when theReactterContextchanges or anyReactterHookspecified. For this to happen, theReactterContextshould put it on listens forBuildContext'swatchers.
Access to ReactterContext
Reactter provides additional methods to BuildContext to access your ReactterContext. These are following:
context.watch: Gets theReactterContext's instance from the closest ancestor ofReactterProviderand watch allReactterHookorReactterHookdefined in first paramater.
final watchContext = context.watch<WatchContext>();
final watchHooksContext = context.watch<WatchHooksContext>(
(ctx) => [ctx.stateA, ctx.stateB],
);
context.watchId: Gets theReactterContext's instance withidfrom the closest ancestor ofReactterProviderand watch allReactterHookorReactterHookdefined in second paramater.
final watchIdContext = context.watchId<WatchIdContext>('id');
final watchHooksIdContext = context.watchId<WatchHooksIdContext>(
'id',
(ctx) => [ctx.stateA, ctx.stateB],
);
context.use: Gets theReactterContext's instance with/withoutidfrom the closest ancestor ofReactterProvider.
final readContext = context.use<ReadContext>();
final readIdContext = context.use<ReadIdContext>('id');
NOTE: These methods mentioned above uses
ReactterProvider.contextOfNOTE:
context.watchandcontext.watchIdwatch all or some of the specifiedReactterHookdependencies and when it will change, re-render widgets in the scope ofReactterProviders,ReactterBuilderor any Widget that exposes theBuildContextlikeBuild,StatelessWidget,StatefulWidget.
React to Signals with ReactterWatcher
ReactterWatcher is a Statefulwidget that listens for Signals and re-build when any Signal is changed.
final count = 0.signal;
final flag = false.signal;
void increase() => count.value += 1;
void toggle() => flag(!flag.value);
class Example extends StatelessWidget {
...
Widget build(context) {
return ReactterWatcher(
child: Row(
children: const [
ElevatedButton(
onPressed: increase,
child: Text("Increase"),
),
ElevatedButton(
onPressed: toggle,
child: Text("Toogle"),
),
],
),
builder: (context, child) {
// This rebuild the widget tree when `count` or `flag` are updated.
return Column(
children: [
Text("Count: $count"),
Text("Flag is: $flag"),
// This takes the widget from the `child` property in each rebuild.
child,
],
);
},
);
}
}
Control re-render with ReactterBuilder
ReactterBuilder is a StatelessWidget that to to get the ReactterContext's instance from the closest ancestor of ReactterProvider and exposes it through the first parameter of builder callback.
ReactterBuilder<AppContext>(
listenAll: true,
builder: (appContext, context, child) {
return Text("Count: ${appContext.count.value}");
},
)
NOTE:
ReactterBuilderis read-only by default(listenAll: false), this means it only renders once. Instead uselistenAllastrueor uselistenStateswith theReactterHooks specific and then thebuildercallback will be rebuild with everyReactterContext'sReactterHookchanges.NOTE:
ReactterBuilderis a "scoped". So, thebuildercallback will be rebuild, when theReactterContextchanges or anyReactterHookspecified. For this to happen, theReactterContextshould put it on listens forBuildContext'swatchers.
Multiple ReactterProvider with ReactterProviders
ReactterProviders is a wrapper StatelessWidget that allows to use multiple ReactterProvider as nested way.
ReactterProviders(
[
ReactterProvider(
() => AppContext(),
),
ReactterProvider(
() => ConfigContext(),
id: 'App',
),
ReactterProvider(
() => ConfigContext(),
id: 'User'
),
],
builder: (context, child) {
final appContext = context.watch<AppContext>();
final appConfigContext = context.watchId<ConfigContext>('App');
final userConfigContext = context.watchId<ConfigContext>('User');
...
},
)
Create a ReactterComponent
ReactterComponent is a abstract StatelessWidget class that provides the functionality of ReactterProvider with a ReactterContext and exposes it through render method.
class CounterComponent extends ReactterComponent<AppContext> {
const CounterComponent({Key? key}) : super(key: key);
@override
get builder => () => AppContext();
@override
get id => 'uniqueId';
@override
listenStates(appContext) => [appContext.stateA];
@override
Widget render(appContext, context) {
return Text("StateA: ${appContext.stateA.value}");
}
}
Resources
- Documentation
- Examples
Roadmap
We want to keeping adding features for Reactter, those are some we have in mind order by priority:
- Widget to control re-render using only hooks.
- Async context.
- Do benchmarks and improve performance.
Contribute
If you want to contribute don't hesitate to create an issue or pull-request in Reactter repository.
You can:
- Provide new features.
- Report bugs.
- Report situations difficult to implement.
- Report an unclear error.
- Report unclear documentation.
- Add a new custom hook.
- Add a new widget.
- Add examples.
- Translate documentation.
- Write articles or make videos teaching how to use Reactter.
Any idea is welcome!