You Might Not Need State Management: The Flutter Edition

You Might Not Need State Management: The Flutter Edition

·

11 min read

Featured on Hashnode

The year was 2021. I had just started building a greenfield application for a startup, and during our discovery phase, we decided to build the Minimum Viable Product in React instead of Flutter. While building out the MVP and researching some of the more recent advancements of React, I stumbled upon React Query (now TanStack Query) - a data-fetching library that handles fetching, caching, synchronizing and updating server state. The concepts of TanStack Query are not new - they were introduced to the React community in part by Apollo GraphQL. One major difference was that platform-agnostic nature: any Promise could be considered a query.

In the following months I would use React Query heavily. In doing so, I devoted less time to solving architecture problems and was able to focus on solving business problems. That experience became the impetus for how I view the current & future state of Riverpod.

Introduction

This article is definitely about Flutter, not React. What is important about this long preamble isn't just what is coming in Riverpod. Had the team not made that initial decision, my approach to building Flutter applications would still be limited to the status quo. My view of application architecture would still be stunted by my belief that Riverpod was just Provider 2.0, not too dissimilar from Scoped Model, Bloc, GetX, and all the libraries that preceded it. Another state management library introduced to help facilitate application architecture. Tinkering and stepping outside your comfort zone is rewarding at any experience level.

Architecture in Flutter

Application architecture in Flutter is what we have been implementing manually for years using endless bowls of async-spaghetti code to quote TanStack's introduction. You may have seen this graphic before.

Application architecture

The previous figure is a modified version of Android's Architecture Component diagrams. It demonstrates a common architectural pattern in Flutter:

  • A UI layer with views for the UI and state holder classes (like Controllers in MVC) that manage remote state and networking state.
  • A data layer that includes repositories for fetching and caching and data sources that handle synchronizing and updating server state.
  • An optional domain layer for the reusable bits between the UI layer and the data layer.

The aforementioned libraries only solve for part of the equation and everything else is solved with that aforementioned spaghetti code. State management is rarely a solution to a business problem. It's a tool that helps you build architecture, which in turn will help you solve business problems. This is where Riverpod 2.0 comes into play. We already have well-defined patterns for retrieving and caching information from a remote data source, so we shouldn't have to reimplement them. Jake Archibald published an extensive offline cookbook in 2014 after all. We also know that we may want to perform actions like polling or invalidating queries when information updates because of a user action or the window state changes.

Exploring Riverpod 2.0

For this exercise, we will explore a simple example application to illustrate the benefits of Riverpod +2. This application will do the following:

  • Define an ExcuseFacade data source to retrieve excuses from a remote data source.
  • Define a FutureProvider that will return a list of excuses using the ExcuseFacade data source.
  • Display the excuse on the Home Screen of the application.

I will not cover the changes to Riverpod that have not yet been published (maybe at a later date), so please refer to Remi's talk at Flutter Vikings 2022 if this article further piques your interest.

Furthermore, if you find the example code to be incomplete this is by design. You can follow along using the source code from this repository, but this is not necessary. The following code snippets meant to represent the paradigm shift between where we are and where we are going.

Defining the common code

Before we explore the differences between Provider and Riverpod, let's setup some common code that could be used for both libraries. We would start by defining a ExcuseFacade class using the following code:

class ExcuseFacade {
  Future<List<Excuse>> fetchExcuses() async {
    try {
      Response response = await Dio().get(
          'https://s3.ap-south-1.amazonaws.com/idontlikework/wfh-reasons.json');

      List<dynamic> json = jsonDecode(response.data);
      var excuses = json.map((json) => Excuse.fromMap(json)).toList();

      return excuses;
    } catch (e) {
      print(e);
      rethrow;
    }
  }
}

The previous code sample defines a method called fetchExcuses that:

  • Tries to fetch a JSON response from a remote data source.
  • Converts the JSON response to a list of Excuse objects.
  • Prints and rethrows an error if one occurs.

