State management patterns and their potential drawbacks

State management patterns and their potential drawbacks

Reactive applications are designed to respond to changes in their environment, such as user input or data updates, by emitting events and triggering side effects. However, certain state management patterns in reactive programming can lead to unintended consequences, which can result in unpredictable behavior and difficult-to-debug issues.

Outlined below are patterns that can be used effectively, but if misused, may result in antipatterns. Each section represents an approach, and its subsections constitute issues/problems associated to it and my final thoughts to it. An alternative pattern is suggested in the conclusion.

Opinions on software patterns can vary. I will make effort to present compelling reasoning and provide illustrative examples that support my perspective.


Flexible Application

Throughout the article, all code is written in Dart (with the use of Flutter); however, the same concepts can be applied to other frameworks, JS libraries, etc.


1. Prop drilling

Prop drilling occurs when data needs to be passed through multiple layers of nested widgets. In such cases, the data is passed down as props from a higher-level widget to its child components, and then potentially further down to their widgets, and so on. This can result in a long chain of passing props through widgets that do not directly use or modify the data. Prop drilling is therefore not an intentional design pattern but rather an inherent characteristic of the widget hierarchy.

Visually, it looks something like this:

Prop drilling graph example


Understanding prop drilling illustrations

  • All the nodes are stateful or stateless widgets.
  • Props starting with “o” are observables, those with “c” are instance constants.
    • The full type annotation for all o# instances is o# : Obs(T) (e.g. o1 : Obs(T)), while the one for c# is c# : T. To avoid repetition, the type for o# and c# is omitted in the next prop drilling figure.
      • For example, only o1 will be displayed instead of o1 : Obs<T>.
  • The props contained in some node “own state” indicate that they are created in the node’s initState.
    • Thus, nodes with own state must be stateful.
  • Each arrow color in the graph represents a unique reference to an object.

If the references in the illustration above are not sufficiently clear, a more detailed version can be provided with explicit references, specific to each widget:

Prop drilling graph example (with explicit references)

Node C could be represented by the code block below. The code for the remaining nodes follows a similar structure.

class NodeC extends StatefulWidget {
  const NodeC({
    super.key,
    this.o1,
    this.o2,
    this.c1,
    this.c2
  });

  final ValueNotifier<String> o1;
  final ValueNotifier<int> o2;
  final String c1;
  final CustomClass c2;

  @override
  State<NodeC> createState() => _NodeCState();
}

class _NodeCState extends State<NodeC> {
  final ValueNotifier<int> o3;
  final ValueNotifier<DateTime> o4;

  @override
  Widget build(BuildContext context) {
    return
      // ...
      // ... (Omitted code for brevity)
      // ...
      NodeF( // NodeF must be instantiated this way somewhere in the return statement
        o1: widget.o1,
        o4: o4,
      )
      // ...
      // ... (Omitted code for brevity)
      // ...
      NodeG( // NodeG must be instantiated this way somewhere in the return statement
        o1: widget.o1,
        o2: widget.o2,
        c1: widget.c1,
        c2: widget.c2,
        o3: o3,
      )
      // ...
      // ... (Omitted code for brevity)
      // ...
  }
}

The figures and the code snippet illustrate that, by passing explicitly down props (data, state, callbacks, etc.), both type safety and widget tree leverage are achieved.

This approach also has issues and problems, which are considered in the following subsections.

1.1. Code duplication

The main issues with prop drilling is that it can lead to duplication of state: when the same data is passed down to multiple child widgets, it results in redundant/repetitive code.

Duplicated local state can also increase the cognitive load on developers, especially if the code was not looked at in a while or if the person taking over the project did not write the code himself. As the widget hierarchy becomes more complex, it can be challenging to keep track of which widgets are passing which data and where that data is being used. This can make it nontrivial to identify and fix bugs, as well as make updates and changes to the codebase.

Moreover, duplicated local state can lead to decreased maintainability over time. As the application grows and becomes more complex, it can be increasingly daunting to add new features or make changes to existing ones. This can result in a codebase that is difficult to work with, decreasing productivity and causing frustration for developers.

1.2. Parameter misplacement

