Context drilling

Context drilling

Context drilling is a simple, structural design pattern for managing state and data in reactive apps. It is a specific form of prop drilling, where props (usually observables) are grouped in special context objects and passed down the widget tree.

These context objects form parent-pointer trees, assuring type safety, leveraging the widget tree to maintaining local state, and simultaneously substantially reducing code duplication resulting from typical prop drilling.


Before diving into the core content, let us explore vital segments that lay the groundwork for understanding context drilling.

Origin

I developed this pattern due to my dissatisfaction with the current state management solutions (as of June, 2023). I recommend reading my dedicated article titled State management patterns and their potential drawbacks before proceeding, as it covers concepts that will be taken into account here.

While the simplicity of this pattern suggests that it may already be used in certain code bases, it is not yet widely recognized or documented; I could not locate any comprehensive guides discussing its implementation. Furthermore, existing reactive state management solutions appear to be primarily focused on providers or global-state stores when prop drilling becomes unwieldy. Thus, sacrificing either type safety or the widget tree structure. A tradeoff that is often not willingly made.

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.

Terminology

The word context can have different meanings in this article:

  • The concept specifically used in pattern.
    • Referred to just as context, throughout the text.
    • In code, identifiers ending with Ctx will be used to denote context classes and their instances.
  • The concept used within the Flutter framework.
    • Referred to as Flutter’s context, throughout the text.
    • In code, BuildContext and context will always refer to Flutter’s context class and instance identifiers, following the widely adopted convention.

1. How it works

Context drilling is implemented by:

  • Defining a context class and its members (fields, callbacks, …).
    • Optionally including a reference to a context parent when defining the context class.
      • The parent context instance has to sit higher in the widget tree.
      • By repeating this pattern, a bottom-up traversable chain of context objects is created.
      • From a top-down perspective, a parent-pointer tree of context objects is formed. This is more noticeable with context siblings.
  • Instantiating the context class in the initState of some widget, and passing the instance down the widget tree.

2. Illustrations

2.1. Context classes

To start simple, let us only consider context classes and their members for now. The figure below contains a context tree:

Simplified Context Drilling Example The next code block contains the code for the classes RootCtx, ImageCtx, and ImageResourceCtx; ReviewCtx and ReviewResourceCtx are written in a similar manner. The chosen observable class is ValueNotifier, but also a different class could have been chosen.


class RootCtx {
  final ValueNotifier<int> itemNr;
  final ValueNotifier<bool> connected;

  RootCtx(this.itemNr, this.connected);
}


class ImageCtx {
  final RootCtx rootCtx;
  final ValueNotifier<String> title;
  final ValueNotifier<String> subtitle;
  final ValueNotifier<String> imageUrl;

  ImageCtx(this.rootCtx, this.title, this.subtitle, this.imageUrl);
}

class ImageResourceCtx {
  final ImageCtx imageCtx;
  final ValueNotifier<String?> error;
  final ValueNotifier<double> controller;

  ImageResourceCtx(this.imageCtx, this.error, this.controller);
}

Traversable chains are available in order to use the needed props, e.g., imageResourceCtx.imageCtx.rootCtx.itemNr.

NB: A context interface or abstract class is not needed for this pattern to exist. In fact, none of the classes in the code block above include implements or extends in their signature. However, a context interface could be created and used for better IDE integration. As for now, let us reason without such interface.

More about the code for this pattern will be explained in the implementation and later sections.

2.2. Converting prop drilling to context drilling

For the rest of the article, the widget tree will be the underlying tree structure, for all illustrations.

Let us recall the prop drilling example from the other article:

Prop Drilling Example


Understanding the illustrations

When typical props are passed down:

  • All the nodes are stateful or stateless widgets.
  • The props contained in some node indicate that they are created in the node’s initState.
    • Thus, nodes with props must be stateful.
  • 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 always omitted in the following figures.
      • For example, only o1 will be displayed instead of o1 : Obs<T>.
  • Each arrow color in the graph represents a unique reference to an object.

In figures involving context drilling (below), these additional properties hold:

  • The notation Ctx {e1, e2, ...} represents the structure of some context class, where e1, e2, ... are the members of that class.
  • Each instance of these context classes is tight-coupled to the widget it is initialized in.
    • E.g., aCtx is tight-coupled to widget A, in the figures below.
  • If a context object is passed down, the arrow color no longer represents its reference, but it represents a context tree, which helps visualize all possible context chains.
    • In our examples, white will refer to the main context tree. In the example with multiple trees, red arrows constitute a different tree.

