refena 2.0.0 refena: ^2.0.0 copied to clipboard
A state management library for Dart and Flutter. Inspired by Riverpod and async_redux.
A state management library for Dart and Flutter. Inspired by Riverpod and async_redux.
Preview #
Define a provider:
final counterProvider = NotifierProvider<Counter, int>((ref) => Counter());
class Counter extends Notifier<int> {
@override
int init() => 10;
void increment() => state++;
}
Use context.watch
to access the provider:
class MyPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
int counterState = context.watch(counterProvider);
return Scaffold(
body: Center(
child: Text('Counter state: $counterState'),
),
floatingActionButton: FloatingActionButton(
onPressed: () => context.notifier(counterProvider).increment(),
child: const Icon(Icons.add),
),
);
}
}
Or in Redux style:
final counterProvider = ReduxProvider<ReduxCounter, int>((ref) => Counter());
class ReduxCounter extends ReduxNotifier<int> {
@override
int init() => 10;
}
class AddAction extends ReduxAction<ReduxCounter, int> {
final int amount;
AddAction(this.amount);
@override
int reduce() => state + amount;
}
class MyPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
int counterState = context.watch(counterProvider);
return Scaffold(
body: Center(
child: Text('Counter state: $counterState'),
),
floatingActionButton: FloatingActionButton(
onPressed: () => context.redux(counterProvider).dispatch(AddAction(2)),
child: const Icon(Icons.add),
),
);
}
}
Offering high traceability with RefenaDebugObserver and RefenaTracingObserver:
[Refena] Action dispatched: [ReduxCounter.AddAction] by [MyPage]
[Refena] Change by [ReduxCounter] triggered by [AddAction]
- Prev: 5
- Next: 8
- Rebuild (1): [MyPage]
With a feature-rich Refena Inspector:
Table of Contents #
- Refena vs Riverpod
- Refena vs async_redux
- Getting Started
- Access the state
- Container and Scope
- Providers
- Notifiers
- Using ref
- What to choose?
- Performance Optimization
- ensureRef
- defaultRef
- Observer
- Tools
- Testing
- Add-ons
- Dart only
- Deep Dives
Refena vs Riverpod #
Refena is aimed to be more notifier focused than Riverpod.
Refena also includes a comprehensive Redux implementation that can be used for crucial parts of your app.
➤ Key differences #
Flutter native:
No ConsumerWidget
or ConsumerStatefulWidget
. You still use StatefulWidget
or StatelessWidget
as usual.
To access ref
, you can either add with Refena
(only in StatefulWidget
) or call context.ref
.
Common super class:
WatchableRef extends Ref
.
You can use Ref
as a parameter type to implement util functions that need access to ref
.
These functions can be called by providers and also by widgets.
No Scheduler:
If a provider changes, listeners will be notified in the next micro task.
That's all.
There is no scheduler that is running in the background.
Use ref anywhere, anytime:
Don't worry that the ref
within providers or notifiers becomes invalid.
They live as long as the RefenaScope
.
Notifier first:
With Notifier
, AsyncNotifier
, PureNotifier
, and ReduxNotifier
,
you can choose the right notifier for your use case.
➤ Similarities #
Testable:
Providers are stateless.
The state is stored in the RefenaContainer
.
This makes providers testable.
Type-safe:
Working with providers and notifiers are type-safe and null-safe.
Auto register:
Don't worry that you forget to register a provider.
They are automatically registered when you use them.
➤ Downsides #
No autodispose
:
Since there is no ConsumerWidget
, providers are never disposed automatically.
Refena encourages you to use ref.dispose to dispose providers explicitly.
For view models, you can use ViewModelBuilder
which disposes the provider automatically.
➤ Migration #
Use refena_riverpod_extension to use Riverpod and Refena at the same time.
// Riverpod -> Refena
ref.refena.read(myRefenaProvider);
// Refena -> Riverpod
ref.riverpod.read(myRiverpodProvider);
Checkout Refena for Riverpod developers for more information.
Refena vs async_redux #
Compared to async_redux, Refena encourages you to split the state into multiple providers, making it easier to scale your app.
➤ Key differences #
More scalable:
Since actions are bound to a ReduxProvider
,
you can split your app into multiple providers with each provider having its own set of actions.
This makes it easier to scale your app.
You can also view the dependency graph without any setup.
No StoreConnector:
You don't need to use a StoreConnector
to access the state.
If you wish, you can use a ViewModelBuilder
instead, but it's not required.
More type-safety:
Refena is more type-safe than async_redux since it differentiates between synchronous and asynchronous actions.
Getting started #
Step 1: Add dependency
Add refena_flutter for Flutter projects, or refena for Dart projects.
# pubspec.yaml
dependencies:
refena_flutter: <version>
Step 2: Add RefenaScope
void main() {
runApp(
RefenaScope(
child: const MyApp(),
),
);
}
Step 3: Define a provider
final myProvider = Provider((ref) => 42);
Step 4: Use the provider
class MyPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
final myValue = context.watch(myProvider);
return Scaffold(
body: Center(
child: Text('The value is $myValue'),
),
);
}
}
Access the state #
The state should be accessed via ref
.
You can get the ref
right from the context
:
class MyPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
final ref = context.ref;
final myValue = ref.watch(myProvider);
final mySecondValue = ref.watch(mySecondProvider);
return Scaffold(
body: Column(
children: [
Text('The value is $myValue'),
Text('The second value is $mySecondValue'),
],
),
);
}
}
To make your life easier, you can also skip the ref
part: Just call context.watch
.
In a StatefulWidget
, you can use with Refena
to access the ref
directly.
class MyPage extends StatefulWidget {
@override
State<MyPage> createState() => _CounterState();
}
class _MyPageState extends State<MyPage> with Refena {
@override
Widget build(BuildContext context) {
final myValue = ref.watch(myProvider);
return Scaffold(
body: Center(
child: Text('The value is $myValue'),
),
);
}
}
You can also use Consumer
to access the state. This is useful to rebuild only a part of the widget tree:
class MyPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Consumer(
builder: (context, ref) {
final myValue = ref.watch(myProvider);
return Text('The value is $myValue');
}
),
),
);
}
}
Container and Scope #
The RefenaContainer
is the root of your app. It holds the state of all providers.
In Flutter, you should use RefenaScope
instead which provides easy access to the state from a BuildContext
.
A RefenaScope
is just a wrapper around a RefenaContainer
.
If no container is provided, a new one is implicitly created.
void main() {
runApp(
RefenaScope( // <-- creates an implicit container
child: const MyApp(),
),
);
}
You can also create a container explicitly. Use RefenaScope.withContainer
to provide the container:
void main() {
final container = RefenaContainer();
// pre init procedure
container.set(databaseProvider.overrideWithValue(DatabaseService()));
container.read(analyticsService).appStarted();
runApp(
RefenaScope.withContainer(
container: container, // <-- explicit container
child: const MyApp(),
),
);
}
More about initialization here.
Providers #
There are many types of providers. Choose the right one for your use case.
Provider | Usage | Notifier API | Can watch * |
Has family * |
---|---|---|---|---|
Provider |
Constants or services | - | No | No |
ViewProvider |
View models | - | Yes | Yes |
FutureProvider |
Async values | - | Yes | Yes |
StreamProvider |
Streams | - | Yes | Yes |
StateProvider |
Simple states | setState |
No | No |
ChangeNotifierProvider |
More rebuild control | Custom methods | No | No |
NotifierProvider |
Regular services | Custom methods | No | No |
AsyncNotifierProvider |
Services with futures | Custom methods | No | No |
ReduxProvider |
Action based services | Custom actions | No | No |
* watch
means that you can use ref.watch
inside the provider build lambda.
* family
means that you can use <Provider>.family
to create a parameterized collection of a provider.
➤ Provider #
The Provider
is the most basic provider.
Use this provider for immutable values (constants or stateless services).
final databaseProvider = Provider((ref) => DatabaseService());
To access the value:
// Everywhere
DatabaseService a = ref.read(databaseProvider);
// Inside a build method
DatabaseService a = ref.watch(databaseProvider);
This type of provider can replace your entire dependency injection solution.
➤ FutureProvider #
Use this provider for asynchronous values.
The value is cached and only fetched once.
import 'package:package_info_plus/package_info_plus.dart';
final versionProvider = FutureProvider((ref) async {
final info = await PackageInfo.fromPlatform();
return '${info.version} (${info.buildNumber})';
});
Access:
build(BuildContext context) {
AsyncValue<String> versionAsync = ref.watch(versionProvider);
return versionAsync.when(
data: (version) => Text('Version: $version'),
loading: () => const CircularProgressIndicator(),
error: (error, stackTrace) => Text('Error: $error'),
);
}
More about AsyncValue
here.
➤ FutureFamilyProvider #
Use this provider for multiple asynchronous values. Use FutureProvider.family
for better readability.
final userProvider = FutureProvider.family<User, String>((ref, id) async {
final api = ref.read(apiProvider);
return api.fetchUser(id);
});
Access:
build(BuildContext context) {
AsyncValue<User> userAsync = ref.watch(userProvider('123'));
}
➤ StateProvider #
The StateProvider
is handy for simple use cases where you only need a setState
method.
final myProvider = StateProvider((ref) => 10);
Update the state:
ref.notifier(myProvider).setState((old) => old + 1);
➤ StreamProvider #
Use this provider to listen to a stream.
final myProvider = StreamProvider<int>((ref) async* {
yield 1;
await Future.delayed(const Duration(seconds: 1));
yield 2;
await Future.delayed(const Duration(seconds: 1));
yield 3;
});
Access:
build(BuildContext context) {
AsyncValue<int> streamAsync = ref.watch(myProvider);
return streamAsync.when(
data: (value) => Text('The value is $value'),
loading: () => const CircularProgressIndicator(),
error: (error, stackTrace) => Text('Error: $error'),
);
}
➤ ChangeNotifierProvider #
Use this provider if you have many rebuilds and need to optimize performance (e.g., progress indicator).
final myProvider = ChangeNotifierProvider((ref) => MyNotifier());
class MyNotifier extends ChangeNotifier {
int _counter = 0;
int get counter => _counter;
void increment() {
_counter++;
notifyListeners();
}
}
Use ref.watch
to listen and ref.notifier
to call methods:
build(BuildContext context) {
final counter = ref.watch(myProvider).counter;
return Scaffold(
body: Center(
child: Column(
children: [
Text('The value is $counter'),
ElevatedButton(
onPressed: ref.notifier(myProvider).increment,
child: const Text('Increment'),
),
],
),
),
);
}
➤ NotifierProvider #
Use this provider for mutable values.
This provider can be used in an MVC-like pattern.
The notifiers are never disposed. You may have custom logic to delete values within a state.
final counterProvider = NotifierProvider<Counter, int>((ref) => Counter());
class Counter extends Notifier<int> {
@override
int init() => 10;
void increment() => state++;
}
To access the value:
// Everywhere
int a = ref.read(counterProvider);
// Inside a build method
int a = ref.watch(counterProvider);
To access the notifier:
Counter counter = ref.notifier(counterProvider);
Or within a click handler:
ElevatedButton(
onPressed: () {
ref.notifier(counterProvider).increment();
},
child: const Text('+ 1'),
)
➤ AsyncNotifierProvider #
Use this provider for mutable async values.
final counterProvider = AsyncNotifierProvider<Counter, int>((ref) => Counter());
class Counter extends AsyncNotifier<int> {
@override
Future<int> init() async {
await Future.delayed(const Duration(seconds: 1));
return 0;
}
void increment() async {
// Set `future` to update the state.
future = ref.notifier(apiProvider).fetchAsyncNumber();
// Use `setState` to also access the old value.
setState((snapshot) async => (snapshot.curr ?? 0) + 1);
// Set `state` directly if you want more control.
final old = state.data ?? 0;
state = AsyncValue.loading(old);
await Future.delayed(const Duration(seconds: 1));
state = AsyncValue.data(old + 1);
}
}
Notice that AsyncValue.loading()
can take an optional previous value.
This is handy if you want to show the previous value while loading.
When using when
, it will automatically use the previous value by default.
build(BuildContext context) {
final (counter, loading) = ref.watch(counterProvider.select((s) => (s, s.isLoading)));
return counter.when(
// also shows previous value even when it's loading
data: (value) => Text('The value is $value (loading: $loading)'),
// only shown initially when there is no previous value
loading: () => Text('Loading...'),
// always shows the error if the future fails (configurable)
error: (error, stackTrace) => Text('Error: $error'),
);
}
➤ ReduxProvider #
The ReduxProvider
is the strictest option. The state
is solely altered by actions.
You need to provide other notifiers via constructor
(dependency injection)
making the ReduxNotifier
self-contained and testable.
Redux has two main benefits:
- Tracing: With
RefenaDebugObserver
orRefenaTracingObserver
, you can see every state transition. - Testing: You can test the state transitions.
final counterProvider = ReduxProvider<Counter, int>((ref) {
return Counter(ref.notifier(providerA), ref.notifier(providerB));
});
class Counter extends ReduxNotifier<int> {
final ServiceA serviceA;
final ServiceB serviceB;
Counter(this.serviceA, this.serviceB);
@override
int init() => 0;
}
class AddAction extends ReduxAction<Counter, int> {
final int amount;
AddAction(this.amount);
@override
int reduce() => state + amount;
}
class SubtractAction extends ReduxAction<Counter, int> {
final int amount;
SubtractAction(this.amount);
@override
int reduce() => state - amount;
// This is called after the state transition
@override
void after() {
// dispatch actions in the same notifier
dispatch(AddAction(amount - 1));
// dispatch actions of other notifiers
external(notifier.serviceA).dispatch(SomeAction());
// access the state of other notifiers
if (notifier.serviceB.state == 3) {
// ...
}
}
}
The widget can trigger actions with ref.redux(provider).dispatch(action)
:
class MyPage extends StatelessWidget {
const MyPage({super.key});
@override
Widget build(BuildContext context) {
final state = context.watch(counterProvider);
return Scaffold(
body: Column(
children: [
Text(state.toString()),
ElevatedButton(
onPressed: () => context.redux(counterProvider).dispatch(AddAction(2)),
child: const Text('Increment'),
),
ElevatedButton(
onPressed: () => context.redux(counterProvider).dispatch(SubtractAction(3)),
child: const Text('Decrement'),
),
],
),
);
}
}
Here is how the console output could look like with RefenaDebugObserver
:
[Refena] Action dispatched: [ReduxCounter.SubtractAction] by [MyPage]
[Refena] Change by [ReduxCounter] triggered by [SubtractAction]
- Prev: 8
- Next: 5
- Rebuild (1): [MyPage]
You can additionally override before
, after
, and wrapReduce
in a ReduxAction
to add custom logic.
The redux feature is quite complex. You can read more about it here.
➤ ViewProvider #
The ViewProvider
is the only provider that can watch
other providers.
This is useful for view models that depend on multiple providers.
This requires more code but makes your app more testable.
class SettingsVm {
final String firstName;
final String lastName;
final ThemeMode themeMode;
final void Function() logout;
// insert constructor
}
final settingsVmProvider = ViewProvider((ref) {
final auth = ref.watch(authProvider);
final themeMode = ref.watch(themeModeProvider);
return SettingsVm(
firstName: auth.firstName,
lastName: auth.lastName,
themeMode: themeMode,
logout: () => ref.notifier(authProvider).logout(),
);
});
The widget:
class SettingsPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
final vm = context.watch(settingsVmProvider);
return Scaffold(
body: Center(
child: Column(
children: [
Text('First name: ${vm.firstName}'),
Text('Last name: ${vm.lastName}'),
Text('Theme mode: ${vm.themeMode}'),
ElevatedButton(
onPressed: vm.logout,
child: const Text('Logout'),
),
],
),
),
);
}
}
To automatically dispose the view model, you can use the ViewModelBuilder
widget.
Nice to know: The ViewModelBuilder
not only works with ViewProvider
but with any provider.
class SettingsPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ViewModelBuilder(
provider: settingsVmProvider,
builder: (context, vm) {
return Scaffold(
body: Center(
child: Column(
children: [
Text('First name: ${vm.firstName}'),
Text('Last name: ${vm.lastName}'),
Text('Theme mode: ${vm.themeMode}'),
ElevatedButton(
onPressed: vm.logout,
child: const Text('Logout'),
),
],
),
),
);
},
);
}
}
You can also add lifecycle hooks to the view model:
class SettingsPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ViewModelBuilder(
provider: settingsVmProvider,
init: (context) => context.notifier(authProvider).init(),
dispose: (ref) => ref.notifier(authProvider).dispose(),
placeholder: (context) => Text('Loading...'), // while init is running
error: (context, error, stackTrace) => Text('Error: $error'), // when init fails
builder: (context, vm) {
return Scaffold(
body: Center(
child: Column(
children: [
Text('First name: ${vm.firstName}'),
Text('Last name: ${vm.lastName}'),
Text('Theme mode: ${vm.themeMode}'),
ElevatedButton(
onPressed: vm.logout,
child: const Text('Logout'),
),
],
),
),
);
},
);
}
}
➤ ViewFamilyProvider #
Similar to ViewProvider
but with a parameter. Use ViewProvider.family
for better readability.
final settingsVmProvider = ViewProvider.family<SettingsVm, String>((ref, userId) {
final auth = ref.watch(authProvider(userId));
final themeMode = ref.watch(themeModeProvider);
return SettingsVm(
firstName: auth.firstName,
lastName: auth.lastName,
themeMode: themeMode,
logout: () => ref.notifier(authProvider).logout(),
);
});
A ViewModelBuilder
would dispose the whole family provider.
You should use FamilyViewModelBuilder
(or ViewModelBuilder.family
) instead.
This will only dispose a member of the family on widget disposal.
class SettingsPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
int userId = 123;
return ViewModelBuilder.family(
provider: settingsVmProvider(userId),
builder: (context, vm) {
return Scaffold(
body: Center(
child: Column(
children: [
Text('First name: ${vm.firstName}'),
Text('Last name: ${vm.lastName}'),
Text('Theme mode: ${vm.themeMode}'),
ElevatedButton(
onPressed: vm.logout,
child: const Text('Logout'),
),
],
),
),
);
},
);
}
}
Notifiers #
A notifier holds the actual state and triggers rebuilds on widgets listening to them.
Provider | Usage | Provider | Exposes ref |
---|---|---|---|
Notifier |
For any use case | NotifierProvider |
Yes |
ChangeNotifier |
For performance critical services | ChangeNotifierProvider |
Yes |
AsyncNotifier |
For async values | AsyncNotifierProvider |
Yes |
PureNotifier |
For clean architectures | NotifierProvider |
No |
ReduxNotifier |
For very clean architectures | ReduxProvider |
No |
➤ Lifecycle #
Inside the init
method, you can access the ref
to read other providers.
The main goal is to return the initial state. Avoid any additional logic here.
class MyNotifier extends Notifier<int> {
@override
int init() {
final persistenceService = ref.read(persistenceProvider);
return persistenceService.getNumber();
}
}
To do initial work, you can override postInit
.
At this point, the state is already set, and you can access or modify it via state
.
class MyNotifier extends Notifier<int> {
@override
int init() => 10;
@override
void postInit() {
print('The initial value is $state');
state++;
}
}
When ref.dispose(provider)
is called, you can hook into the disposing process by overriding dispose
.
class MyNotifier extends Notifier<int> {
@override
int init() => 10;
@override
void dispose() {
// custom cleanup logic
// everything is still accessible until the end of this method
}
}
➤ Notifier vs PureNotifier #
Notifier
and PureNotifier
are very similar.
The difference is that the Notifier
has access to ref
and the PureNotifier
does not.
// You need to specify the generics (<..>) to have the correct type inference
// Waiting for https://github.com/dart-lang/language/issues/524
final counterProvider = NotifierProvider<Counter, int>((ref) => Counter());
class Counter extends Notifier<int> {
@override
int init() => 10;
void increment() {
final anotherValue = ref.read(anotherProvider);
state++;
}
}
A PureNotifier
has no access to ref
making this notifier self-contained.
This is often used in combination with dependency injection, where you provide the dependencies via constructor.
final counterProvider = NotifierProvider<PureCounter, int>((ref) {
final persistenceService = ref.read(persistenceProvider);
return PureCounter(persistenceService);
});
class PureCounter extends PureNotifier<int> {
final PersistenceService _persistenceService;
PureCounter(this._persistenceService);
@override
int init() => 10;
void increment() {
counter++;
_persistenceService.persist();
}
}
➤ Access BuildContext inside a notifier #
To access BuildContext
inside a notifier, you need to bind the lifetime of the notifier to the widget.
This is done by adding the ViewBuildContext
mixin to the notifier.
final counterProvider = NotifierProvider<Counter, int>((ref) => Counter());
class Counter extends Notifier<int> with ViewBuildContext {
@override
int init() => 10;
void increment() {
state++;
ScaffoldMessenger.of(context).showSnackBar( // <-- access BuildContext
SnackBar(
content: Text('Counter: $state'),
),
);
}
}
This provider needs to be initialized with ViewModelBuilder
.
Not doing so will throw an error.
class MyPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ViewModelBuilder(
provider: counterProvider, // <-- Binds the lifetime of the provider to the widget
builder: (context, vm) {
return Scaffold(
body: Center(
child: Column(
children: [
Text('The value is $vm'),
ElevatedButton(
onPressed: () => context.notifier(counterProvider).increment(),
child: const Text('Increment'),
),
],
),
),
);
},
);
}
}
Using ref #
With ref
, you can access the providers and notifiers.
➤ ref.read #
Read the value of a provider.
int a = ref.read(myProvider);
➤ ref.watch #
Read the value of a provider and rebuild the widget / provider when the value changes.
This should be used within a build
method of a widget or inside a provider build lambda.
Warning: Watching outside the build
method can lead to inconsistent rebuilds.
Widget build(BuildContext context) {
final ref = context.ref;
final currentValue = ref.watch(myProvider);
// ...
}
You may add an optional listener
callback:
build(BuildContext context) {
final currentValue = ref.watch(myProvider, listener: (prev, next) {
print('The value changed from $prev to $next');
});
// ...
}
➤ ref.accessor #
Similar to Ref.read
, but instead of returning the state right away,
it returns a StateAccessor
to get the state later.
This is useful if you need to read the latest state of a provider (ViewProvider
in particular),
but you can't use Ref.watch
when building a notifier.
More about dependency injection.
final myViewProvider = ViewProvider((ref) {
final counter = ref.watch(counterProvider);
return MyView(counter);
});
final myProvider = NotifierProvider<MyNotifier, int>((ref) {
// If we just use ref.read, then we don't have the latest state
// of the myViewProvider.
final view = ref.accessor(myViewProvider);
return MyNotifier(view);
});
class MyNotifier extends Notifier<int> {
final StateAccessor<MyView> _view;
MyNotifier(this._view);
@override
int init() => 0;
void add() {
state += _view.state.counter;
}
}
➤ ref.stream #
Similar to ref.watch
with listener
, but you need to manage the subscription manually.
The subscription will not be disposed automatically.
Use this outside a build
method.
final subscription = ref.stream(myProvider).listen((value) {
print('The value changed from ${value.prev} to ${value.next}');
});
➤ ref.future #
Get the Future
of a FutureProvider
, a StreamProvider
, or an AsyncNotifierProvider
.
Future<String> version = ref.future(versionProvider);
➤ ref.rebuild #
Reruns the build method of a provider and triggers a rebuild on all listeners.
This is useful if you want to manually rebuild a provider.
Only available for rebuildable providers: ViewProvider
, FutureProvider
, StreamProvider
.
Returns the result of the build method: T
, Future<T>
, Stream<T>
.
Future<T> result = ref.rebuild(myFutureProvider);
➤ ref.notifier #
Get the notifier of a provider.
Counter counter = ref.notifier(counterProvider);
// or
ref.notifier(counterProvider).increment();
➤ ref.redux #
Dispatches an action to a ReduxProvider
.
ref.redux(myReduxProvider).dispatch(MyAction());
await ref.redux(myReduxProvider).dispatchAsync(MyAsyncAction());
➤ ref.dispose #
Providers are never disposed automatically. Instead, you should create a custom "cleanup" logic.
To make your life easier, you can dispose a provider by calling this method:
ref.dispose(myProvider);
This can be called in a StatefulWidget
's dispose
method and is safe to do so because ref.dispose
does not trigger a rebuild.
class MyPage extends StatefulWidget {
@override
State<MyPage> createState() => _MyPageState();
}
class _MyPageState extends State<MyPage> with Refena {
@override
void dispose() {
ref.dispose(myProvider); // <-- dispose the provider
super.dispose();
}
}
In a notifier, you can hook into the disposing process by overriding dispose
.
class MyNotifier extends Notifier<int> {
@override
int init() => 10;
@override
void dispose() {
// custom cleanup logic
// everything is still accessible until the end of this method
}
}
➤ ref.message #
Emits a message to the observer.
This might be handy if you use one of the built-in observers like RefenaDebugObserver
.
The messages are also shown in the inspector.
ref.message('Hello World');
Inside a ReduxAction
, this method is available as emitMessage
.
➤ ref.container #
Returns the backing container. The container exposes more advanced methods for edge cases like post-init overrides.
RefenaContainer container = ref.container;
➤ BuildContext Extensions #
Some frequently used methods are available as extensions on BuildContext
.
context.watch(myProvider);
context.read(myProvider);
context.notifier(myProvider).increment();
context.redux(myReduxProvider).dispatch(MyAction());
What to choose? #
There are lots of providers and notifiers. Which one should you choose?
For most use cases, Provider
and Notifier
are more than enough.
If you work in an environment where clean architecture is important,
you may want to use ReduxProvider
and ViewProvider
.
Be aware that you will need to write more boilerplate code.
Providers & Notifiers | Boilerplate | Testability |
---|---|---|
Provider , StateProvider |
Low | |
Provider , NotifierProvider |
notifiers | Medium |
Provider , ReduxProvider |
notifiers, actions | High |
Provider , ViewProvider , NotifierProvider |
notifiers, view models | Very high |
Provider , ViewProvider , ReduxProvider |
notifiers, view models, actions | Maximum |
➤ Can I use different providers & notifiers together? #
Yes. You can use any combination of providers and notifiers.
The cool thing about notifiers is that they are self-contained.
Usually, you don't need to create a view model for each page. This refactoring can be done later when your page gets too complex.
➤ What layers should an app have? #
Let's start from the UI (Widgets):
- Widgets: The UI layer. This is where you build your UI. It should not contain any business logic.
- View Models: This layer provides the data for the UI. It should not contain any business logic. Use
ViewProvider
for this layer. - Controllers: This layer contains the business logic for one specific view (page). It should not contain any business logic shared between multiple views. Possible providers:
NotifierProvider
,ReduxProvider
. - Services: This layer contains the business logic that is shared between multiple views. A "service" is essentially a "feature" of your app. Possible providers:
NotifierProvider
,ReduxProvider
.
As always, you don't need to use all layers. It depends on the complexity of your app.
- Create widgets and services first.
- If you notice that your widgets get too complex, you can add controllers or view models to avoid
StatefulWidget
s.
Simple StatefulWidget
s are fine because they are still self-contained.
The problem starts when you want to test them.
View models makes it easier to test the UI.
This is a very pragmatic approach. You can also write the full architecture from the beginning.
Additional types like "repositories" can be added if you need them. Usually, they can be treated as services (but more specialized) in this model.
You can open the dependency graph to see how the layers are connected.
Performance Optimization #
➤ Selective watching #
You may restrict the rebuilds to only a subset of the state with provider.select
.
Here, the ==
operator is used to compare the previous and next value.
build(BuildContext context) {
final themeMode = ref.watch(
settingsProvider.select((settings) => settings.themeMode),
);
// ...
}
For more complex logic, you can use rebuidWhen
.
build(BuildContext context) {
final currentValue = ref.watch(
myProvider,
rebuildWhen: (prev, next) => prev.attribute != next.attribute,
);
// ...
}
You can use both select
and rebuildWhen
at the same time.
The select
will be applied, when rebuildWhen
returns true
.
Do not execute ref.watch
of the same provider multiple times
as only the last one will be used for the rebuild condition.
Instead, you should use Records to combine multiple values.
build(BuildContext context) {
final (themeMode, locale) = ref.watch(settingsProvider.select((settings) {
return (settings.theme, settings.locale);
}));
}
➤ Notify Strategy #
A more global approach than select
is to set a defaultNotifyStrategy
.
By default, NotifyStrategy.equality
is used to reduce rebuilds.
You can change it to NotifyStrategy.identity
to use identical
instead of ==
.
This avoids comparing deeply nested objects.
void main() {
runApp(
RefenaScope(
defaultNotifyStrategy: NotifyStrategy.identity,
child: const MyApp(),
),
);
}
You probably noticed the default-
prefix.
Of course, you can override updateShouldNotify
for each notifier individually.
class MyNotifier extends Notifier<int> {
@override
int init() => 10;
@override
bool updateShouldNotify(int old, int next) => old != next;
}
ensureRef #
In a StatefulWidget
, you can use ensureRef
to access the providers and notifiers within initState
.
You may also use ref
inside dispose
because ref
is guaranteed to be initialized.
Please note that you need with Refena
.
class MyPage extends StatefulWidget {
@override
State<MyPage> createState() => _MyPageState();
}
class _MyPageState extends State<MyPage> with Refena {
@override
void initState() {
super.initState();
ensureRef((ref) {
ref.read(myProvider);
});
// or
ensureRef();
}
@override
void dispose() {
ensureRef((ref) {
// This is safe now because we called `ensureRef` in `initState`
ref.read(myProvider);
ref.notifier(myNotifierProvider).doSomething();
});
super.dispose();
}
}
defaultRef #
If you are unable to access ref
, there is a pragmatic solution for that.
You can use RefenaScope.defaultRef
to access the providers and notifiers.
Remember that this is only for edge cases, and you should always use the accessible ref
if possible.
void someFunction() {
Ref ref = RefenaScope.defaultRef;
ref.read(myProvider);
ref.notifier(myNotifierProvider).doSomething();
}
Observer #
With observers, you access the events emitted by providers.
To add one, specify observers
in the RefenaScope
/ RefenaContainer
constructor.
You can implement one yourself or just use one of the built-in observers.
void main() {
runApp(
RefenaScope(
observers: [
if (kDebugMode) ...[
RefenaDebugObserver(), // <-- built-in observer
],
],
child: const MyApp(),
),
);
}
Now you will see useful information printed into the console:
[Refena] Provider initialized: [Counter]
- Reason: INITIAL ACCESS
- Value: 10
[Refena] Listener added: [SecondPage] on [Counter]
[Refena] Change by [Counter]
- Prev: 10
- Next: 11
- Rebuild (2): [HomePage], [SecondPage]
Example implementation of a custom observer. Note that you also have access to ref
.
class MyObserver extends RefenaObserver {
@override
void init() {
// optional initialization logic
ref.read(crashReporterProvider).init();
}
@override
void handleEvent(RefenaEvent event) {
if (event is ActionErrorEvent) {
Object error = event.error;
StackTrace stackTrace = event.stackTrace;
ref.read(crashReporterProvider).report(error, stackTrace);
if (error is ConnectionException) {
// show snackbar
ref.global.dispatch(ShowSnackBarAction(message: 'No internet connection'));
}
}
}
}
➤ Built-in Observers #
Observer | Description | Package |
---|---|---|
RefenaDebugObserver |
Prints all events to the console. Useful for debugging. | refena |
RefenaHistoryObserver |
Stores events in a list. Useful for testing. | refena |
RefenaTracingObserver |
Stores recent events in a list to be read by a tracing view. | refena |
RefenaInspectorObserver |
Sends the state of the app to an inspector server. | refena_inspector_client |
RefenaSentryObserver |
Populates the Sentry breadcrumbs. | refena_sentry |
Tools #
➤ Event Tracing #
Refena includes a ready-to-use UI to trace the state changes.
First, you need to add the RefenaTracingObserver
to the RefenaScope
.
void main() {
runApp(
RefenaScope(
observers: [
RefenaTracingObserver(), // <-- add this observer
],
child: const MyApp(),
),
);
}
Then, you can use the RefenaTracingPage
to show the state changes.
class MyPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
body: ElevatedButton(
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => const RefenaTracingPage(), // <-- open the page
),
);
},
child: const Text('Show tracing'),
),
);
}
}
Here is how it looks like:
Side note:
The RefenaTracingObserver
itself is quite performant as events are added and removed with O(1)
complexity.
To build the tree, it uses O(n^2)
complexity, where n
is the number of events.
That's why you see a loading indicator when you open the tracing UI.
➤ Dependency Graph #
You can open the RefenaGraphPage
to see the dependency graph. It requires no setup.
Be aware that Refena only tracks dependencies during the build phase of a notifier, hence, always prefer dependency injection!
class MyPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
body: ElevatedButton(
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => const RefenaGraphPage(), // <-- open the page
),
);
},
child: const Text('Show graph'),
),
);
}
}
Here is how it looks like:
➤ Inspector #
Both the RefenaTracingPage
and the RefenaGraphPage
are included in the Refena Inspector.
➤ Sentry #
Use refena_sentry to add breadcrumbs to Sentry.
This is especially useful if you use Redux since you can see every action in Sentry.
Testing #
➤ Override providers #
You can override any provider in your tests.
void main() {
testWidgets('My test', (tester) async {
await tester.pumpWidget(
RefenaScope(
overrides: [
myProvider.overrideWithValue(42),
myNotifierProvider.overrideWithNotifier((ref) => MyNotifier(42)),
],
child: const MyApp(),
),
);
});
}
➤ Testing without Flutter #
You can use RefenaContainer
to test your providers without Flutter.
void main() {
test('My test', () {
final ref = RefenaContainer();
expect(ref.read(myCounter), 0);
ref.notifier(myCounter).increment();
expect(ref.read(myCounter), 1);
});
}
➤ Testing ReduxProvider #
For simple tests, you can use ReduxNotifier.test
. It provides a state
getter, a setState
method and optionally allows you to specify an initial state.
void main() {
test('My test', () {
final counter = ReduxNotifier.test(
redux: Counter(),
initialState: 11,
);
expect(counter.state, 11);
counter.dispatch(IncrementAction());
expect(counter.state, 12);
counter.setState(42); // set state directly
expect(counter.state, 42);
});
}
To quickly override a ReduxProvider
, you can use overrideWithReducer
.
void main() {
test('Override test', () {
final ref = RefenaContainer(
overrides: [
counterProvider.overrideWithReducer(
reducer: {
IncrementAction: (state) => state + 20,
DecrementAction: null, // do nothing
},
),
],
);
expect(ref.read(counterProvider), 0);
// Should use the overridden reducer
ref.redux(counterProvider).dispatch(IncrementAction());
expect(ref.read(counterProvider), 20);
// Should not change the state
ref.redux(counterProvider).dispatch(DecrementAction());
expect(ref.read(counterProvider), 20);
});
}
➤ Testing Notifiers #
You can use Notifier.test
(or AsyncNotifier.test
) to test your notifiers in isolation.
This is only useful if your notifier is not dependent on other providers, or if you have specified all dependencies via constructor (dependency injection).
void main() {
test('My test', () {
final counter = Notifier.test(
notifier: Counter(),
initialState: 11,
);
expect(counter.state, 11);
counter.notifier.increment();
expect(counter.state, 12);
});
}
➤ Access the state within tests #
A RefenaScope
is a Ref
, so you can access the state directly.
void main() {
testWidgets('My test', (tester) async {
final ref = RefenaScope(
child: const MyApp(),
);
await tester.pumpWidget(ref);
// ...
ref.notifier(myNotifier).increment();
expect(ref.read(myNotifier), 2);
});
}
➤ State events #
Use RefenaHistoryObserver
to keep track of every state change.
void main() {
testWidgets('My test', (tester) async {
final observer = RefenaHistoryObserver();
await tester.pumpWidget(
RefenaScope(
observers: [observer],
child: const MyApp(),
),
);
// ...
expect(observer.history, [
ProviderInitEvent(
provider: myProvider,
notifier: myNotifier,
cause: ProviderInitCause.access,
value: 1,
),
ChangeEvent(
notifier: myNotifier,
action: null,
prev: 1,
next: 2,
rebuild: [WidgetRebuildable<MyLoginPage>()],
),
]);
});
}
To test Redux, you can access RefenaHistoryObserver.dispatchedActions
:
final observer = RefenaHistoryObserver.only(
actionDispatched: true,
);
// ...
expect(observer.dispatchedActions.length, 2);
expect(observer.dispatchedActions, [
isA<IncrementAction>(),
isA<DecrementAction>(),
]);
➤ Example test #
There is an example test that shows how to test a counter app.
Add-ons #
Add-ons are features implemented on top of Refena, so you don't have to write the boilerplate code yourself. The add-ons are entirely optional, of course.
To get started, add the following import:
import 'package:refena_flutter/addons.dart';
The core library never imports the add-ons, so we don't need to publish an additional package as the add-ons are tree-shaken away if you don't use them.
➤ Snackbars #
Show snackbar messages.
First, set up the snackBarProvider
:
MaterialApp(
scaffoldMessengerKey: ref.watch(snackBarProvider).key,
home: MyPage(),
)
Then show a message:
class MyNotifier extends Notifier<int> {
@override
int init() => 10;
void increment() {
state++;
ref.read(snackbarProvider).showMessage('Incremented');
}
}
Optionally, you can also dispatch a ShowSnackBarAction
:
ref.global.dispatch(ShowSnackBarAction(message: 'Hello World from Action!'));
➤ Navigation #
Manage your navigation stack.
First, set up the navigationProvider
:
MaterialApp(
navigatorKey: ref.watch(navigationProvider).key,
home: MyPage(),
)
Then navigate:
class MyNotifier extends Notifier<int> {
@override
int init() => 10;
void someMethod() async {
state++;
ref.read(navigationProvider).push(MyPage());
// or wait for the result
final result = await ref.read(navigationProvider).push<DateTime>(DatePickerPage());
}
}
Optionally, you can also dispatch a NavigateAction
:
ref.global.dispatchAsync(NavigateAction.push(SecondPage()));
// or wait for the result
final result = await ref.global.dispatchAsync<DateTime?>(
NavigateAction.push(DatePickerPage()),
);
➤ Actions #
The add-on actions are all implemented as GlobalAction
.
So you can dispatch them from anywhere.
ref.global.dispatch(ShowSnackBarAction(message: 'Hello World from Action!'));
Please read the full documentation about global actions here.
You can easily customize the given add-ons by extending their respective "Base-" classes.
class CustomizedNavigationAction<T> extends BaseNavigationPushAction<T> {
@override
Future<T?> navigate() async {
// get the key
GlobalKey<NavigatorState> key = ref.read(navigationProvider).key;
// navigate
T? result = await key.currentState!.push<T>(
MaterialPageRoute(
builder: (_) => _SecondPage(),
),
);
return result;
}
}
Then, you can use your customized action:
class MyAction extends ReduxAction<Counter, int> with GlobalActions {
@override
int reduce() => state + 1;
@override
void after() {
global.dispatchAsync(CustomizedNavigationAction());
}
}
Dart only #
You can use Refena without Flutter.
# pubspec.yaml
dependencies:
refena: <version>
void main() {
final ref = RefenaContainer();
ref.read(myProvider);
ref.notifier(myNotifier).doSomething();
ref.stream(myProvider).listen((value) {
print('The value changed from ${value.prev} to ${value.next}');
});
}
Deep dives #
This README is a high-level overview of Refena.
To learn more about each topic, checkout the topics.
License #
MIT License
Copyright (c) 2023-2024 Tien Do Nam
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.