A concrete problem that arises from code duplication is the potential for parameter misplacement.

Since props have to be passed down and manually reassigned every time, this can cause parameter misplacements with data of the same type. It can happen more easily than expected, especially after refactoring parts of code. Also, unfortunately it is not possible for the IDE or the compiler to detect these kind of situations, since they are totally valid syntax.

A simple example:

class ParentWidget extends StatelessWidget {
  final ValueNotifier<int> ageNotifier;
  final ValueNotifier<int> distanceNotifier;

  ParentWidget(this.ageNotifier, this.distanceNotifier);

  @override
  build(BuildContext context) {
    return ChildWidget(distanceNotifier, ageNotifier);
  }
}

class ChildWidget extends StatelessWidget {
  final ValueNotifier<int> ageNotifier;
  final ValueNotifier<int> distanceNotifier;

  ChildWidget(this.ageNotifier, this.distanceNotifier);

  @override
  build(BuildContext context) =>
      Text('age: ${ageNotifier.value}, distance: ${distanceNotifier.value}');
}

Named parameters would definitely help identify this issue. However, I would personally avoid them unless it is really convenient (e.g. in situations where a lot of passed params use their default value — usually null — therefore only passing required params or where the default value needs to be overridden).

A more effective strategy for avoiding such situations is by utilizing inlay hints. Personally, I prefer to keep them enabled unless the types become excessively lengthy. In such cases, I opt to temporarily disable them. NB: While inlay hints can provide assistance, they are unable to completely circumvent the underlying problem.

1.3. Final thoughts on prop drilling

Prop drilling offers good guarantees and is a good starting point for an application. However, individually passing everything down the widget tree is not a practical solution in medium or large codebases.

2. InheritedWidget and Provider

In Flutter, you have surely seen the following pattern: MediaQuery.of(context), GoRouter.of(context). Basically, this code — through InheritedWidget — tries to infer the nearest instance of MediaQueryData or GoRouter that sits somewhere above in the widget tree.

The context inside the inline code blocks are instances of Flutter’s context class BuildContext. At runtime, each widget gets a unique context instance, which represents the position in the widget tree.

A commonly known pattern that relies entirely on InheritedWidget is the provider pattern, as in the Provider package. It provides a way to manage dependencies by creating and registering providers that hold the dependencies. Providers can be created at the top of some widget subtree and then accessed by widgets lower down in the subtree using context.read<Database>() or context.watch<Database>() (or using the Provider.of or Consumer widgets).

Example:

Provider\<Database\> example


Understanding provider illustrations

  • Colored nodes depict providers, which are special widgets that hold an observable, and widgets consuming them.

Inside the E’s build method we only have context.watch<Database>(). That means that types are needed for this pattern, therefore no instance is explicitly passed down to E from the previous node.

The InheritedWidget/Provider pattern is especially helpful in cases where we know there is only one instance of a certain type. For example:

class ExampleWidget extends StatelessWidget {
  const ExampleWidget({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return TextButton(
      child: Text('Go to Settings'),
      onPressed: () {
        GoRouter.of(context).goNamed('settings');
      },
    );
  }
}

This way we don’t have to manually pass a GoRouter as argument to all the widgets that need it.

Another good aspect of this pattern is that it leverages the widget tree: a new MediaQueryData instance will be created in case a widget subtree containing it is replaced (in conjunction with MediaQuery.of(context)).

Let us now focus on the pitfalls of the InheritedWidget/Provider approach.

2.1. Type unsafety

The single biggest drawback is type-unsafety. The InheritedWidget/Provider pattern acts a bit like a form of context-based service locator, since only types are used to inject dependencies.

Several conditions (all of them not predictable at compile time) can take place. Let us consider them separately.

2.1.1. Missing instance at runtime

There is no guarantee that, e.g., a GoRouter instance exists in the widget tree for a given BuildContext (i.e. no GoRouter instance found at runtime). In such scenario, a runtime error will be thrown. While this is going to be very unlikely in practice with GoRouter (since it is usually sits on top of the widget tree, and its instance is not supposed to be removed or changed), this pattern can actually become a problem with instances of other types.

Let’s imagine a developer thinking that the matching Provider<int> sits on top of the widget tree, as in the following figure.

Provider\<int\> example (developer’s mental picture)

The same, in code:


main() {
  runApp(MaterialApp(home: Scaffold(body: 
    // Wrapping node A
    Provider<int>(
      create: (context) => 42,
      child: NodeA(),
    )
  )));
}

// NodeA, NodeB, NodeC, NodeD classes here

class NodeE extends StatelessWidget{
  Widget build(BuildContext context) {
    final value = context.watch<int>();
    return Text(value.toString());
  }
}

A ProviderNotFoundException was thrown! Wait a minute, what happened? After inspecting the code, they realize that the provider was actually on node C, i.e.:

Provider\<int\> example (the actual, buggy code)

The same, in code:

main() {
  runApp(MaterialApp(home: Scaffold(body: 
    NodeA()
  )));
}

class NodeA extends StatelessWidget{
  Widget build(BuildContext context) {
    final b = NodeB();
    final providerOverC = Provider<int>(
      create: (context) => 42,
      child: NodeC(),
    );
    // TODO: return some widget wrapping b and providerOverC
  }
}

// NodeB, NodeC, NodeD classes here

class NodeE extends StatelessWidget{
  Widget build(BuildContext context) {
    final value = context.watch<int>();
    return Text(value.toString());
  }
}

The provider cannot be shared with node E, as it has to be lifted over node A to be sharable with E.

This example highlights the main reason why this pattern is considered type unsafe. There is also another scenario that needs to be acknowledged.

2.1.2. Multiple providers of the same type

If there are two providers of the same type, only the closest one found will be used. That implies that there is no way to use multiple providers of the same type.

For example, it is impossible to watch the int value of the first provider in the illustration below.

Example with 2 Provider\<int\>

A typical “solution” to this problem is to create wrapper classes, such as Age and Distance.

With a wrapper, it is now possible to watch the wrapped int value of the first provider (one could obviously replace Age with Distance for the other value).

Example with 2 Provider\<Wrapper\>

Subjectively speaking, however, creating a wrapper class once or twice may be acceptable, but doing so repeatedly may not feel like the best solution.

Some other state management solutions mitigate this problem with another approach, which is similar in principle: instead of using a wrapper class, an additional argument is provided (besides the type). In the next example, such an argument is of type String. NB: the Provider package does not support this syntax; the next example depicts how it would look like, if it were supported.

Example with 2 Provider\<Wrapper\>

Also in this case, type safety is not preserved. I don’t think that programming like this is very intuitive, especially if an app includes several providers of type int.

2.2. Final thoughts on InheritedWidget/Provider

The InheritedWidget/Provider pattern is an attempt to fix the prop drilling problem when the state of the app grows very high: code duplication and parameter misplacement are mitigated. This pattern leverages the underlying tree-based hierarchical structure at the cost of type safety.

The type unsafety associated to this pattern implies that a mental blueprint of the application structure needs to be constantly remembered to prevent unexpected runtime errors.

In reactive apps, InheritedWidget plays a crucial role, particularly when handling core functionality, as it empowers developers to leverage convenient syntax like MediaQuery.of(context);. However, state management solutions that entirely rely on this this pattern should be used carefully: if this becomes a one-size-fits-it-all solution, a lot of type safety will be lost. Personally, I cannot recommend this approach for situations where the value being consumed is of a common type such as String or int or some particular custom class used a lot in some code base, as the pattern will feel very error-prone and imprecise.

3. Provider injection: fallback provider and fallback value

The provider with fallback provider and the provider with fallback value are attempts to address the limitations of the provider pattern.

Provider injection with fallback provider

It works on the same principle as the provider pattern, but instead of throwing an error when it doesn’t find an instance, it generates a new provider containing an instance of the requested type.

This pattern is not supported by the Provider package. If it were supported, the syntax could be similar to the following Dart code:

class NodeA extends StatelessWidget{
  Widget build(BuildContext context) {
    final b = NodeB();
    final providerOverC = Provider<int>(
      create: (context) => 42,
      child: NodeC(),
    );
    // TODO: return some widget wrapping b and providerOverC
  }
}

class NodeB extends StatelessWidget {...}

// NodeC, NodeD classes here

class NodeE extends StatelessWidget{
  Widget build(BuildContext context) {
    final value = context.watch<int>(
      fallbackProvider: // lazily evaluated
        (context) => Provider<int>(create: (context) => 1000),
    );
    return Text(value.toString());
  }
}

The following images show the visualization of the app structure before and after building widget E.