In the figure above, many props are passed down multiple times; to counter code duplication, let us group them into context objects. There are multiple ways to do this. Here are two possibilities:

  • Creating context classes only as it becomes really convenient

    Context Drilling Example

    Since F only needs two properties, it might not be necessary to include o4 in cCtx.

  • Creating context classes preemptively

    Context Drilling Example

    o4 is included in cCtx preemptively. This could be done because it is already known that o4 will be used by G or H, or one of their children in the future.

Depending on the exact requirements and future needs, one of the two solutions will feel more right, since there is not really a single, correct way to pick one. The code style varies based on the developer or on code style adherence.

2.3. Multiple context trees

While a single context tree could be utilized throughout the entire app, it would make more sense to leverage the local-scope aspect of the pattern and thus fork the context tree into multiple context trees, as development needs evolve. For examples, one main context tree could be used throughout code base of the reactive app and some specific context trees for special features (independent from the main context tree).

Multiple Context Trees Example

In the figure above, gCtx and lCtx compose the new context tree; the arrows passing them down are represented with another color.

In conclusion, as the number of props grows, it possible to visualize context drilling becoming more and more pragmatic.

3. Comparison with other patterns

Let’s delve into a comparison with the most common state management patterns, which are also outlined in my other article.

Typical prop drilling

It is worth reminding that context drilling emerges as a specific form of prop drilling, rendering this comparison the most relevant.

  • A lot of code duplication and possible parameter misplacement situations caused by typical prop drilling are not possible with context drilling.
  • Convenience-wise, context drilling is a colossal improvement over typical prop drilling, since the number of props decreases significantly.
  • With prop drilling, props are constantly reassigned when passed down, and at some layer they could be misplaced if some props share the same type.
    • With context drilling — on the other hand — the the observables, constants, etc. are assigned as few times as possible, strongly minimizing the number of potential parameter misplacements.

InheritedWidget and Provider

Comparison with prop drilling, in general:

  • With prop drilling, type safety is preserved, as opposed to the InheritedWidget and Provider approaches (e.g. ServiceClass.of(context) or context.watch<ServiceClass>()). Thus, when adopting context drilling:
    • Multiple observables of the same type are possible.
    • The absence of a needed instance of a certain type will result in a syntax error.
      • Can be mitigated through fallback provider or fallback value, albeit with associated tradeoffs to consider.
    • InheritedWidget/Provider might need tests to assure type-safety-related runtime errors are prevented. Such tests are not needed with context drilling.

Comparison specifically with context drilling:

  • Context drilling and InheritedWidget/Provider share a similarity: the observables are both instantiated only once in the widget tree, at the most appropriate place.
    • The same context classes may be instantiated multiple times, though. This is however not a concern, since the context classes should be tight-coupled to a specific widget. Thus, making parameter misplacements much harder to reproduce.
  • Implementing context classes, instantiating them and passing them down is a bit more work than just having to insert a Provider widget and injecting its instance by specifying only its type, but it necessary for assuring type safety.

Global state management solutions with local-state-like semantics

Comparison with prop drilling, in general:

  • With prop drilling, the state is really locally scoped, requiring and leveraging the widget tree as opposed to global state management solutions with an approach that resembles local-state (e.g. Riverpod providers marked with autoDispose).
    • Due to lack of true local scope, global solutions can hit some limitations.
  • When modelling state using global stores, the structural logic must be written inside the each individual widget.

Comparison specifically with context drilling:

  • Managing a lot of state through context drilling is almost as convenient as with global state.
    • Implementing context classes, instantiating them and passing them down is not as straightforward as just having to define and access them globally. However, this step guarantees that the state is scoped locally.

    • With global state, code refactoring can be carried out without inadvertently breaking functionality, albeit with associated tradeoffs to consider. This is a consequence of modelling state logic at the widget level instead of at the widget tree level.

      On the contrary, with context drilling, accessing an observable might require having to traverse a context chian, e.g.: imageResourceCtx.imageCtx.rootCtx.itemNr. This code can potentially break if changes to the widget tree are made (e.g. a feedCtx needs to be inserted between imageCtx and rootCtx), and, if that is the case, it will result in syntax error.

      It should also be noted that:

      • IDE autocompletion can help mitigate this in the future (more detail in a later section).
      • Traversing the context chain is not inherently a bad thing, as it gives more structure to the code.

3.1. Summary

Context drilling is an endeavor to preserve the advantages of prop drilling while circumventing its limitations. It aims to make reactive programming simple and pragmatic, ensuring that providers and globals take a secondary role. The guarantees of this pattern are especially prominent in projects characterized by significant scale and/or high complexity, where they have the most profound impact.