Next, let's define the aforementioned Excuse model class by adding the following code:

class Excuse extends Equatable {
  const Excuse({
    required this.text,
    required this.id,
  });

  factory Excuse.fromMap(Map<String, dynamic> map) {
    return Excuse(
      text: (map['text'] as String?) ?? '',
      id: map['id'] ?? -1,
    );
  }

  final String text;
  final int id;

  Excuse copyWith({
    String? text,
    int? id,
  }) {
    return Excuse(
      text: text ?? this.text,
      id: id ?? this.id,
    );
  }

  Map<String, dynamic> toMap() {
    return {
      'text': text,
      'id': id,
    };
  }

  Map<String, dynamic> toJson() => toMap();

  @override
  List<Object> get props => [text, id];

  @override
  bool? get stringify => true;
}

In this code sample, we have defined a standard model object with two fields - a unique ID and the text for the excuse - along with the standard properties for serialization and immutability.

Next we will look at how these classes would be leveraged in Provider.

Fetching with Provider

Using Provider we commonly consume and display information using a Listenable (ChangeNotifier being the most common), FutureProvider, or StreamProvider. The FutureProvider version would look like the following:

class _HomePageState extends State<HomePage> {
  @override
  Widget build(BuildContext context) {
    return FutureProvider<Resource<List<Excuse>>>(
      create: (context) => context
          .read<ExcuseFacade>()
          .fetchExcuses()
          .then((value) => Resource.success(data: value))
          .catchError((err) => Resource<List<Excuse>>.error(msg: '$err')),
      initialData: const Resource.loading(),
      builder: ((context, child) {
        final Resource<List<Excuse>> state = context.watch();
        final excuses = state.data;

        return Scaffold(
          body: SafeArea(
            child: Padding(
              padding: const EdgeInsets.all(16.0),
              child: ExcusesPageTransitionSwitcher(
                child: Builder(
                  key: ValueKey(state),
                  builder: (context) {
                    if (state is LoadingResource) {
                      return const ExcuseSkeletonView();
                    }

                    if (state is ErrorResource) {
                      return const ExcuseErrorView();
                    }

                    return ExcusesDataView(
                      excuses: excuses!,
                    );
                  },
                ),
              ),
            ),
          ),
          floatingActionButton: (excuses?.isNotEmpty ?? false)
              ? NextFloatingActionButton(
                  excuses: excuses!,
                )
              : null,
        );
      }),
    );
  }
}

In the previous code example we:

  • Define a future provider that leverages the ExcuseFacade.fetchExcuses function to retrieve excuses.
  • Wrap the excuses data in a Resource class that will define the meta networking state of our request.
  • Translate the Resource state to the appropriate UI by unwrapping the networking state.

Running the example in an emulator would display the following:

Excuses app loading transition

In the previous image, you can see the application transition from a loading state to a success state, rendering the first excuses.

Next, let's explore how that same example looks in Riverpod.

Fetching and caching with Riverpod

A reasonable port of the logic from Provider to Riverpod might look something like the following:

class HomePage extends ConsumerWidget {
  const HomePage({Key? key}) : super(key: key);

  static Page<dynamic> route() => const MaterialPage(
        child: HomePage(),
      );

  @override
  Widget build(BuildContext context, ref) {
    final excuses$ = ref.watch(excusesProvider);

    return Scaffold(
      body: SafeArea(
        child: ExcusesPageTransitionSwitcher(
            child: Padding(
              padding: const EdgeInsets.all(16.0),
              child: ExcusesPageTransitionSwitcher(
                child: excuses$.when(
                  data: (excuses) => ExcusesDataView(excuses: excuses),
                  error: (_, __) => const ExcuseErrorView(),
                  loading: () => const ExcuseSkeletonView(),
                ),
              ),
            ),
        ),
      ),
      floatingActionButton: excuses$.hasValue
          ? NextFloatingActionButton(
              excuses: excuses$.value!,
            )
          : null,
    );
  }
}