  • Before building widget E:

    Example of provider with fallback - before

    NB: the color of the provider is the same, because it is initially thought that it is reachable.

  • After building widget E:

    Example of provider with fallback - after

    NB: the widget tree traversal resulted in no matching provider found, resulting in a new provider created dynamically. The provider in the unreachable subtree is thus colored differently.

This provider variant gives us the guarantee that we won’t run into runtime errors.

Provider injection with fallback value

It works on the same principle as the provider pattern, but instead of throwing an error when it doesn’t find an instance, it uses a fallback value (therefore, no provider is dynamically created in this case).

This pattern is also not supported by the Provider package. If it were supported, the syntax could be similar to the following Dart code:

class NodeA{
  Widget build(BuildContext context) {
    final b = NodeB();
    final providerOverC = Provider<int>(
      create: (context) => 42,
      child: NodeC(),
    );
    // TODO: return some widget wrapping b and providerOverC
  }
}

class NodeB extends StatelessWidget {...}

// NodeC, NodeD classes here

class NodeE extends StatelessWidget{
  Widget build(BuildContext context) {
    final value = context.watch<int>(
      fallbackValue: (context) => 1000, // lazily evaluated
    );
    return Text(value.toString());
  }
}

We have the guarantee that we won’t run into runtime errors only in node E. However, unlike the fallback provider variant, if a descendant of node E calls context.watch<int> without fallback value/provider (and no Provider<int> was added between node E and the descendant), an error can still be thrown at runtime.

3.1. Only one problem mitigated

These two alternative variants only address the first issue — missing instance at runtime — outlined in the previous section.

3.2. Unsynchronized providers/values

In most cases, there should only be one provider. For example, in the illustration depicting the provider injection with fallback provider, the two providers are supposed to be the same. However, multiple unsynchronized providers are created, adding complexity to the projects, especially in large codebases. The solution is to lift the provider wrapping C up so that it wraps A, as shown in the first figure displayed in the missing instance at runtime subsection, which is shown again below:

Provider placed at the most appropriate place)

A similar argument can be made for the fallback value alternative implementation, where instead of having a single source of truth, there are a lot of default values.

3.3. Final thoughts on provider injection: fallback provider and fallback value

While these alternative variants might appear to be an improvement over the typical provider pattern (and may have their utility in specific scenarios), I am not convinced that they are clean solutions. The typical provider pattern clearly has limitations, but I believe that it’s better to have runtime errors and patch them (by positioning providers at the most suitable locations), instead of preventing them with potentially unsynchronized and dynamically created providers or values.

4. Simple global state management solutions

A lot of state management tools rely solely on global state. With global state, data is cached at top level.

In this section, only global state that is not automatically disposed is considered. In the next section, global state management solutions providing local-state-like semantics are presented.

The following graph example depicts the widget tree structure of an app using the third-part Flutter library Riverpod, which relies solely on global state.

NB:

  • The providers in this section are different from the ones seen earlier in this article; these are Riverpod-specific providers, which are global state stores.
  • Instead of using Riverpod providers, the same could have been demonstrated, e.g., with ChangeNotifiers/ValueNotifiers defined globally.

Example with two Riverpod providers\<int\>


Understanding global state management illustrations

  • Red nodes represent either global state stores or widgets consuming global state.
  • Red arrows coming out of a global store indicate that it is being consumed (read, watched or listened) by the widget the arrow points to.

The code for the figure above might look like this:

// the two providers are defined top-level
final ageProvider = StateProvider((ref) => 18);
final distanceProvider = StateProvider((ref) => 1000);

class NodeC extends ConsumerWidget{
  // TODO: add fields and ctor...

