Basic State Management in Flutter

As apps grow, managing the flow of data through the app becomes a more complex and important issue. The Flutter community has struggled with this problem. Due to this, they have devised several solutions to deal with state management. All these solutions have one aspect they share: the separation of model and view.

Before diving into any state management solutions (BLoC, MVVM, Redux, and so on), we will explore the elements that they all share. Each of these patterns divides apps into layers. These are groups of classes that perform specific kinds of tasks. Layer strategies can be applied to almost any app architecture. Once you’ve mastered the basics, learning any of the more advanced patterns will be easy.

In this tutorial, you are going to build a to-do note-taking application. In this application, users will be able to create to-do lists that contain many tasks. Users will be able to add, edit, delete and complete their tasks. 

We will cover the following recipes:

  • Model-view separation
  • Managing the data layer with InheritedWidget
  • Making the app state visible across multiple screens
  • Designing an n-tier architecture, part 1 – controllers
  • Designing an n-tier architecture, part 2 – repositories
  • Designing an n-tier architecture, part 3 – services

Technical requirements

Start off by creating a brand new Flutter project called master_plan in your favorite IDE. Once Flutter has generated the project, delete everything in the lib and test folders.

Model-view separation

Models and views are very important concepts in app architecture. Models are classes that deal with the data for an app, while views are classes that present that data on screen. 

In Flutter, the closest analogy we have to views are widgets. Subsequently, a model would be a basic dart class that doesn’t inherit from anything in the Flutter framework. Each one of these classes is responsible for one and only one job. Models are concerned with maintaining the data for your app. Views are concerned with drawing your interface. When you keep a clear and strict separation between your models and views, your code will become simpler and easier to work with.

In this recipe, we’re going to build the start of our Todo app and create a model graph to go along with our views.

Getting ready

Any self-respecting app architecture must ensure it has the right folder structure set up and ready to go. Inside the lib folder, create both a models and a views subfolder. 

Once you have created these two directories, you should see them in the lib folder, as shown in the following screenshot: 

You are now ready to start working on the project.

How to do it…

To implement separation of concerns for views and models, follow these steps: 

  1. The best place to start is the data layer. This will give you a clear view of your app, without going into the details of your user interface. In the models folder, create a file called task.dart and create the Task class. This should have a description string and a complete Boolean, as well as a constructor. This class will hold the task data for our app. Add the following code:
class Task {
String description;
bool complete;

Task({
this.complete = false,
this.description = '',
});
}
  1. We also need a plan that will hold all our tasks. In the models folder, create plan.dart and insert this simple class:
import './task.dart';

class Plan {
String name = '';
final List<Task> tasks = [];
}
  1. We can wrap up our data layer by adding a file that will export both models. That way, our imports will not get too bloated as the app grows. Create a file called data_layer.dart in the models folder. This will only contain export statements, no actual code:
export 'plan.dart';
export 'task.dart';
  1. Moving on to main.dart, we need to set up our MaterialApp for this project. This should hopefully be easy by now. Just create a StatelessWidget that returns a MaterialApp that, in its home directory, calls a widget called PlanScreen. We will build this shortly:
import 'package:flutter/material.dart';
import './views/plan_screen.dart';

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

class MasterPlanApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
theme: ThemeData(primarySwatch: Colors.purple),
home: PlanScreen(),
);
}
}
  1. With the plumbing out of the way, we can continue with the view layer. In the views folder, create a file called plan_screen.dart and use the StatefulWidget template to create a class called PlanScreen. Import the material library and build a basic Scaffold and AppBar in the State class. We’ll also create a single plan that will be stored as a property in the State class:
import '../models/data_layer.dart';
import 'package:flutter/material.dart';

class PlanScreen extends StatefulWidget {
@override
State createState() => _PlanScreenState();
}

class _PlanScreenState extends State<PlanScreen> {
final plan = Plan();

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Master Plan')),
body: _buildList(),
floatingActionButton: _buildAddTaskButton(),
);
}
}
  1. This code won’t compile because we’re missing a few methods. Let’s start with the easier one – the Add Task button. This button will use the FloatingActionButton layout, which is a common way to add an item to a list according to the material design’s specifications. Add the following code just below the build method:
Widget _buildAddTaskButton() {
return FloatingActionButton(
child: Icon(Icons.add),
onPressed: () {
setState(() {
plan.tasks.add(Task());
});
},
);
}
  1. We can create a scrollable list to show all our tasks; ListView.builder will certainly suit our needs. Create this simple method to build our ListView:
Widget _buildList() {
return ListView.builder(
itemCount: plan.tasks.length,
itemBuilder: (context, index) =>
_buildTaskTile(plan.tasks[index]),
);
}
  1. We just need to return a ListTile that displays the value of our task. Because we took the effort to set up a model for each task, building the view will be easy. Add the following code right after the build list method:
Widget _buildTaskTile(Task task) {
return ListTile(
leading: Checkbox(
value: task.complete,
onChanged: (selected) {
setState(() {
task.complete = selected;
});
}),
title: TextField(
onChanged: (text) {
setState(() {
task.description = text;
});
},
),
);
}
  1. Run the app; you will see that everything has been fully wired up. You can add tasks, mark them as complete, and scroll through the list when it gets too long. However, there is one iOS-specific feature we need to add. Once the keyboard is open, you can’t get rid of it. You can use a ScrollController to remove the focus from any TextField during a scroll event. Add a scroll controller as a property of the State class, just after the plan property:
ScrollController scrollController;
  1. scrollController has to be initialized in the initState life cycle method. This is where you will also add the scroll listener. Add the initState method to the State class, after the scrollController declaration, as shown here:
@override
void initState() {
super.initState();
scrollController = ScrollController()
..addListener(() {
FocusScope.of(context).requestFocus(FocusNode());
});
}
  1. Add the controller to ListView in the _buildList method:
return ListView.builder(
controller: scrollController,
  1. Finally, dispose of scrollController when the widget is removed from the tree:
@override
void dispose() {
scrollController.dispose();
super.dispose();
}
  1. Hot restart (not reload) your app. You should see our plan coming together:

How it works…

The UI in this recipe is data-driven. The ListView widget (the view) queries the Plan class (the model) to figure out how many items there are. In the itemBuilder closure, we extract the specific Task that matches the item index and pass the entire model to the buildTaskTile method. 

The Tiles are also data-driven as they read the complete boolean value in the model to choose whether the checkbox should be checked or not.

If you look at our implementation of the Checkbox widget, you’ll see that it takes data from the model and then returns data to the model when its state changes. This widget is truly a view into our data:

Checkbox(
value: task.complete,
onChanged: (selected) {
setState(() {
task.complete = selected;
});
}),

When building the UI for each individual task, the State of these widgets is owned by the model. The UI’s job is to query the model for its current state and draw itself accordingly.

In the onTapped and onChanged callbacks, we take the values that are returned from the widgets/views and store them in the model. This, in turn, calls setState, which causes the widget to repaint with the most up-to-date data.Normally, it is not ideal to have your views directly communicate with your models. This can still lead to strong coupling with business logic leaking into the view layer. This is usually where the fancier patterns such as BLoC and Redux come in – they act as the glue between the model and the view. We will start exploring these components in the next recipe.

App Architecture is an interesting beast. It can be argued that it’s more of an art than a science. The fact that you created layers where different parts of your code live is not required to make the app work.

The real reason why you would want to separate the model and view classes has little to do with functionality and more to do with productivity. By separating these concepts into different components, you can compartmentalize your development process. When you are working on a model file, you don’t need to think at all about the user interface. At the data level, concepts such as buttons, colors, padding, and scrolling are a distraction. The goal of the data layer should be to focus on the data and any business rules you need to implement. On the other hand, your views do not need to think about the implementation details of the data models. In this way, you achieve what’s called “separation of concerns,” which is a solid development pattern.

See also

Check out these resources to learn more about app architecture:

Managing the data layer with InheritedWidget

How should you call the data classes in your app? 

You could, in theory, set up a place in static memory where all your data classes will reside, but that won’t play well with tools such as Hot Reload and could even introduce some undefined behavior down the road. The better options involve placing your data classes in the widget tree so they can take advantage of your application’s life cycle. 

The question then becomes, how can you place a model in the widget tree? Models are not widgets, after all, and there is nothing to build onto the screen.

A possible solution is using InheritedWidget. So far, we’ve only been using two types of widgets: StatelessWidget and StatefulWidget. Both of these widgets are concerned with rendering widgets onto the screen; the only difference is that one can change and the other cannot. InheritedWidget is another beast entirely. Its job is to pass data down to its children, but from a user’s perspective, it’s invisible. InheritedWidget can be used as the doorway between your view and data layers.

In this recipe, we will be updating the Master Plan app to move the storage of the to-do lists outside of the view classes.

Getting ready

You should complete the previous recipe, Model-view separation, before following along with this one. 

How to do it…

Let’s learn how to add InheritedWidget to our project:

  1. Create a new file called plan_provider.dart for storing our plans. Place this file in the root of the project’s lib directory. This widget extends InheritedWidget:
import 'package:flutter/material.dart';
import './models/data_layer.dart';

class PlanProvider extends InheritedWidget {
final _plan = Plan();

PlanProvider({Key key, Widget child}) : super(key: key, child:
child);

@override
bool updateShouldNotify(InheritedWidget oldWidget) => false;
}
  1. To make the data accessible from anywhere in the app, we need to create our first of-context method. Add a static Plan of method that takes a BuildContext just after updateShouldNotify:
static Plan of(BuildContext context) {
final provider = context.dependOnInheritedWidgetOfExactType<PlanProvider>();
return provider._plan;
}
  1. Now that the provider widget is ready, it needs to be placed in the tree. In the build method of MasterPlanApp, in main.dart, wrap PlanScreen with a new PlanProvider class. Don’t forget to fix any broken imports if needed:
return MaterialApp(
theme: ThemeData(primarySwatch: Colors.purple),
home: PlanProvider(child: PlanScreen()),
);
  1. Add two new get methods to the plan.dart file. These will be used to show the progress on every plan. Call the first one completeCount and the second completenessMessage:
int get completeCount => tasks
.where((task) => task.complete)
.length;

String get completenessMessage =>
'$completeCount out of ${tasks.length} tasks';
  1. Tweak PlanScreen so that it uses the PlanProvider’s data instead of its own. In the State class, delete the plan property (this creates a few compile errors).
  2. To fix the errors that were raised in the previous step, add PlanProvider.of(context) to the _buildAddTaskButton and _buildList methods:
Widget _buildAddTaskButton() {
final plan = PlanProvider.of(context);
Widget _buildList() {
final plan = PlanProvider.of(context);
  1. Still in the PlanScreen class, update the build method so that it shows the progress message at the bottom of the screen. Wrap the _buildList method in an Expanded widget and wrap it in a Column widget. 
  2. Finally, add a SafeArea widget with completenessMessage at the end of Column. The final result is shown here: 
@override
Widget build(BuildContext context) {
final plan = PlanProvider.of(context);
return Scaffold(
appBar: AppBar(title: Text('Master Plan')),
body: Column(children: <Widget>[
Expanded(child: _buildList()),
SafeArea(child: Text(plan.completenessMessage))
]),
floatingActionButton: _buildAddTaskButton());
}
  1. Change TextField in _buildTaskTile to a TextFormField, to make it easier to provide initial data:
TextFormField(
initialValue: task.description,
onFieldSubmitted: (text) {
setState(() {
task.description = text;
});
},

Finally, build and run the app. There shouldn’t be any noticeable change, but by doing this, you have created a cleaner separation of concerns between your view and the models. 

How it works…

InheritedWidgets are some of the most fascinating widgets in the whole Flutter framework. Their job isn’t to render anything on the screen, but to pass data down to lower widgets in the tree. Just like any other widget in Flutter, InheritedWidgets can also have child widgets.

Let’s break down the first portion of the PlanProvider class:

class PlanProvider extends InheritedWidget {
final _plans = <Plan>[];

PlanProvider({Key key, Widget child}) : super(key: key, child: child);

@override
bool updateShouldNotify(InheritedWidget oldWidget) => false;

First, we define an object that will store the plans (_plans). Then, we define a default unnamed constructor, which takes in a key and a child, and passes them to the superclass (super). 

InheritedWidget is an abstract class, so you must implement the updateShouldNotify method. Flutter calls this method whenever the widget is rebuilt. In the updateShouldNotify method, you can look at the content of the old widget and determine if the child widgets need to be notified that the data has changed. In our case, we just return false and opt-out of this functionality. In most cases, it is rather unlikely that you need this method to return true.

Then, you must create your own implementation of the of-context pattern:

static Plan of(BuildContext context) {
final provider = context.dependOnInheritedWidgetOfExactType
<PlanProvider>();
return provider._plan;
}

Here, you are using the context’s dependOnInheritedWidgetOfExactType method to kick off the tree traversal process. Flutter will start from the widget that owns this context and travel upward until it finds a PlanProvider.

An interesting side effect of this method is that after it is called, the originating widget is registered as a dependency. This now creates a hard link between the child widget and PlanProvider. The next time this method is called, there is no need to travel up the tree anymore; the child already knows where the data is and can retrieve it immediately. This optimization makes it extremely fast, if not almost instant, to get the data from InheritedWidgets, no matter how deep the tree goes.

See also

The official documentation on InheritedWidget can be found at https://api.flutter.dev/flutter/widgets/InheritedWidget-class.html.

Making the app state visible across multiple screens

One phrase that is thrown around a lot in the Flutter community is “Lift State Up.” This mantra, which originally came from React, refers to the idea that State objects should be placed higher than the widgets that need it in the widget tree. Our InheritedWidget, which we created in the previous recipe, works perfectly for a single screen, but it is not ideal when you add a second. The higher in the tree your state object is stored, the easier it is for your children widgets to access it.

In this recipe, you are going to add another screen to the Master Plan app so that you can create multiple plans. Accomplishing this will require our State provider to be lifted higher in the tree, closer to its root.

Getting ready

You should have completed the previous recipes in this tutorial before following along with this one. 

How to do it…

Let’s add a second screen to the app and lift the State higher in the tree: 

  1. Update the PlanProvider class so that it can handle multiple plans. Change the storage property from a single plan to a list of plans:
final _plans = <Plan>[];
  1. We also need to update the of-context method so that it returns the correct type. This will temporarily break the project, but we will fix this in the next few steps:
static List<Plan> of(BuildContext context) {
final provider = context.dependOnInheritedWidgetOfExactType
<PlanProvider>();
return provider._plans;
}
  1. PlanProvider is also going to have a new home in the widget tree. Instead of sitting underneath MaterialApp, we actually want this global state widget to be placed above it. Update the build method in main.dart so that it looks like this:
return PlanProvider(
child: MaterialApp(
theme: ThemeData(primarySwatch: Colors.purple),
  1. We can now create a new screen to manage the multiple plans. This screen will depend on the PlanProvider to store the app’s data. In the views folder, create a file called plan_creator_screen.dart and declare a new StatefulWidget called PlanCreatorScreen. Make this class the new home widget for the MaterialApp, replacing PlanScreen.
home: PlanCreatorScreen(),
  1. In the _PlanCreatorScreenState class, we need to add a TextEditingController so that we can create a simple TextField to add new plans. Don’t forget to dispose of textController when the widget is unmounted:
final textController = TextEditingController();

@override
void dispose() {
textController.dispose();
super.dispose();
}
  1. Now, let’s create the build method for this screen. This screen will have a TextField at the top and a list of plans underneath it. Add the following code before the dispose method to create a Scaffold for this screen:
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Master Plans')),
body: Column(children: <Widget>[
_buildListCreator(),
Expanded(child: _buildMasterPlans())
]),
);
}
  1. The _buildListCreator method constructs a TextField and calls a function to add a plan when the user taps Enter on their keyboard. We’re going to wrap TextField in a Material widget to make the field pop out:
Widget _buildListCreator() {
return Padding(
padding: const EdgeInsets.all(20.0),
child: Material(
color: Theme.of(context).cardColor,
elevation: 10,
child: TextField(
controller: textController,
decoration: InputDecoration(
labelText: 'Add a plan',
contentPadding: EdgeInsets.all(20)),
onEditingComplete: addPlan),
));
}
  1. The addPlan method will check whether the user actually typed something into the field and will then reset the screen:
void addPlan() {
final text = textController.text;
if (text.isEmpty) {
return;
}

final plan = Plan()..name = text;
PlanProvider.of(context).add(plan);
textController.clear();
FocusScope.of(context).requestFocus(FocusNode());
setState(() {});
}
  1. We can create a ListView that will read the data from PlanProvider and print it onto the screen. This component will also be aware of its content and return the appropriate set of widgets:
Widget _buildMasterPlans() {
final plans = PlanProvider.of(context);

if (plans.isEmpty) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Icon(Icons.note, size: 100, color: Colors.grey),
Text('You do not have any plans yet.',
style: Theme.of(context).textTheme.headline5)
]);
}

return ListView.builder(
itemCount: plans.length,
itemBuilder: (context, index) {
final plan = plans[index];
return ListTile(
title: Text(plan.name),
subtitle: Text(plan.completenessMessage),
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => PlanScreen(plan: plan)));
});
});
}
  1. PlanScreen is going to need some small tweaks as well. We need to add a constructor where the specific plan can be injected and then update the build methods to read that value. Add this property and constructor to the widget in plan_screen.dart:
final Plan plan;
const PlanScreen({Key key, this.plan}) : super(key: key);
  1. Finally, we just need to add an easy way to access the widget. Add this getter inside the state class: 
Plan get plan => widget.plan;
  1. Remove all the previous references to PlanProvider. You will need to skim through the class and delete this line everywhere it appears:
final plan = PlanProvider.of(context);

When you hot restart the app, you will be able to create multiple plans with different lists on each screen:

How it works…

The main takeaway from this recipe is the importance of proper widget tree construction. When you push a new route onto Navigator, you are essentially replacing every widget that lives underneath MaterialApp, as explained in this diagram:

If PlanProvider was a child of MaterialAppit would be destroyed when pushing the new route, making all its data inaccessible to the next widget. If you have an InheritedWidget that only needs to provide data for a single screen, then placing it lower in the widget tree is optimal. However, if this same data needs to be accessed across multiple screens, it has to be placed above our Navigator.

Placing our global state widget at the root of the tree also has the added benefit of causing our app to update without any extra code. Try checking and unchecking a few tasks in your plans. You’ll notice that the is data automatically updated, like magic. This is one of the primary benefits of maintaining a clean architecture in our apps.

Written by

XR Developer responsible for end-to-end development of XR solutions spanning multiple domains, by using various XR and WebXR libraries.

Leave a Reply