final excusesProvider = FutureProvider<List<Excuse>>((ref) async {
  final excusesFacade = ref.read(excusesFacadeProvider);
  return await excusesFacade.fetchExcuses();
});

final excusesFacadeProvider = Provider<ExcuseFacade>((ref) {
  return ExcuseFacade();
});

The obvious change is that in using Riverpod's FutureProvider we avoid having to define a class to represent remote state because this feature is built into the 3rd-party library. We are deferring commonly implemented logic to the library. A less obvious change is that the library includes the logic to update the cache of excuses using the popular stale-while-revalidate pattern. On a manual refresh, the cached data will be returned immediately and then the UI will be updated when the updated version is retrieved successfully.

We could demonstrate this by enhancing the Scaffold with the ability to refresh the list of excuses using a RefreshIndicator widget.

    return Scaffold(
      body: SafeArea(
        child: RefreshIndicator(
          onRefresh: () {
            return ref.refresh(excusesProvider.future).then((excuses) {
              final newExcuse = _randomIndex(excuses.length);
              final id = excuses[newExcuse].id.toString();
              Routemaster.of(context).push('/excuses/$id');
            });
          },
          child: CustomScrollView(
            physics: const AlwaysScrollableScrollPhysics(),
            slivers: [
              SliverFillRemaining(
                child: Padding(
                  padding: const EdgeInsets.all(16.0),
                  child: ExcusesPageTransitionSwitcher(
                    child: excuses$.when(
                      data: (excuses) => ExcusesDataView(excuses: excuses),
                      error: (_, __) => const ExcuseErrorView(),
                      loading: () => const ExcuseSkeletonView(),
                    ),
                  ),
                ),
              )
            ],
          ),
        ),
      ),
      floatingActionButton: excuses$.hasValue
          ? NextFloatingActionButton(
              excuses: excuses$.value!,
            )
          : null,
    );

Notice that we are using the ref.refresh function that is built into Riverpod. This function will refresh the cache of Excuses while notifying the UI that the query is refreshing. Then it will update the UI with the refreshed Excuses. If we rerun the application, these changes would mirror the following graphic:

Excuses app: pull to refresh transition

To further demonstrate how this caching pattern works, we could change the opacity of the current excuse while the query is refreshing:

              SliverFillRemaining(
                child: Padding(
                  padding: const EdgeInsets.all(16.0),
                  child: ExcusesPageTransitionSwitcher(
                    child: excuses$.when(
                      data: (excuses) => AnimatedOpacity(
                        duration: const Duration(milliseconds: 350),
                        curve: Curves.decelerate,
                        opacity: excuses$.isRefreshing ? .5 : 1,
                        child: ExcusesDataView(excuses: excuses),
                      ),
                      error: (_, __) => const ExcuseErrorView(),
                      loading: () => const ExcuseSkeletonView(),
                    ),
                  ),
                ),
              )

Rerunning the application with the previous code change would result produce the aforementioned animated behavior:

Excuses app: pull to refresh transition with opacity

This example demonstrates capabilities built into Riverpod that are not available in Provider and would normally be defined in each Controller class.

  • Fetching from a remote data source and wrapping with remote state.
  • Cache invalidation on refresh using the SWR pattern.
  • Exposing the refresh state to the UI.

We could certainly abstract this logic in a generic way to reuse it, but in doing so we would also be creating a larger surface area to test and maintain patterns that are well-defined.

Now let's take a look at how to update and synchronize server state in Riverpod.

Synchronizing and updating with Riverpod

Currently, features to synchronize and update server state have not been implemented, but the feature is planned.

For a good custom solution to this problem, refer to Andrea Bizzotto's article How to handle loading and error states with StateNotifier & AsyncValue in Flutter.

Using that aforementioned example, we could create a generic mutation notifier that approximates some of the functionality from the article:

import 'dart:async';

import 'package:riverpod/riverpod.dart';

class MutationNotifier extends StateNotifier<AsyncValue> {
  MutationNotifier() : super(const AsyncValue.data(null));

  Future<void> mutate(FutureOr<void> Function() mutationFn) async {
    state = const AsyncValue<void>.loading();
    state = await AsyncValue.guard(() async => mutationFn());
  }
}

final mutationProvider =
    StateNotifierProvider.family<MutationNotifier, AsyncValue, String>((
  ref,
  key,
) {
  return MutationNotifier();
});

In the above code, we have defined a MutationNotifier that has a single function mutate. This function accepts a function parameter and executes it using AsyncValue.guard. Additionally, the mutationProvider accepts a String that is used purely to match the mutation being executed.

In practice, we could add a button to favorite excuses that would look like the following:

class FavoriteIconButton extends ConsumerWidget {
  const FavoriteIconButton({
    Key? key,
    required this.id,
    required this.isFavorite,
  }) : super(key: key);

  final int id;
  final bool isFavorite;

  @override
  Widget build(BuildContext context, ref) {
    final mutation = ref.watch(mutationProvider('toggleFavorite$id'));
    final icon = isFavorite ? Icons.bookmark : Icons.bookmark_outline;
    final handlePressed = mutation.isLoading
        ? null
        : () {
            final mutation =
                ref.read(mutationProvider('toggleFavorite$id').notifier);
            mutation.mutate(() =>
                ref.read(favoritesLogicControllerProvider).toggleFavorite(id));
          };

    return IconButton(
      icon: Icon(icon),
      onPressed: handlePressed,
    );
  }
}

/// Logic controller defined elsewhere
class FavoritesLogicController {
  FavoritesLogicController(this.ref);

  final Ref ref;

  Future<void> toggleFavorite(int id) async {
    await ref.read(excusesFacadeProvider).toggleFavorite(id);
    await ref.refresh(favoritesProvider.future);
  }
}

final favoritesLogicControllerProvider =
    Provider<FavoritesLogicController>((ref) {
  return FavoritesLogicController(ref);
});

We can describe the above code in two parts. First we

  • Defined a logic controller to handle the logic of favoriting/unfavoriting an excuse.

In the second part we

  • Define a FavoriteIconButton that will subscribe to the async state of an excuse being favorited using a remote server.
  • When the mutation is executing, the button is disabled.

Additionally, we could listen to state changes and display some errors if the mutation fails. Andrea's article goes into this in more depth, so I won't rehash the ideas here.

Now that we have a clear understanding of the differences between Riverpod and some of its competitors, let's quickly recap.

Conclusion

In this article, we learned how we can leverage Riverpod +2 to handle querying, updating, and synchronizing data using conventions built into the library that we would otherwise manually implement. The title of this article might be a bit misleading because state management has not disappeared - it's common responsibilities are just abstracted by Riverpod. In 2018, I wrote a similar article bucking the trend of using Redux in favor of simpler, more concise solutions until the complexity of a problem required Redux. This article operates in the same vein, with a natural progression to delegating complex problems to libraries built for them instead of with complex solutions.

Since Google IO 2018, we have accepted that pragmatic state management is what it is. Riverpod +2 helps us implement more of the problems around application architecture once rather than manually and repetitively. The purpose, as with TanStack Query, is that you don't have to solve monotonous, complex problems that have already been solved. You can focus on implementing solutions to business problems - the true key differentiators of your application.

The benefit of Riverpod +2 is not that you write less code. With Riverpod +2, you write more concise, simpler code to solve the same complex problems. There may be instances where you might need to use a more complex state management pattern (a complex offline sync that handles conflict resolution, for example) and in those cases, Riverpod provides all the escape hatches you would normally rely on. But those will likely be exceptions to the rule, and that is a good thing. The purpose of Riverpod, and any good library for that matter, is to make the complex, mundane tasks trivial.