refena 0.40.4 copy "refena: ^0.40.4" to clipboard
refena: ^0.40.4 copied to clipboard

A state management library for Dart and Flutter. Inspired by Riverpod and async_redux.

logo

pub package ci License: MIT

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.ref to access the provider:

class MyPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    var ref = context.ref;
    int counterState = ref.watch(counterProvider);
    return Scaffold(
      body: Center(
        child: Text('Counter state: $counterState'),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () => ref.notifier(counterProvider).increment(),
        child: const Icon(Icons.add),
      ),
    );
  }
}

Or in Redux style:

final counterProvider = ReduxProvider<Counter, int>((ref) => Counter());

class Counter extends ReduxNotifier<int> {
  @override
  int init() => 10;
}

class AddAction extends ReduxAction<Counter, int> {
  final int amount;
  AddAction(this.amount);
  
  @override
  int reduce() => state + amount;
}

class MyPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    var ref = context.ref;
    int counterState = ref.watch(counterProvider);
    return Scaffold(
      body: Center(
        child: Text('Counter state: $counterState'),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () => ref.redux(counterProvider).dispatch(AddAction(2)),
        child: const Icon(Icons.add),
      ),
    );
  }
}

With a feature-rich Refena Inspector:

inspector

Table of Contents #

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 parameter to implement util functions that need access to ref. These functions can be called by providers and also by widgets.

ref.watch:
Only the ViewProvider can watch other providers. Every other provider can only be accessed with ref.read or ref.notifier within a provider body. This ensures that the notifier itself is not accidentally rebuilt.

Use ref anywhere, anytime:
Don't worry that the ref within providers or notifiers becomes invalid. They live as long as the RefenaScope. With ensureRef, you also can access the ref within initState or dispose of a StatefulWidget.

No provider modifiers:
There is no .family or .autodispose. This makes the provider landscape simple and straightforward.

Notifier first:
With Notifier, AsyncNotifier, PureNotifier, and ReduxNotifier, you can choose the right notifier for your use case.

➤ Similarities #

Testable:
The state is still bound to the RefenaScope widget. This means that you can override every provider in your tests.

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.

➤ 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);

Refena vs async_redux #

Compared to async_redux, Refena encourages you to split the state into multiple notifiers.

This makes it easier to implement isolated features, so you not only have separation of concerns between UI and business logic, but also between different features.

Refena uses dependency injection to allow you to use other notifiers. Dependency injection enables the creation of the dependency graph.

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((_) => 42);

Step 4: Use the provider

class MyPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final myValue = context.ref.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'),
        ],
      ),
    );
  }
}

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 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. Each one has its own purpose.

The most important ones are Provider, NotifierProvider, and ReduxProvider because they are the most flexible.

Provider Usage Notifier API Can watch
Provider Constants or stateless services - No
FutureProvider Immutable async values - No
FutureFamilyProvider Immutable collection of async values - No
StateProvider Simple states setState No
ChangeNotifierProvider Performance critical services Custom methods No
NotifierProvider Regular services Custom methods No
AsyncNotifierProvider Services that need futures Custom methods No
ReduxProvider Action based services Custom actions No
ViewProvider View models - Yes

➤ Provider #

The Provider is the most basic provider. It is simple but very powerful.

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 that never change.

Example use cases:

  • fetch static data from an API (that does not change)
  • fetch device information (that does not change)

The advantage over FutureBuilder is that the value is cached and the future is only called 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.

final userProvider = FutureFamilyProvider<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);

➤ 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 #

Redux full documentation.

The ReduxProvider is the strictest option. The state is solely altered by actions.

You need to provide other notifiers via constructor making the ReduxNotifier self-contained and testable.

This has two main benefits:

  • Logging: With RefenaDebugObserver, you can see every action in the console.
  • Testing: You can easily 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;

  @override
  void after() {
    // dispatch actions of other notifiers
    external(notifier.serviceA).dispatch(SomeAction());

    // access the state of other notifiers
    if (notifier.serviceB.state == 3) {
      // ...
    }

    // dispatch actions in the same notifier
    dispatch(AddAction(amount - 1));
  }
}

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 ref = context.ref;
    final state = ref.watch(counterProvider);
    return Scaffold(
      body: Column(
        children: [
          Text(state.toString()),
          ElevatedButton(
            onPressed: () => ref.redux(counterProvider).dispatch(AddAction(2)),
            child: const Text('Increment'),
          ),
          ElevatedButton(
            onPressed: () => ref.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.ref.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 have initialization and dispose logic, you can use the ViewModelBuilder widget.

It automatically disposes the view model when the widget is removed from the widget tree.

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,
      init: (context, ref) => ref.notifier(authProvider).init(),
      dispose: (context, 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'),
                ),
              ],
            ),
          ),
        );
      },
    );
  }
}

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:

class MyNotifier extends Notifier<int> {
  @override
  int init() => 10;

  @override
  void postInit() {
    // do some work
  }
}

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();
  }
}

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 when the value changes.

This should be used within a build method.

build(BuildContext context) {
  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.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 or an AsyncNotifierProvider.

Future<String> version = ref.future(versionProvider);

➤ 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).dispatch(MyAction());

➤ 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 have a RefenaTracingPage.

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;

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 StatefulWidgets.

Simple StatefulWidgets 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 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 #

The RefenaScope accepts observers.

You can implement one yourself or just use the included RefenaDebugObserver.

void main() {
  runApp(
    RefenaScope(
      observers: [
        if (kDebugMode) ...[
          RefenaDebugObserver(),
        ],
      ],
      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.dispatch(ShowSnackBarAction(message: 'No internet connection'));
      }
    }
  }
}

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()],
      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: Center(
        child: ElevatedButton(
          onPressed: () {
            Navigator.of(context).push(
              MaterialPageRoute(
                builder: (_) => const RefenaTracingPage(),
              ),
            );
          },
          child: const Text('Show tracing'),
        ),
      ),
    );
  }
}

Here is how it looks like:

tracing-ui

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: Center(
        child: ElevatedButton(
          onPressed: () {
            Navigator.of(context).push(
              MaterialPageRoute(
                builder: (_) => const RefenaGraphPage(),
              ),
            );
          },
          child: const Text('Show graph'),
        ),
      ),
    );
  }
}

Here is how it looks like:

graph-ui

➤ Inspector #

Both the RefenaTracingPage and the RefenaGraphPage are included in the Refena Inspector.

inspector

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.

See the example test.

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.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.dispatchAsync(NavigateAction.push(SecondPage()));

// or wait for the result
final result = await ref.dispatchAsync<DateTime?>(
  NavigateAction.push(DatePickerPage()),
);

➤ Actions #

The add-on actions are all implemented as GlobalAction.

So you can dispatch them from anywhere.

ref.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 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.

29
likes
0
pub points
76%
popularity

Publisher

verified publisherrefena.dev

A state management library for Dart and Flutter. Inspired by Riverpod and async_redux.

Repository (GitHub)
View/report issues

Topics

#state #provider #riverpod #framework #redux

Funding

Consider supporting this project:

github.com

License

unknown (license)

Dependencies

collection, meta

More

Packages that depend on refena