Managing State in Flutter

Joshua Hall

Most non-trivial apps will have some sort of state change going on and over time managing that complexity becomes increasingly difficult. Flutter apps are no different, but luckily for us, the Provider package is a perfect solution for most of our state management needs.

Prerequisites

We’re going to be passing data between 3 different screens, to keep things brief I will be assuming that you are already familiar with basic navigation and routes. If you need a quick refresher, you can check out my intro here. We’ll also be setting up a basic form without any validation, so a little knowledge on working with form state is a plus.

Installation

First, we’re going to need to add the Provider package to our pubspec.yaml file, I really recommend using the Pubspec Assist extension if you’re using VSCode. You can check out the full package here.

pubspec.yaml

dependencies:
  flutter:
    sdk: flutter
  provider: ^3.1.0

Problem

Imagine you want to build an app that customizes some of its screens with some of the user’s data, like their name. The normal methods for passing down data between screens would quickly become a tangled mess of callbacks, unused data, and unnecessarily rebuilt widgets. With a front-end library like React this is a common problem called prop drilling.

If we wanted to pass data up from any of those widgets then you need to further bloat every intermediate widget with more unused callbacks. For most small features, this may make them almost not worth the effort.

Luckily for us, the Provider package allows us to store our data in a higher up widget, like wherever we initialize our MaterialApp, then access and change it directly from sub-widgets, regardless of nesting and without rebuilding everything in between.

Setup

We’re just going to need 2 screens, our router, and a navbar. We’re just setting up a page to display our account data and another to update it with the state itself being stored, changed, and passed down from our router.

* screens 📂
  * account.dart 
  * settings.dart
* main.dart 
* navbar.dart

main.dart

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import './screens/account.dart';
import './screens/settings.dart';

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

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Provider Demo',
      home: MyHomePage(),
    );
  }
}

class MyHomePage extends StatefulWidget {
  @override
  _MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(home: AccountScreen(), routes: {
      'account_screen': (context) => AccountScreen(),
      'settings_screen': (context) => SettingsScreen(),
    });
  }
}

We’re creating our form state, setting a map to store our inputs, and adding a submit button that we’ll use later.

settings.dart

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../main.dart';
import '../navbar.dart';

class SettingsScreen extends StatelessWidget {
  static const String id = 'settings_screen';

  final formKey = GlobalKey<FormState>();

  Map data = {'name': String, 'email': String, 'age': int};

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      bottomNavigationBar: Navbar(),
      appBar: AppBar(title: Text('Change Account Details')),
      body: Center(
        child: Container(
        padding: EdgeInsets.symmetric(vertical: 20, horizontal: 30),
        child: Form(
          key: formKey,
          child: Column(
              mainAxisAlignment: MainAxisAlignment.center,
              crossAxisAlignment: CrossAxisAlignment.center,
              children: <Widget>[
                TextFormField(
                  decoration: InputDecoration(labelText: 'Name'),
                  onSaved: (input) => data['name'] = input,
                ),
                TextFormField(
                  decoration: InputDecoration(labelText: 'Email'),
                  onSaved: (input) => data['email'] = input,
                ),
                TextFormField(
                  decoration: InputDecoration(labelText: 'Age'),
                  onSaved: (input) => data['age'] = input,
                ),
                FlatButton(
                  onPressed: () => formKey.currentState.save(),
                  child: Text('Submit'),
                  color: Colors.blue,
                  textColor: Colors.white,
                )
              ]),
        ),
      )),
    );
  }
}

Settings Page

Our account page will simply display whatever account information, which doesn’t exist yet.

account.dart

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../main.dart';
import '../navbar.dart';

class AccountScreen extends StatelessWidget {
  static const String id = 'account_screen';

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      bottomNavigationBar: Navbar(),
      appBar: AppBar(
        title: Text('Account Details'),
      ),
      body: Center(
        child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: <Widget>[
              Text('Name: '),
              Text('Email: '),
              Text('Age: '),
            ]),
      ),
    );
  }
}

Provider

Setting up a Provider in incredibly easy, we just need to wrap our MaterialApp in a Provider with the type of our data, which in our case is a map. Finally, we need to set our builder to then use our context and data. Just like that, our data map is now available in every other screen and widget, assuming you import main.dart and the provider package.

main.dart

class _MyHomePageState extends State<MyHomePage> {
  Map data = {
    'name': 'Frank Abignale',
    'email': 'someEmail@alligatorio',
    'age': 47
  };

  @override
  Widget build(BuildContext context) {
    return Provider<Map>(
      builder: (context) => data,
      child: MaterialApp(home: AccountScreen(), routes: {
        'account_screen': (context) => AccountScreen(),
        'settings_screen': (context) => SettingsScreen(),
      }),
    );
  }
}

Everything we passed to our provider builder is now available on Provider.of<Map>(context). Note that the type you pass in must match the type of data our Provider is expecting.

account.dart

body: Center(
  child: Column(
    mainAxisAlignment: MainAxisAlignment.center,
    children: <Widget>[
      Text('Name: ' + Provider.of<Map>(context).data['name'].toString()),
      Text('Email: ' + Provider.of<Map>(context).data['email'].toString()),
      Text('Age: ' + Provider.of<Map>(context).data['age'].toString()),
    ]),
  ),

Account Screen

I recommend creating a snippet, since you’ll probably be accessing provider a lot. Here’s what the snippet would look like in VSCode:

dart.json

"Provider": {
  "prefix": "provider",
  "body": [
    "Provider.of<$1>(context).$2"
  ]
}

Change Notifier

Using Provider this way seems very top down, what if we want to pass data up and alter our map? The Provider alone isn’t enough for that. First, we need to break down our data into its own class that extends ChangeNotifier. Provider won’t work with that, so we need to change it to a ChangeNotifierProvider and pass in an instance of our Data class instead.

Now we’re passing down a whole class and not just a single variable, this means that we can start creating methods that can manipulate our data, which will also be available to everything that accesses Provider.

After we change any of our global data we want to use notifyListeners, which will rebuild every widget that depends on it.

main.dart

class _MyHomePageState extends State<MyHomePage> {
  @override
  Widget build(BuildContext context) {
    return ChangeNotifierProvider<Data>(
      builder: (context) => Data(),
      child: MaterialApp(home: AccountScreen(), routes: {
        'account_screen': (context) => AccountScreen(),
        'settings_screen': (context) => SettingsScreen(),
      }),
    );
  }
}

class Data extends ChangeNotifier {
  Map data = {
    'name': 'Frank Abignale',
    'email': 'someEmail@alligatorio',
    'age': 47
  };

  void updateAccount(input) {
    data = input;
    notifyListeners();
  }
}

Since we changed our Provider type, we need to update our calls to it.

account.dart

Widget>[
  Text('Name: ' + Provider.of<Data>(context).data['name'].toString()),
  Text('Email: ' + Provider.of<Data>(context).data['email'].toString()),
  Text('Age: ' + Provider.of<Data>(context).data['age'].toString()),
]),

To pass data up, we just need to access the provider just like before and use our method that was passed down in the Data class.

setting.dart

 FlatButton(
  onPressed: () {
    formKey.currentState.save();
    Provider.of<Data>(context).updateAccount(data);
    formKey.currentState.reset();
  },
)

screenshot: Finished Example App

Conclusion

Once you get proficient with Provider it will save you an enormous amount of time and frustration. As always, if you had any trouble reproducing this app check out the repo here.

  Tweet It

🕵 Search Results

🔎 Searching...

Sponsored by #native_company# — Learn More
#native_title# #native_desc#
#native_cta#