Organize Your “Global” Providers in Flutter Riverpod with Mixin Class

Organize Your “Global” Providers in Flutter Riverpod with Mixin Class

Do you like Riverpod?

I’ve been using the Riverpod package for state management in a new Flutter project I recently started. Since I have mainly used packages like Provider or GetX before, I wasn’t familiar with Riverpod. However, I was curious about why Flutter users have been so enthusiastic about Riverpod lately. So, I decided to boldly choose Riverpod for my project.

And not long after, I too fell deeply in love with the charm of Riverpod

While implementing the project using Riverpod, I was mostly satisfied with the package’s reactive mechanism and various features it offers. However, there was one aspect where I had an unsatisfactory experience.

It’s the fact that providers are declared globally (top-level).

I don’t mean to say that a provider declared as a global variable is always evil. (Moreover, the state of the provider is managed within the ProviderContainer, so it’s heardly global)

As mentioned in the Riverpod official documentation, globally declared providers have an immutable nature, so they don’t interfere with the app’s lifecycle or cause issues when writing test code. However, the downside is that being able to access the provider from anywhere with just an import makes it difficult to determine which providers are being used on a specific page.

Issues Arising from the Global Declaration of Riverpod

Such a drawback comes with various challenges.

For instance, imagine you join a Flutter project that utilizes Riverpod, and your supervisor assigns you the following task.

“Please write unit test code based on the Providers used on the home page.”

Since you’ve just been assigned to the project, it would be challenging to grasp which provider state values and event methods are being used on the home page. Consequently, this makes it time-consuming to understand and write the test code.

Moreover, in a collaborative project involving multiple team members, understanding the providers used on a specific page is crucial. If you don’t have a clear understanding of the scope of provider usage, you might miss opportunities to reuse already-created providers and inadvertently introduce unnecessary additional providers, leading to potential side effects.

In addition to these issues, the difficulty in comprehending the scope of provider usage, especially in larger and more complex apps, becomes a factor that complicates the project’s maintenance.

The Rivepod “Global” Myth

To mitigate the aforementioned issues, it is crucial to structure the scope of provider usageIn other words, it should be easy to discern which providers are being used in a specific section. While contemplating how to structure the scope of provider usage, I came across a YouTube video titled ‘The Riverpod “Global” Myth’ by Randal L. Schwartz

I highly recommend watching this video

In this video, various methods for not only dispelling the misconception that Riverpod exclusively uses global variables but also for structuring the scope of provider usage are explained in detail.

In this post, I’ll briefly introduce two methods discussed in the video:

  • Local Variables (precixed with underscore)
  • Class static locals

Additionally, in the aspect of structuring the scope of provider usage, I’ll explain a method that is somewhat similar to what Randal introduced earlier but utilizes the mixin class introduced in Dart 3.0. This technique provides a clear and testable structure for the scope of provider usage. Feel free to explore it.

The examples covered in this post are based on the ‘Todo app’ from the Riverpod official documentation.

Local Variables

Firstly, let’s explore a method of localizing providers by declaring them as private to a specific page section.

final _uncompletedTodosCount = Provider<int>((ref) {  
  return ref.watch(todoListProvider).where((todo) => !todo.completed).length;  
});

class Toolbar extends HookConsumerWidget {  
  const Toolbar({  
    Key? key,  
  }) : super(key: key);  
  
