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
andcontext
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.
- Optionally including a reference to a context parent when defining the context class.
- 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:
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:
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 iso# : Obs(T)
(e.g.o1 : Obs(T)
), while the one forc#
isc# : T
. To avoid repetition, the type foro#
andc#
is always omitted in the following figures.- For example, only
o1
will be displayed instead ofo1 : Obs<T>
.
- For example, only
- The full type annotation for all
- 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, wheree1
,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 widgetA
, in the figures below.
- E.g.,
- 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
Since
F
only needs two properties, it might not be necessary to includeo4
incCtx
.Creating context classes preemptively
o4
is included incCtx
preemptively. This could be done because it is already known thato4
will be used byG
orH
, 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).
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)
orcontext.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. afeedCtx
needs to be inserted betweenimageCtx
androotCtx
), 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 ChangeNotifier
s or ValueNotifier
s, but also ones from 3-rd party libraries supporting local state, e.g., Solidart
Signal
s (or Resource
s, …).
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 ofRootCtx
) is swapped, then accessingimageResourceCtx.imageCtx.rootCtx
is unsafe.
- E.g.: if
- 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.
- 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 ofCtx
Ctx''
is the parent ofCtx'
- etc.
For example, when considering the context classes illustration:
Ctx''
would beRootCtx
Ctx'
would beImageCtx
Ctx
would beImageResourceCtx
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’sinitState
.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.