  @override
  build(BuildContext context, WidgetRef ref) {
    final distance = ref.watch(distanceProvider);
    // TODO: return some widget using distance
  }
}

class NodeE extends ConsumerWidget{
  // TODO: add fields and ctor...

  @override
  build(BuildContext context, WidgetRef ref) {
    final age = ref.watch(ageProvider);
    final distance = ref.watch(distanceProvider);
    // TODO: return some widget using age and distance
  }
}

The Riverpod library includes ConsumerWidget, which functions similarly to StatelessWidget. Additionally, it provides a ref instance param to the build method, which is essential for consuming Riverpod providers (through ref.read, ref.watch or ref.listen).

The family keyword

Many times, simple Riverpod providers are not enough: an extra argument is needed. In Riverpod, the family keyword — which, to be precise, is a static method of the class Provider — is syntax sugar for managing a map of providers, which fulfills in these circumstances.

In the following case, if the passed id is bigger than 99, the notifier will have an initial value of 18, else 16.

final ageProvider = StateProvider.family((ref, int id) => id >= 100 ? 18 : 16);

class AgeConsumer extends ConsumerWidget {
  @override
  build(BuildContext context, WidgetRef ref) {
    final age = ref.watch(ageProvider(233)); // id is 233
    return Text(age.toString()); // will display 18
  }
}

Global state is known for having a bad reputation. Let us understand why in the following subsections.

4.1. Ignored widget tree structure

Global state management solutions have to be considered very carefully, as they inherently go against the widget tree structure and rules.

The stores are defined globally, thus breaking the widget tree structure.

Since relying on local scope is usually desired, storing app state mainly globally is not ideal for many reasons:

  • Only a subtree is actually supposed to interact with the state of global observables. However, a global observer can be accessed from anywhere, since it is global.
  • Eliminating or modifying global state may pose a greater challenge compared to local state. When it comes to local state, one can introduce a widget that encapsulates an existing widget subtree. This allows for the addition of a local scope at the most suitable layer (although certain refactoring changes may still be necessary).
  • Among others.

4.2. Incomplete solution

State management solutions entirely relying on simple global state cannot be completely leveraged throughout an app’s codebase, as they do not take local state into account.

Limited local state functionality is possible to achieve by creating multiple global stores, such as:

// defined top-level
final someFeatureProvider1 = StateProvider((ref) => 'Some feature (1)');
final someFeatureProvider2 = StateProvider((ref) => 'Some feature (2)');

This however involves writing more code, probably duplicated code if the functionality is supposed to be exactly the same one. Also, many manual calls will be needed to dispose the state in case of needed removal.

To circumvent this limitation, either a switch to local state is needed, or local-state-like support is required.

4.3. Final thoughts on global state management solutions

This is another attempt to fix the prop drilling problem: it is more convenient than prop drilling (since it totally avoids code duplication), making parameter misplacement much less likely. Unlike InheritedWidget/Provider, global state keeps type safety, however, it does not keep the underlying tree-based hierarchical structure and it does not work with local state.

My personal recommendation would be to try to avoid this pattern, unless it really feels like the right approach given the right circumstances.

5. Local-state-like solutions

Some advanced global state management solutions try to imitate local state functionality, e.g., Riverpod provider can be marked with autoDispose.

The providers of the previous graph can be easily adapted:

Example with local-state-like semantics\<Wrapper\>

The autoDispose keyword is actually a static method any of the Provider classes.

The code for the illustration above includes these essential parts:

// the two providers are defined top-level
final ageProvider = StateProvider.autoDispose((ref) => 18)
final distanceProvider = StateProvider.autoDispose((ref) => 100);

class NodeC extends ConsumerWidget{
  // TODO: add fields and ctor...

  @override
  build(BuildContext context, WidgetRef ref) {
    final distance = ref.watch(distanceProvider);
    // TODO: return some widget using distance
  }
}

class NodeE extends ConsumerWidget{
  // TODO: add fields and ctor...

  @override
  build(BuildContext context, WidgetRef ref) {
    final age = ref.watch(ageProvider);
    final distance = ref.watch(distanceProvider);
    // TODO: return some widget using age and distance
  }
}