3.2. Not a one-size-fits-it-all approach

The pattern described in this article is not tailored for every circumstance.

While it is generally advisable to steer clear of local-state-like semantics, it is important to recognize that the other approaches have their own merits and applications.

In some cases, simple globals might be preferable. There are scenarios where employing the InheritedWidget/Provider pattern might be more pragmatic (for example, GoRouter.of(context) is more pragmatic than calling settingsCtx.baseCtx.routerCtx.goRouterInstance; the GoRouter instance will be pretty much at the root of the widget tree, making the type safety concern not much of an issue in this case anyway). When working on a small library that only uses local state, context drilling might be a bit excessive. These are just a few examples illustrating the varying considerations to be taken into account.

4. Implementation

In this section, we will consider some important implementation aspects.

4.1. Context class creation

In the most usual case, one wants a single context instance associated to a specific stateful widget instance. In more complex cases, one might even need a list/map/set of context instances, which is also associated to a specific stateful widget instance.

A simple way to keep track of this association is to define your context class between the stateful widget’s main class and its state class.

Since traversing chains of context instances is fundamental in this pattern, keeping their names short is recommended to improve readability. This is especially true for long chains.

For example:

class SettingsPage extends StatefulWidget {
  const SettingsPage({Key? key, required this.accountCtx}) : super(key: key);

  @override
  State<SettingsPage> createState() => _SettingsPageState();
}

// Context class defined between SettingsPage and _SettingsPageState
class SettingsCtx {
  // still empty at the moment
}

class _SettingsPageState extends State<SettingsPage> {
  final SettingsCtx settingsCtx = SettingsCtx();
  // instantiating settingsCtx here is equivalent to doing it in this class' initState
  // (just more type-safe, since we don't need to access `widget`)

  @override
  Widget build(BuildContext context) {
    return
      // ...
      // ... (Omitted code for brevity)
      // ...
      TextButton( // this TextButton is present somewhere in the return statement
        child: Text('toggle theme'),
        onPressed: () {},
      )
      // ...
      // ... (Omitted code for brevity)
      // ...
  }
}

We are now ready to insert members in a context class.

4.2. Context members

The next procedures need to be followed thoroughly to ensure correct functionality.

4.2.1. Prefer observables over typical data variables

When defining context classes, avoid create mutable fields such as:

class SomeCtx {
  int age;
  int distance;

  SomeCtx(this.age, this.distance);
}

The reason is that the SomeCtx instance will not be replaced in case age or distance changes. If you were to make these context classes immutable, then you would need many callback functions, probably also rely a lot on initState, implement didUpdateWidget and other life-cycle methods for some widgets.

It is much simpler to wrap your data in observables (which handle a lot of the logic for you) and solely react to them in the build method of your widgets. The observables can be simple ChangeNotifiers or ValueNotifiers, but also ones from 3-rd party libraries supporting local state, e.g., Solidart Signals (or Resources, …).

If done with caution, normal variables can also be fields of a context class (instead of observables), as long as they are not directly used for triggering rebuilds in some widget. For example, they can be used for in-context caching.

4.2.2. Constants

If you have constants, you should not wrap them in observables, since that would result in unnecessary overhead and it would be a bit confusing.

4.2.3. Methods

Methods can also be fields of context classes. This is especially useful for actions that need coordination among multiple observables.

4.3. Mark fields as final

Immutability of some components assures strong guarantees, therefore:

  • Always mark the context object as final in the associated widget’s state class.
    • If an instance of some context class is swapped at runtime, unpredictable behaviour might occur, since the reference inside context chains will still point to the previous instance.
      • E.g.: if imageResourceCtx.imageCtx.rootCtx (an instance of RootCtx) is swapped, then accessing imageResourceCtx.imageCtx.rootCtx is unsafe.
  • Always mark both observables and instance constants with the final keyword.

Example:

class SomeCtx {
  // mutable values ought to be wrapped in observables
  // notice final keyword
  final ValueNotifier<int> ageNotifier;
  final ValueNotifier<int> distanceNotifier;

  // a constant
  // notice final keyword
  final int alexanderTheGreatsBirthYear = -356;

  SomeCtx(int age, int distance)
      : ageNotifier = ValueNotifier(age),
        distanceNotifier = ValueNotifier(distance);
}

class SomeWidget extends StatefulWidget { 
  const SomeWidget({Key? key}) : super(key: key);

  @override
  State<SomeWidget> createState() => _SomeWidgetState();

 }

class _SomeWidgetState extends State<SomeWidget> {
  // notice final keyword
  final SomeCtx someCtx = SomeCtx(9, 10);
  