  @override  
  Widget build(BuildContext context, WidgetRef ref) {  
    return Material(  
      child: Row(  
        mainAxisAlignment: MainAxisAlignment.spaceBetween,  
        children: [  
          Text(  
            '${ref.watch(_uncompletedTodosCount)}', // <- Accessing the localized provider
          ),
         ...
         
      }
  }

By restricting the access scope of a provider to private within a specific source file, the provider can only be accessed within that file. This allows for explicit management of the provider’s usage scope.

However, if you need to access this provider from multiple child widgets separated into different classes, the code may become somewhat complex.

final _uncompletedTodosCount = Provider<int>((ref) {  
  return ref.watch(todoListProvider).where((todo) => !todo.completed).length;  
});

part 'tool_bar3.dart'; // <- Separated into a part file

class HomePage extends HookConsumerWidget with HomeEvent, HomeState {  
  const HomePage({Key? key}) : super(key: key);  
  
  @override  
  Widget build(BuildContext context, WidgetRef ref) {  
  
    return Scaffold(  
      body: ListView(  
        children: [  
          Toolbar1(ref.watch(_uncompletedTodosCount)),
          Toolbar2(ref.watch(_uncompletedTodosCount)),
          const _Toolbar3(), 
          ...
          
      } 
  }

For example, if HomePage has child widgets named Toolbar1Toolbar2, and Toolbar3, and all of them need to access the _uncompletedTodosCount provider, it might lead to inconvenience, either by passing the localized provider’s state value as an argument each time or by separating child widgets into part files.

Class static locals

Another method to address the previously mentioned issue is to assign providers to static variables within a class.

abstract class HomeProviders {  
  HomeProviders._();  
  
  static final todoListFilter = StateProvider((_) => TodoListFilter.all);  
  
  static final uncompletedTodosCount = Provider<int>((ref) {  
    return ref.watch(todoListProvider).where((todo) => !todo.completed).length;  
  });

  ...
}

In this code, all providers used in the Home section are assigned as static variables within the class.

class Toolbar extends ConsumwerWidget {  
  const Toolbar({  
    Key? key,  
  }) : super(key: key);  
  
  @override  
  Widget build(BuildContext context, WidgetRef ref) {  
    return Material(  
      child: Row(  
        mainAxisAlignment: MainAxisAlignment.spaceBetween,  
        children: [  
          Text(  
            '${ref.watch(HomeProviders.uncompletedTodosCount)}', 
            // Accessing the provider through a static variable
          ),
         ...
         
      }
  }

Next, you can refer to the required providers in your widgets through this class. Since the provider is assigned as a static variable within the class, it allows for structuring the scope of provider usage without creating unnecessary instances or interfering with the lifecycle of the provider.

Structuring the Scope of Provider Usage Using Mixin Class

While the two methods already introduced are sufficient to explicitly define the scope of provider usage, let’s explore little bit more explicit and test-friendly approach using the Mixin Class.

This approach employs two forms of Mixin Classes.

State Mixin Class

Firstly, the State Mixin Class. This class consists of methods that return the state values of all providers used on a specific page.

mixin class HomeState {  
  int uncompletedTodosCount(WidgetRef ref) => ref.watch(uncompletedTodosCountProvider);  
  
  List<Todo> filteredTodos(WidgetRef ref) => ref.watch(filteredTodosProvider);  
  
  ...
}

The above HomeState Mixin Class manages the state values of providers used in the HomePage section. Each method takes a WidgetRef as an argument and uses the watch extension method of WidgetRef to pass the state.

AsyncValue<Todo> todoAsync(WidgetRef ref) => ref.watch(todoProvider);

If you need to return asynchronous data of type Future, you can wrap the value in the AsyncValue type.

Unlike the previously introduced methods, this approach does not manage the provider itself as a variable but rather returns the state value of the provider as a method using WidgetRef.

The State Mixin Class configured as above is then mixed into the widget class, allowing the widget to access the state values of providers.

Event Mixin Class

Next, let’s examine the Event Mixin Class. The Event Mixin Class efficiently manages all event logic used in a specific section. Similar to the State Mixin Class, it takes a WidgetRef as an argument, allowing easy access to provider methods.

mixin class HomeEvent {  
  void addTodo(  
    WidgetRef ref, {  
    required TextEditingController textEditingController,  
    required String value,  
  }) {  
    ref.read(todoListProvider.notifier).add(value);  
    textEditingController.clear();  
  }  

  void requestTextFieldsFocus(  
    {required FocusNode textFieldFocusNode,  
    required FocusNode itemFocusNode}) {  
  itemFocusNode.requestFocus();  
  textFieldFocusNode.requestFocus();  
  }
...

}

For example, the addTodo method above, through the WidgetRef object, accesses the Notifier Provider named todoListProvider to execute a method that adds a new item to the current list.

NOTE: It is recommended to manage not only logic related to providers but also event values caused by other user interactions in the same Mixin Class. Managing event values used in a specific section in one place allows for a perfect separation of UI code and event logic, enhancing readability and making code tracking easier.

Similarly, the Event Mixin Class, as shown above, is mixed into widgets that require event methods, making it easy to pass event methods.

Core Concept

While it may seem a bit complex, the concept is straightforward.

The key is not to directly access the provider from the widget but to provide a new channel for accessing the provider through State and Event Mixin Classes.

For those familiar with using BLoC, you might notice some similarity in separating state and event. The advantages of separating the state and event logic of providers will be explored in the subsequent content.

Benifits

Now, what are the benefits of managing Riverpod provider state values and event methods in Mixin Classes? Let’s explore five major advantages.

1. Easy Maintenance

The logic of providers used in a specific page section is centrally managed in a single Mixin Class, making it easy to maintain.

mixin class HomeState {  
  List<Todo> todos(WidgetRef ref) => ref.watch(todoListFromRemoteProvider).value; 
}

For example, in the above code, let’s assume that we are retrieving remote data through the todoListFromRemoteProvider provider, and multiple widgets within a specific page reference this value.

mixin class HomeState {  
  List<Todo> todos(WidgetRef ref) => ref.watch(todoListFromLocal); 
}

If you need to change from calling remote data to loading local data in the existing provider, changing it to the todoListFromLocal provider, you can simply replace the provider in the HomeState class.

However, without using Mixin State Class and having a structure where each widget directly uses the provider, you may encounter the inconvenience of having to manually change each widget’s existing provider to the new provider.

2. Improved Readability

When managing provider resources in Mixin Classes, you can easily understand which provider state values and event logic are used on a specific page at a glance. This is because the Mixin Class is mixed into the parent page widget or child widgets, establishing clear dependencies.

Furthermore, by managing event logic in the Event Mixin Class, you can achieve a perfect separation of UI code and event methods, enhancing readability.

3. Advantages in Writing Unit Test Code

Using the State and Event Mixin Classes makes writing unit test code more convenient.

Understanding Test Scope

Setting the test scope can be challenging, especially as the app grows larger, before writing unit test code. It’s always a concern about where the testing should stop.

mixin class HomeEvent {  
  void addTodo(  
    WidgetRef ref, {  
    required TextEditingController textEditingController,  
    required String value,  
  }) { ... }  
  
  void removeTodo(WidgetRef ref, {required Todo selectedTodo}) { ... }  
  
  void changeFilterCategory(WidgetRef ref, {required TodoListFilter filter}) { ... }  
  
  void toggleTodoState(WidgetRef ref, {required String todoId}) { ... }  
  
  void editTodoDesc(WidgetRef ref,  
      {required bool isFocused,  
      required TextEditingController textEditingController,  
      required Todo selectedTodo}) { ... }  
}

However, if you can easily understand the event logics used in a specific page at a glance in the Event Mixin Class, it can be quite helpful in setting the test scope and constructing test scenarios.

Concise Unit Test Code

Utilizing the existing State and Event Mixin modules makes unit test code much more concise.

mixin class HomeEventTest {  
  void addTodo(  
    ProviderContainer container, {  
    required TextEditingController textEditingController,  
    required String value,  
  }) {  
    container.read(todoListProvider.notifier).add(value);  
    textEditingController.clear();  
  }  
  
  void removeTodo(ProviderContainer container, {required Todo selectedTodo}) {  
    container.read(todoListProvider.notifier).remove(selectedTodo);  
  }
  ...
  
}

mixin class HomeStateTest {  
  List<Todo> filteredTodos(ProviderContainer container) =>  
      container.read(filteredTodosProvider);  
  
  int uncompletedTodosCount(ProviderContainer container) =>  
      container.read(uncompletedTodosCountProvider);
  ...
  
}

First, copy the code of the existing State and Event Mixin modules to create a new Test Mixin Class. In this case, change the argument from WidgetRef to ProviderContainer type and replace the existing .watch method with .read to execute the test code.

void main() {  
  final homeEvent = HomeEventTest();  
  final homeState = HomeStateTest();  
  
  test('Add todo', () {  
    final container = createContainer();  
    const String todoDescription = 'Write Riverpod Test Code';  
    homeEvent.onTodoSubmitted(container,  
        textEditingController: TextEditingController(), value: todoDescription);  
    expect(  
        homeState.filteredTodos(container).last.description, todoDescription);  
  });
}

Then, within the test main method, initialize the instances of each State and Event Mixin Class and use these instances to write test code.

Explained step by step:

  1. Initialization: Initialize the necessary test State and Event Mixin instances.
  2. Manipulation: Use the test Event Mixin to execute event logics and manipulate and change the state.
  3. Verification: Utilize the test State Mixin to verify the expected values.

Since the Event Mixin Class contains the event methods to be tested, and the State Mixin Class has the expected test result values defined, writing unit test code, including complex scenarios, becomes straightforward using these.

4. Increased Efficiency in the Workflow

During real-world projects, developers often receive feature specifications and API specs while the design might not be completed. In such situations, it is possible to perform the task of preparing State and Event Mixin Class modules in advance without waiting for the design to be finalized. By utilizing these pre-made Mixin Classes, developers can implement UI widgets after receiving the finalized design and seamlessly proceed with the project by integrating the already-created Mixin Classes, eliminating any gaps in the process.

5. Minimization of Errors in the Collaboration Process

As mentioned earlier, understanding which providers are being used on a specific page becomes crucial, especially in collaborative projects with multiple team members. Overlooking this aspect might lead to the inadvertent creation of an additional provider with the same functionality but a different name, resulting in unexpected side effects (based on personal experience). Therefore, minimizing such oversights is crucial in collaborative projects where multiple contributors are involved.

Conclusion

In this post, we explored the method of structuring the scope of providers in Riverpod using Mixin Classes. While this approach might be seen as over-engineering for smaller applications, it proves to be more advantageous as the app scales and the number of managed providers increases. Personally, I find the ease of writing unit test code with this approach particularly appealing.

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top