Understanding Flutter: State Management simplified

Niko
10 min readAug 2, 2023

--

This is the first of a row of articles that try to explain some of the flutter basics as simplified as possible. The next article after this one would be about how and when widgets will be rebuild.

State introduction

In flutter Widgets are the building blocks to describe the user interface of your app as a tree structure. The widget objects themselves are only short lived immutable descriptions which are used under the hood to create Elements which will then again be used to create RenderObjects and those are finally used to paint on the screen.
These articles only look at the widgets without explaining all of the details.

A Widget can be a single part of your UI like a button, but a single page of your app can also be a widget. And “State” can be described as some mutable data in memory that is needed to rebuild the UI at any moment. This can be seperated in local UI state (also called Ephermeral State) that is contained inside of a single widget and globally shared App State that is used by many widgets and other parts of your app.
So your different widgets of a page might need to communicate with each other, or you might also need to implement some sort of communication between different pages, or between the UI and your data from the backend.

Now in flutter there is the StatelessWidget which does not have a mutable state. Here all of its member variables are final and are initialized inside of the constructor. Now the state for these variables might live inside of a parent widget that is build higher above inside of the widget tree and if they change, then the child StatelessWidget is recreated with different parameter.

But there is also the StatefulWidget which has an associated State object with its Element to contain any local UI state. So here we can store and modify some internal data and rebuild the widget and it’s children with the changed data to update the UI.

Basic state passing

The following example should demonstrate the most basic way to pass state downwards from a parent to a child widget inside of your widget tree, which is simply using parameter inside of your widget constructors. You can directly copy the code into dartpad.dev and test it.

import 'package:flutter/material.dart';

void main() => runApp(App());

class App extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(home: Scaffold(body: buildExample()));
}
}

Widget buildExample() {
return const ParentWidget();
}

class ParentWidget extends StatefulWidget {
const ParentWidget({super.key});

@override
State<ParentWidget> createState() => _ParentWidgetState();
}

class _ParentWidgetState extends State<ParentWidget> {
late int data;

@override
void initState() {
super.initState();
data = 0;
}

void parentClick() {
setState(() {
data++;
});
}

void childClick() {
setState(() {
data = 0;
});
}

@override
Widget build(BuildContext context) {
return Column(
children: <Widget>[
ElevatedButton(
onPressed: parentClick,
child: const Text("parent increment state"),
),
ChildWidget(someData: data, onClick: childClick),
],
);
}
}

class ChildWidget extends StatelessWidget {
final int someData;
final VoidCallback onClick;

const ChildWidget({super.key, required this.someData, required this.onClick});

@override
Widget build(BuildContext context) {
return ElevatedButton(
onPressed: onClick,
child: Text("reset child $someData"),
);
}
}

Here you can see how the ParentWidget has a mutable state variable called data stored inside of the state object which can change.
The ChildWidget is now always created with the current data value by the parent, but it also receives a callback to call a the method childClick inside of the parent.

On pressing either button, the data will change and because of the setState method both build methods of the parent and child will be called again.
Of course during the parents build method a new ChildWidget object will be created, but this is fine performance wise, because the associated element will not be recreated in this case.

InheritedWidget

Now the example from above works great when a parent builds its own children, but what about a parent that receives a child widget as a parameter itself, so it can’t construct the child?
Or what if there are many nested widgets between your parent and child widgets which makes it clunky to add the same parameter to each layer inbetween?

Well that’s what the InheritedWidgets are for. Your widgets can depend on some data of an InheritedWidget, so they get added as a listener and will then be notified when the InheritedWidget changes, so that they rebuild with the new data.

The InheritedWidgets are typically accessed with a static method called of with the current BuildContext object which you might now from the Navigator, or Theme widgets. But of course the calling child widget must be build somewhere below the InheritedWidget, because otherwise the child can’t find it.

The following example shows how to make the state object of a widget build higher up the widget tree accessable to widgets below. But of course an InheritedWidget can also be used with standalone data similar to a StatelessWidget and rebuild its listeners, or dependants when it gets rebuild. For a short explanation look at the comments inside of the code.
In this example, the InheritedChildWidget can access the InheritedStateContainerState without any constructor parameter.

import 'package:flutter/material.dart';

void main() => runApp(App());

class App extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(home: Scaffold(body: buildExample()));
}
}

Widget buildExample() {
// here the child widget is build outside of the parent widget
return const InheritedStateContainerWidget(InheritedChildWidget("further down"));
}

// the widget that is used inside of your widget tree
class InheritedStateContainerWidget extends StatefulWidget {
// in the real world this widget would also only be a wrapper and has a child property for all widgets further down
final Widget child;

const InheritedStateContainerWidget(this.child);

static InheritedStateContainerState of(BuildContext context) {
return maybeOf(context)!; // this can fail if the calling widget is not build below the inherited widget
}

static InheritedStateContainerState? maybeOf(BuildContext context) {
return context.dependOnInheritedWidgetOfExactType<_InheritedWidgetWrapper>()?.state;
}

@override
InheritedStateContainerState createState() => InheritedStateContainerState();
}