  @override
  Widget build(BuildContext context) {
    // TODO: return a widget using someCtx
  }
}

For the readers that don’t know what final does: it is used to define immutable constants or objects, at runtime.

4.4. Dispose the notifiers

Remember to dispose the notifiers in the stateful widget’s dispose method.

class SettingsPage extends StatefulWidget {
  const SettingsPage({Key? key, required this.accountCtx}) : super(key: key);

  @override
  State<SettingsPage> createState() => _SettingsPageState();
}

// Between SettingsPage and _SettingsPageState
class SettingsCtx {
  final ValueNotifier<int> lastSelected = ValueNotifier(0);
}

class _SettingsPageState extends State<SettingsPage> {
  final SettingsCtx settingsCtx = SettingsCtx();

  @override
  void dispose() {
    settingsCtx.lastSelected.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return
      // ...
      // ... (Omitted code for brevity)
      // ...
      TextButton(
        child: Text('toggle theme'),
        onPressed: () {},
      )
      // ...
      // ... (Omitted code for brevity)
      // ...
  }
}

By adopting this approach, potential memory leaks are effectively prevented.

5. Potential IDE Integration

Context drilling has its drawbacks, which IDE integration could help mitigate in the future.

All IDE integration features proposed below require to be possible to identify if one of the members of a context class is a parent context. This could be possible through creation of a context interface; every context class should implement a specific interface CtxInterface (or just Ctx); this way, if one of the fields of the context class also implements this interface, the IDE will be able to detect it, making the following features possible.

5.1. Lifting Props Up Through Refactoring Actions

Sometimes we realize we need to lift props up. In the case of context drilling, that means lifting a member of some context class to some ancestor context class — which is sitting somewhere above in the widget tree.

Refactoring actions could be added to easily accomplish the described task. For example, in the first illustration, we could lift ImageCtx’s title field up to RootCtx (in case this action needs to be undone, the key combination CTRL-Z shall be pressed).

After lifting the member up, all affected code parts should be automatically refactored. By sticking to the example in the paragraph above, all [*.]imageCtx.title parts should be updated to [*.]imageCtx.rootCtx.title — where [*.] represents chains of other context class instances that can reach imageCtx, if they exist.

Since we know which fields of a context class also extend the context interface, options to lift some prop higher than by a single layer could be offered. For example, some field x of some class Ctx could be lifted up directly also to Ctx'', Ctx''', Ctx'''', etc.


Understanding the Ctx, Ctx’, Ctx’’, etc. syntax

The syntax Ctx, Ctx', Ctx'', etc. does not refer to particular context classes, but it is used for describing context hierarchy:

  • Ctx' is the parent of Ctx
  • Ctx'' is the parent of Ctx'
  • etc.

For example, when considering the context classes illustration:

  • Ctx'' would be RootCtx
  • Ctx' would be ImageCtx
  • Ctx would be ImageResourceCtx

5.2. Autocomplete Support

Autocomplete support could make this pattern simpler to use, as it would remove the need to manually traverse context chains.

When a prop belonging to some reachable context instance needs to be used, it should be possible to start typing the name of the prop, and the IDE should display the name.

For example, if schoo is typed, then the following suggestion could be displayed:

  • settingsCtx.mainCtx.schoolGrades

    If inside a stateless widget or if ctxSettings was instantiated in the current widget’s initState.

  • widget.settingCtx.mainCtx.schoolGrades

    If inside a stateful widget and ctxSettings was passed down to it.

This can be achieved efficiently by having both SettingsCtx and MainCtx implement a context interface, so that only fields that also implement the same interface are recursively checked for.

5.2.1. Getter Autocomplete Support

Special autocomplete support could be added to avoid having to manually type the getter method signature of a notifier, a constant, or some other kind of member of a reachable context class.

For example, when typing dar:

class SettingsPage extends StatefulWidget {
  const SettingsPage({Key? key, required this.accountCtx}) : super(key: key);

  final AccountCtx accountCtx;

  @override
  State<SettingsPage> createState() => _SettingsPageState();
}

class _SettingsPageState extends State<SettingsPage> {
  dar... // autocomplete shows darkThemeNotifier

  @override
  Widget build(BuildContext context) {
    return
      // ...
      // ... (Omitted code for brevity)
      // ...
      TextButton(
        child: Text('toggle theme'),
      )
      // ...
      // ... (Omitted code for brevity)
      // ...
  }
} 

then the whole notifier/listenable can be autocompleted for you, including its method signature, e.g., ValueNotifier<bool> get darkThemeNotifier =>, as in the next code block:

class SettingsPage extends StatefulWidget {
  const SettingsPage({Key? key, required this.accountCtx}) : super(key: key);

  final AccountCtx accountCtx;

  @override
  State<SettingsPage> createState() => _SettingsPageState();
}

class _SettingsPageState extends State<SettingsPage> {
  // the whole getter is autocompleted at this point
  ValueNotifier<bool> get darkThemeNotifier => widget.accountCtx.mainCtx.darkThemeNotifier;

  @override
  Widget build(BuildContext context) {
    return
      // ...
      // ... (Omitted code for brevity)
      // ...
      TextButton(
        child: Text('toggle theme'),
        // now we can use darkThemeNotifier
        onPressed: () => darkThemeNotifier.value = !darkThemeNotifier.value,
      )
      // ...
      // ... (Omitted code for brevity)
      // ...
  }
} 

This example demonstrates autocomplete usage with an instance of ValueNotifier, but the integration can be made highly adaptable, allowing the construction of getters for any type, enabling seamless treatment of notifiers/listenables from third-party state management libraries as first-class citizens.

6. Testing

Context drilling is testable.

It is possible to create mock objects of any context class and set only the necessary properties. With Mockito, this is done as follows:

import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';

@GenerateMocks([ImageCtx])
void main() {
  test('makeTitle', () {
    // Create a mock instance
    var mockImageCtx = MockImageCtx();

    // Set the required property
    when(mockImageCtx.title).thenReturn(ValueNotifier('coBra'));

    // Call the function with the mock object
    final result = makeTitle(mockImageCtx);

    // Verify the result
    expect(result, 'Cobra');
  });
}

where makeTitle is defined as follows:

String makeTitle(ImageCtx ic) {
  if (ic.title.value.isEmpty) {
    return ic.title.value;
  }

  final firstLetter = ic.title.value.substring(0, 1).toUpperCase();
  final restOfWord = ic.title.value.substring(1).toLowerCase();

  return '$firstLetter$restOfWord';
}

Given that this pattern heavily relies on object composition, there is a high possibility for a context object of a specific class to depend on many other context objects.

A simple example on how to mock nested context classes:

@GenerateMocks([ImageCtx, ImageResourceCtx])
void main() {
  test('makeTitleFromResource', () {
    // Create a mock instance
    final mockImageCtx = MockImageCtx();

    // Set the required property
    when(mockImageCtx.title).thenReturn(ValueNotifier('coBra'));

    // Create a mock instance
    final mockImageResourceCtx = MockImageResourceCtx();

    // Set the required property
    when(mockImageResourceCtx.imageCtx).thenReturn(mockImageCtx);

    // Call the function with the mock object
    final result = makeTitleFromResource(mockImageResourceCtx);

    // Verify the result
    expect(result, 'Cobra');
  });
}

where

String makeTitleFromResource(ImageResourceCtx irc) {
  if (irc.imageCtx.title.value.isEmpty) {
    return irc.imageCtx.title.value;
  }

  final firstLetter = irc.imageCtx.title.value.substring(0, 1).toUpperCase();
  final restOfWord = irc.imageCtx.title.value.substring(1).toLowerCase();

  return '$firstLetter$restOfWord';
}

A similar approach can be used for widget testing.

7. Compatible state management libraries

In Flutter, third-party local state management libraries can provide a wider range of features compared to ChangeNotifier/ValueNotifier.

Libraries that are compatible with this pattern need to support local state that can be passed down explicitly. Below are listed the packages present on Flutter’s state management approaches webpage that are can work in combination with context drilling:


Conclusion

Context drilling is founded on the concept of parent-pointer tree through object composition. It does not try to replace prop drilling like other approaches do; instead, it just groups props before passing them down, leveraging the widget tree structure and maintaining type safety. Grouping props into context objects leads to much less duplicated code, reducing the array of issues and problems that come with it, such as high cognitive load and parameter misplacement. Unlike provider and local-state-like solutions, this pattern does not require a mental blueprint of the app structure. Additionally, it offers the benefit of code generation-free implementation.

Context drilling’s powerful guarantees are particularly attractive for large and/or complex projects.

Addendum

Thank you for investing your time in reading my article. I hope you found the content enlightening and that it has introduced you to a new design pattern that you can apply in your projects.

If you find this design pattern valuable and feel inspired to create a tutorial, write an article, or even develop an IDE extension based on the ideas presented here, I would be grateful if you could kindly acknowledge the original article I authored. Giving credit to the source not only honors the work put into researching and documenting this pattern but also encourages the continued sharing of knowledge within the community.

Once again, I appreciate your attention and I look forward to seeing how this design pattern unfolds in your own endeavors.