As long as a global provided is being consumed (e.g. through ref.watch), its state will be kept. For example, if distanceProvider’s initial state is 20, and it gets manually changed in node C to the value 30, as soon as node C is disposed, the notifier inside distanceProvider is also disposed. Therefore, the next time it is listened to, the value held by its notifier will be 20.

However, if node E was also built at the time of of node C’s disposal (as in the figure above), distanceProvider would not be auto-disposed (keeping value 30). It will get disposed as soon as all listeners are removed.

Therefore, autoDispose enables local-state-like semantics, allowing for the simulation of singular subtree spawning, removal and respawning.

The family keyword

The usage of family-marked providers here differs from the analysis conducted in the previous section.

Sometimes, autoDispose is not enough. For example, both node C and node E might need a distanceProvider notifier that is not shared (as in the figure above). To achieve this, we need a list or map of providers. The family provider in riverpod allows us to conveniently use a map.

For example, in code:

// the two providers are defined top-level
final ageProvider = StateProvider.family<int>((ref) {
  return 18;
});
final distanceProvider = StateProvider.autoDispose.family<int, String>((ref, arg) {
  if (arg == 'a') {
    return 900;
  }
  return 1000;
});

class NodeC extends ConsumerWidget{
  // TODO: add fields and ctor...

  @override
  build(BuildContext context, WidgetRef ref) {
    final distance = ref.watch(distanceProvider('c'));
    // TODO: return some widget using distance
  }
}

class NodeE extends ConsumerWidget{
  // TODO: add fields and ctor...

  @override
  build(BuildContext context, WidgetRef ref) {
    final age = ref.watch(ageProvider);
    final distance = ref.watch(distanceProvider('e'));
    // TODO: return some widget using age and distance
  }
}

By providing one extra argument (through family), but without autoDispose, there will be two different notifiers behind the scenes, allowing for the simulation of multiple subtree spawning requiring the same state structure. Therefore, only using family does not achieve local-state-like semantics.

On the other hand, the combination of family and autoDispose allows for the simulation of multiple subtree spawning, removal and respawning. This duo is particularly intriguing, as it achieves complete local-state-like semantics, meaning that it is capable of replacing prop drilling and the provider pattern.

But at what cost?

5.1. Ignored widget tree structure

As it is possible to deduce from the illustrations and the accompanying code, the state management pattern discussed here disregards the structure of the widget tree. In this pattern, stores (i.e., Riverpod providers in our case) must be defined globally, which means the structure needs to be individually defined in each widget. In the case of Riverpod, this can be done conveniently within the build method. This approach has the advantage that global notifiers are instantiated and automatically disposed (eliminating the need to use initState and dispose widget lifecycle methods). It’s worth noting that using build instead of initState and dispose would yield similar results.

However, the downside of modeling the logic within the widgets themselves is that it requires more mental effort from the developer. To ensure that the state is correctly auto-disposed throughout the app, developers need to maintain a mental blueprint of the structure of all widgets. This additional cognitive load translates into complexity that may not be immediately apparent to developers using this pattern.

On the positive side, IDEs can be very helpful in debugging problems by providing features like the ability to select “Find all references” on a global store.

Now, let’s explore some examples to further illustrate these points.

Example involving two dependencies

Here’s an example that showcases two dependencies:

final accountProvider = StateProvider((ref) {
  // TODO: return an initial account
});
final monthEvents = StreamProvider.autoDispose.family((ref, Account _) { 
  // TODO: return an initial list of events, per account.
  //
  // NB: the passed account is ignored.
})

class AgendaWidget extends ConsumerWidget {
  build(BuildContext context, WidgetRef ref) {
    final account = ref.watch(accountProvider); // type is Account
    final monthEvents = ref.watch(monthEvents(account)); // type is List<Event>
    return Column(children([...(monthEvents.map(_createMonthWidgets))]));
  }

  Widget _createMonthWidgets(Event e) {
    // TODO: return some widget
  }
}

In practice, it’s common to require more than just two dependencies.

Example involving several dependencies

Let’s consider an example that involves a cascade of dependencies:

class GeneralizedExampleWidget extends ConsumerWidget {
  build(BuildContext context, WidgetRef ref) {
    final dependency1 = ref.watch(dependency1Provider);
    final dependency2 = ref.watch(dependency2Provider(dependency1));
    final dependency3 = ref.watch(dependency3Provider(dependency2));
    final dependency4 = ref.watch(dependency4Provider(dependency3));
    // TODO: return some widget using dependency4
  }
}

In the above example, the dependencies may appear “simple”. In practice, a high number of checks and comparisons (e.g. if statements) might have to be added to the code, making it much longer.

This programming approach can feel wrong or frustrating to work with since developers must ensure that all injected dependencies are not consumed by other unrelated widgets.

It’s worth noting that global state is not always used for every scenario. In the example, instead of having a cascade of dependencies, some of the Riverpod providers could be transformed into appropriate local state using ValueNotifier, for instance.

5.2. Global map of global stores: a potential antipattern

Using autoDispose with family and ignoring the passed argument to emulate local state can be considered an antipattern. In this approach, the family keyword loses its intended purpose as a map and instead becomes a workaround for managing state across multiple subtrees.

On the contrary, when modelling the code around prop drilling, the widget tree is fully leveraged. By creating proper locally-scoped state and using widget keys when necessary, developers can avoid reinventing the wheel and make the most of the inherent capabilities of the widget tree.

5.3. Limitation of single-argument semantics for local-state-like functionality

The Riverpod package has a limitation in its use of family, as it only supports a single argument at a time. In some cases, using a tuple class as a workaround can be sufficient. However, there are situations where relying on multiple intermediate providers is necessary (as shown in the example involving several dependencies), which can result in code that is initially challenging to understand.

Expanding the support for multiple provider parameters in the Riverpod library would introduce a higher risk of bugs, increased complexity, and consequently make the codebase immensely more challenging to maintain.

It is reasonable to speculate that other state management libraries that follow the local-state-like pattern may also encounter this limitation.

5.4. Final thoughts on local-state-like solutions

In contrast to simpler global state management solutions, solutions with local-state-like semantics offer a complete solution, enabling the construction of virtually any application. However, great caution must be exercised to assure the state is correctly auto-disposed where it needs to. This issue may arise when state fails to auto-dispose as intended, often due to unrelated widgets still observing the corresponding global store.

Local-state-like semantics is meant to make global state management solutions more pragmatic, so that — in theory — they can be used throughout an the code base of an application.

While, at first, local-state-like semantics could seem a neat solution, it comes with severe limitations:

  • The state is actually not local.
    • It is global, but will be auto disposed as soon as the auto-disposible global dependency is no longer watched/listened to.
  • As the requirements grow, complexity grows.
    • Need to mentally keep track of dependencies
  • The combination autoDispose + family allows for only one parameter.
    • This is more of a Riverpod implementation limitation, and other local-state-like global state management tools might support more than one param. However, other implementations probably have other limitations.
    • If multiple parameters cannot be achieved with tuple classes, limitations are met.
      • Nontrivial workarounds (that keep global state) to these limitations go against Flutter’s widget tree structure and rules.
        • Need intermediate providers
      • Instead of modelling everything through a global state management solution, code sections can be converted to local state.

It’s worth noting that while many apps utilize this approach successfully, it tends to be more suited for simpler scenarios, such as those involving a maximum of one user account being used concurrently.

For more complex scenarios, it requires significant engineering effort to ensure its effectiveness. From a professional standpoint, I discourage employing this pattern. The reason being that requirements can evolve unpredictably, and maintaining such a solution can quickly become burdensome and unwieldy.

Conclusion

The prop drilling, provider and local-state-like semantics approaches outlined in this article can serve as complete solutions to model the main application logic. However, their tradeoffs can make it hard to choose one pattern for the main logic.

In conclusion, I would like to propose an alternative, comprehensive approach that I have developed and documented in my article called Context drilling. By leveraging the strengths of existing patterns while mitigating their drawbacks, context drilling offers a fresh perspective and potential solution to the challenges faced, that has proven effective in my own experiences.