Working with Context in Flutter & React
In this article, we'll explore the way we can implement theming by using Context. We'll look at how React and Flutter allow us to achieve virtually the same functionality.
Check the final version out really quick
We'll build an app that can switch the Theme from Light to Dark when pressing a button. The Theme will pass down its values thru Context to any interested component.
1. Working with Context in React
Let's jump right in and see how we would use Context to implement a simple theme switcher in React and then switch to Flutter and implement the same thing, step by step.
1.1 Defining the Content in React
Before we share something thru Context, we need to define it. With typescript, we use an Interface and two plain objects that respect the interface to define the two variants of the theme: light and dark.
// Typescript data model
interface AppThemeValues {
primaryColor: string;
primaryBackgroundColor: string;
}
const ligthTheme: AppThemeValues = {
primaryColor: "black",
primaryBackgroundColor: "white"
};
const darkTheme: AppThemeValues = {
primaryColor: "white",
primaryBackgroundColor: "black"
};
1.2 Defining a Context Type in React
Next, we create an object that holds the information needed by React to identify this context type. The API for it requires a default value at initialization, so we have to pass one of the themes there.
// React context type definition
const AppThemeContext = createContext<AppThemeValues>(ligthTheme);
1.3 Providing a Value for our Context in React
The Context Type object we created previously exposes a Provider
component that will hold a value of type AppThemeValue
. We pass in a value to the Provider from a local state variable. We change the value of that variable when clicking a button.
// The theme switcher component
export default function App() {
const [theme, setTheme] = useState(ligthTheme);
return (
<AppThemeContext.Provider value={theme}>
<div className="App">
<PageHeading>Theme switcher</PageHeading>
<PrimaryButton
onClick={() =>
setTheme((t) => (t === ligthTheme ? darkTheme : ligthTheme))
}
>
Change Theme
</PrimaryButton>
<style>{`
body {
background-color: ${theme.primaryBackgroundColor};
}
`}</style>
</div>
</AppThemeContext.Provider>
);
}
1.4 Accessing the Context Value in React
We import the Context Type and use the useContext
hook. Alternatively, we can access the Value component from the Context Type object the same way we did with the Provider. There's a third way, but that only works for React Class components, and we don't explore that approach here. A minimal Component that uses Context would be the heading we used in the Main Component.
// A hreading that uses the theme context
const PageHeading: FC = (props) => {
const themeContext = useContext(AppThemeContext);
return <h1 style={{ color: themeContext.primaryColor }}>{props.children}</h1>;
};
1.5 Changing the Context Value in React
A simple local state is enough to showcase how Context changes propagate into the dependent widgets. As stated above, a simple local state change is enough to trigger the change. This step will be a bit more elaborated in the Flutter implementation.
We can check the final implementation here: https://codesandbox.io/s/react-them-switcher-woq1v?file=/src/App.tsx:930-1145
2. The Flutter Way:
2.1 Defining the Content in Flutter
We are working with classes only. Dart is more OOP than the current trend in React development. Because of this, we have to be explicit when we create the theme properties object.
We first define the two Theme objects, the light and dark theme. Both will be of type AppThemeValues
.
/// Dart data model
class AppThemeValues {
final Color primaryColor;
final Color primaryBackgroundColor;
AppThemeValues(
{required this.primaryColor, required this.primaryBackgroundColor});
}
AppThemeValues ligthTheme = AppThemeValues(
primaryColor: Color.fromARGB(255, 0, 0, 0),
primaryBackgroundColor: Color.fromARGB(255, 255, 255, 255),
);
AppThemeValues darkTheme = AppThemeValues(
primaryColor: Color.fromARGB(255, 255, 255, 255),
primaryBackgroundColor: Color.fromARGB(255, 0, 0, 0),
);
2.2 Defining a Context Type in Flutter
Flutter allows us to define specialized widgets (components) in the hierarchy that have only one purpose. To attach their data to the tree and have it accessible to the descendants via a simple API. These specialized widgets are called InheritedWidgets
. As the name suggests, other widgets are supposed to inherit them somewhere deeper in the widget hierarchy.
We are essentially defining just the Provider equivalent from React. We will use this class to ask for the Context value in the future.
/// Flutter context type definition
class AppTheme extends InheritedWidget {
final AppThemeValues values;
AppTheme(
{required Widget child,
required this.values})
: super(child: child);
bool updateShouldNotify(covariant InheritedWidget oldWidget) {
return true;
}
}
Notice how we are not required to define any initial default values for the InheritedWidget
. I encountered cases where I had to create default context values to React to avoid typescript errors in the past. It was not great. With Flutter, I don't have to worry about default values, as the system will fail if I try to read from Context something that does not exist, and I cannot create a Context that has missing or invalid values.
The updateShouldNotify
function is very similar to the old shouldComponentUpdate
method from React. It gives us the chance to decide if it is time to rebuild the widgets that depend on this Context value.
updateShouldNotify(covariant AppTheme oldWidget) {
return oldWidget.value.primaryColor != value.primaryColor;
}
bool
In the example above, we are only rebuilding if the primary colour is different. It's not a very practical example, but it should get the point across.
2.3 Providing a Value for our Context in Flutter
We have almost the same approach as we did with React, but instead of having JSX tags, we have to use simple class instantiation.
class AppThemeSwitcher extends StatelessWidget {
Widget build(BuildContext context) {
return AppTheme(
value: ligthTheme,
child: Column(
children: [
PrimaryHeading(
title: "Theme Switcher",
),
// we'll add the button and the state management later
],
),
);
}
}
Notice that we did not implement the state change yet. We'll get to it later.
2.4 Accessing the context value in Flutter
We follow a similar path to what we did in React. We need to have access to the type of Context we
want. The context type is an InheritedWidget
. We access the first parent that is of that type of Widget with the following code:
/// Flutter heading component
class PrimaryHeading extends StatelessWidget {
final String title;
const PrimaryHeading({Key? key, required this.title}) : super(key: key);
Widget build(BuildContext context) {
var appTheme = context.dependOnInheritedWidgetOfExactType<AppTheme>();
if ( appTheme == null ) {
/// This happens when we did not provide a AppTheme ancestor.
/// Usually this is a good place to throw an Exception if we consider
/// this widget a dependent of the AppTheme.
}
return Text(
title,
style: TextStyle(color: appTheme.value.primaryColor),
);
}
}
If we were to compare context.dependOnInheritedWidgetOfExactType
to something found in the web world, it would be the DOM node closest
(https://developer.mozilla.org/en-US/docs/Web/API/Element/closest) method. Instead of sending a CSS selector to the method, we are sending an InheritedWidget
type. Just like the DOM API, we might get a null value, or we might get a matched target.
There's a common best practice in Flutter to reduce boilerplate for accessing context values. The framework itself and many plugins in the ecosystem implement a helper function, ContextWidget.of
, which calls to get the InheritedWidget
instance and throw an exception if it is null.
/// Flutter context type updated
class AppTheme extends InheritedWidget {
final AppThemeValues values;
AppTheme(
{required Widget child,
required this.values})
: super(child: child);
bool updateShouldNotify(covariant InheritedWidget oldWidget) {
return true;
}
static AppTheme of(BuildContext context) {
final appTheme =
context.dependOnInheritedWidgetOfExactType<AppTheme>();
assert(appTheme != null, 'No AppTheme found in context');
return appTheme!;
}
}
With this change, we can use AppTheme.of(context)
to get the theme. It looks much cleaner this way.
We might go even further and return just the value
from the AppTheme.of(context)
if we wanted to, but by doing this, we break the standard that so many other Context-based widgets work in Flutter.
/// possible alternative, but maybe breaking the best practice
static AppThemeValue of(BuildContext context) {
final appTheme =
context.dependOnInheritedWidgetOfExactType<AppTheme>();
assert(appTheme != null, 'No AppTheme found in context');
return appTheme!.value;
}
Now, accesing the Value of the Context looks much more clean in the Header Widget:
class PrimaryHeading extends StatelessWidget {
final String title;
const PrimaryHeading({Key? key, required this.title}) : super(key: key);
Widget build(BuildContext context) {
var appTheme = AppTheme.of(context);
return Text(
title,
style: TextStyle(color: appTheme.value.primaryColor),
);
}
}
If you followed along so far, you now know how many of the app-wide functionalities of Flutter are implemented. Some of them are Theme.of(context)
for Material and Cupertino themes, Navigator.of(context)
for switching screens/pages and changing the URL when running your app on the web. But we'll explore those in future articles.
2.5 Changing the Context Value in Flutter
Any UI change in a React or Flutter happens by changing a state value somewhere in the components hierarchy.
We won't get into the details of how the state works in Flutter; there will be another article about that soon. It would be nice, though, to see how the Context System rerenders content in our mini-app. For this article, let's think of Flutter state as a React class state. You know, the one where we setState({...values})
. The API is very similar.
We must create a Flutter widget that supports state changes. Flutter widgets, by default, don't have state capability. Flutter components that support state changes and rebuild content are called StatefulWidgets.
class AppThemeSwitcher extends StatefulWidget {
final Widget child;
const AppThemeSwitcher({Key? key, required this.child}) : super(key: key);
_AppThemeSwitcherState createState() => _AppThemeSwitcherState();
}
class _AppThemeSwitcherState extends State<AppThemeSwitcher> {
AppThemeValues currentTheme = ligthTheme;
Widget build(BuildContext context) {
return AppTheme(
value: currentTheme,
child: Column(
children: [
PrimaryHeading(
title: "Theme Switcher",
),
TextButton(
onPressed: () {
setState(() {
currentTheme =
currentTheme == ligthTheme ? darkTheme : ligthTheme;
});
},
/// Improvided button, should be extracted to a
/// component just like PrimaryHeading
child: Container(
color: currentTheme.primaryColor,
child: Text(
"Change Theme",
style:
TextStyle(color: currentTheme.primaryBackgroundColor),
),
))
],
),
);
}
}
We can check the Flutter app in action here: https://dartpad.dev/8d7f378317bd9318ad130dd747b8b305?null_safety=true
4. Conclusions
We've seen how Flutter defines and exposes Context data in the widget tree, how descendent widgets access that data and how changing the InheritedWidget
is the equivalent of changing the Provider
value in React.
I hope you found this helpful, and if you'd like to learn more about Flutter through the lens of a React Developer, consider following me on Twitter at @agilius.