/// the state object for the [InheritedStateContainerWidget]
class InheritedStateContainerState extends State<InheritedStateContainerWidget> {
// if you don't want children to be able to modify this without a call to "setState", make this private
int someData = 10;

// can be called from child and also rebuilds child
void incrementData() {
setState(() {
someData += 1;
});
}

@override
Widget build(BuildContext context) {
// wrap everything with the inherited widget so that all children can access the data/members of this state.
return _InheritedWidgetWrapper(
state: this,
child: Column(
children: <Widget>[
ElevatedButton(
onPressed: incrementData,
child: Text("Parent Increment $someData"),
),
widget.child,
],
),
);
}
}

/// used inside of the [InheritedStateContainerState] to provide it to children build higher above
class _InheritedWidgetWrapper extends InheritedWidget {
final InheritedStateContainerState state;

const _InheritedWidgetWrapper({required this.state, required super.child});

@override
bool updateShouldNotify(_InheritedWidgetWrapper old) {
// here this always returns true to always notify and rebuild all listeners/dependants when this inherited widget
// rebuilds (which happens on a "setState" call inside of the state object above). If you have a standalone
// inherited widget with some data that is received from the constructor, then you could compare the old to the
// new data here and only return true if the data has changed
return true; // returns if the listeners should be notified and rebuild
}
}

/// this is below the [InheritedStateContainerWidget] inside of the widget tree, but it is build higher above
class InheritedChildWidget extends StatelessWidget {
final String text;

const InheritedChildWidget(this.text);

@override
Widget build(BuildContext context) {
// access the parent state with the static method
final InheritedStateContainerState state = InheritedStateContainerWidget.of(context);
// now the data of the parent state can be changed (with an internal call to "setState"!) and this child will be
// rebuild if the data of the parent changes!
return ElevatedButton(
onPressed: () => state.incrementData(),
child: Text("$text Increment: ${state.someData}"),
);
}
}

The other way around

Now the previous example showed you how to access the state object of any parent widget inside of any child widget which is below the parent widget in the widget tree, but you can also access the state object of a child widget that is build inside of the parent widget.
For that you need to use a GlobalKey when creating the child widget and then simply use it to access the state of the child widget inside of the parent widget. The parent widget stores the key inside of its own state object and can access it after the build method has been called once (so not inside of initState).
The following example better illustrates this:

import 'package:flutter/material.dart';

void main() => runApp(App());

class App extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(home: Scaffold(body: buildExample()));
}
}

Widget buildExample() {
return const ParentWidget();
}

class ParentWidget extends StatefulWidget {
const ParentWidget({super.key});

@override
State<ParentWidget> createState() => ParentWidgetState();
}

class ParentWidgetState extends State<ParentWidget> {
final GlobalKey<ChildWidgetState> childGlobalKey = GlobalKey<ChildWidgetState>();

void parentClick() {
// important: of course the key can only be used as soon as the child widget is build,
// so this would be "null" inside of the "initState" method
final ChildWidgetState? childState = childGlobalKey.currentState;
childState?.increment(); // access child state
}

@override
Widget build(BuildContext context) {
return Column(
children: <Widget>[
ElevatedButton(
onPressed: parentClick,
child: const Text("parent increment"),
),
ChildWidget(key: childGlobalKey),
],
);
}
}

class ChildWidget extends StatefulWidget {
const ChildWidget({super.key});

@override
State<ChildWidget> createState() => ChildWidgetState();
}

class ChildWidgetState extends State<ChildWidget> {
int childData = 0;

void increment() {
// internal setState call to change the data
setState(() => childData++);
}

@override
Widget build(BuildContext context) {
return ElevatedButton(
onPressed: increment,
child: Text("increment child $childData"),
);
}
}

As you can see inside of the example, both buttons will update the data of the child and also rebuild the child.

State Management Solutions

There are many different packages available on pub.dev like provider, get_it, flutter_bloc and riverpod which all offer cleaner, simpler, or more advanced state management. The most lightweight approach would be to use provider which is similar to the InheritedWidgets and will be covered in the following example.

Provider

Now Provider simplifies the state management and acts as a wrapper around InheritedWidgets. The following example shows how state management with the provider package would look.

First you need a ChangeNotifier class to store your “state” data in which you want to share across multiple widgets. But you must call notifyListeners after any change to the internally stored data of the ChangeNotifier to rebuild the widgets that depend on the internal data of the ChangeNotifier.
A good practice is to use getters and setters with private member variables like the following, but you can also use public final immutable members:

class SomeModel with ChangeNotifier {
int _data;

SomeModel(int data) : _data = data;

void increment() {
_data += 1;
notifyListeners();
}

/// make sure that the public access without a call to notifyListeners can not modify the internal data to avoid
/// errors ( only final immutable member variables should be public )
int get data => _data;

set data(int data) {
_data = data;
notifyListeners(); // always notify listeners and rebuild them on changes
}
}

Of course you can also add async methods and you can also store references to more complex objects as member variables. But with those you have to be careful that the internal data of the references is not modified outside of the class without a call to “notifiyListeners”.
Now we have the data storage container, but how can widgets “depend” on this?

For this the model must be made available to all children widgets further down the tree inside of some parent widget high above. This is done with the ChangeNotifierProvider widget which can either create a new object itself, or use some cached state member variable.

class ParentWidget extends StatefulWidget {
const ParentWidget({super.key});

@override
State<ParentWidget> createState() => _ParentWidgetState();
}

class _ParentWidgetState extends State<ParentWidget> {
late final SomeModel savedInState;

@override
void initState() {
super.initState();
savedInState = SomeModel(100); // in this case the model is saved inside of the state object
// and it can be modified like any other member variable
}

@override
Widget build(BuildContext context) {
return Column(
children: <Widget>[
ChangeNotifierProvider<SomeModel>(
create: (BuildContext context) => SomeModel(0),
child: const ChildWidget(text: "with provider created model"),
),
ChangeNotifierProvider<SomeModel>.value(
value: savedInState,
child: const ChildWidget(text: "with saved in state model"),
),
],
);
}
}

Now when accessing the provided model, make sure that the child widget is below the parent widget in the widget tree and that you don’t stack multiple providers of the same model type.

Accessing the model inside of a child widget can be done with the Consumer widget which automatically creates a dependency and its builder method is rerun when the data of the model changes. But you could also use “Provider.of<SomeModel>(context, listen: true)” inside of your childrens build method to create a dependency on the model and rebuild the whole child when the data changes.
Important: inside of button callbacks where you don’t want to listen for data changes, but still want to modify the model, you have to use “listen: false” when using the “.of” method instead. It’s very important to remember these separate use cases with the different values for listen!

class ChildWidget extends StatelessWidget {
final String text;

const ChildWidget({super.key, required this.text});

@override
Widget build(BuildContext context) {
return Column(
children: <Widget>[
// you could also pass a child argument to the Consumer widget and use it inside of the builder
// method below for better performance (so that those widgets that don't depend on the model
// won't get rebuild on each change.
Consumer<SomeModel>(builder: (BuildContext context, SomeModel model, Widget? child) {
// this builder method is rerun everytime the models data changes!
// instead of using the consumer widget, you can also use the following inside of the build method:
// "Provider.of<SomeModel>(context, listen: true).data"
return Text("child $text with value: ${model.data}");
}),
ElevatedButton(
// the following could also be replaced with "context.read<SomeModel>().increment()"
onPressed: () => Provider.of<SomeModel>(context, listen: false).increment(),
child: const Text("increment"),
),
const SizedBox(height: 10),
],
);
}
}

Now if we combine these three code snippets, then we have a working example where the child widget and the parent widget can work with the same “state” inside of the model.

One important thing to note: if you want to modify a ChangeNotifier model during the build process (for example from inside of the “build ”method, or the “initState ”method), then you would have to wrap the modifications inside of a “WidgetsBinding.instance.addPostFrameCallback” so that they are executed at the end of the frame instead! Of course within the “initState” method, you should also use “listen: false”.

For performance reasons, you can also use a Selector instead of a Consumer widget which has an additional selector callback to only listen to a specific member of the ChangeNotifier model and rebuild if that changes. The builder callback now no longer receives the model and instead the compared data when it changes:

Selector<SomeModel, int>(
selector: (BuildContext context, SomeModel model) => model.data,
builder: (BuildContext context, int selectedData, Widget? child) {
return Text("Selected Data $selectedData");
},
);

When using mutable data members of a ChangeNotifier, you should create a deep copy of those inside of the selector callback, so that a rebuild will still be triggered when their internal data changes.
One last thing: Provider and Consumer will match the exact type, so you cant use the sub type inside of a Provider and then access the registered object with the super type inside of a Consumer. But of course you could always use the super type and create objects of the sub type and then cast them yourself.

Choosing the state management solution for your app

For more convenience, i would recommend using Riverpod, or BLoC and GetIt if you plan on building a larger-scale application. These will be explained in the following article. I myself like to use BLoC and GetIt for my apps, but there are also many more state management approaches like for example GetX, or flutter_redux, etc…

--

--

Niko
0 Followers

Hi, i'm a Full Stack Developer with a focus on Flutter. You can contact me at(there are 2 zeros in the middle): nikoo00o.mail@gmail.com