Using the Provider Package for Dependency Injection and State Management in Flutter

Flutter, Google’s UI toolkit, enables developers to build natively compiled applications for mobile, web, and desktop from a single codebase. One of the critical aspects of building robust and maintainable Flutter apps is effective state management and dependency injection. The Provider package is a popular choice for managing application state and dependencies in Flutter applications. This article explores how to leverage the Provider package for dependency injection and state management in Flutter, providing detailed code examples.

What is the Provider Package?

The Provider package is a Flutter package that provides a simple way to access data and services throughout your application. It’s a wrapper around InheritedWidget, making it easier to manage and access app state without writing a lot of boilerplate code. The Provider package is highly versatile and suitable for both simple and complex applications.

Why Use Provider?

  • Simplicity: Easy to understand and implement compared to other state management solutions.
  • Efficiency: Leverages InheritedWidget under the hood, optimized for performance.
  • Testability: Makes your widgets and logic more testable by providing a clear separation of concerns.
  • Accessibility: Enables easy access to application state from any part of the widget tree.
  • Scalability: Suitable for small to large-scale applications.

Setting Up the Provider Package

To start using the Provider package, add it to your pubspec.yaml file:


dependencies:
  flutter:
    sdk: flutter
  provider: ^6.0.0 # Use the latest version

Then, run flutter pub get to install the package.

Dependency Injection with Provider

Dependency injection is a software design pattern in which one or more dependencies (or services) are provided to a dependent object. This helps in decoupling components and improves code testability. Here’s how to use the Provider package for dependency injection:

Step 1: Define Your Services/Dependencies

First, define the services or dependencies that your widgets will need. For example, let’s create a simple ApiService class:


class ApiService {
  Future fetchData() async {
    // Simulate fetching data from an API
    await Future.delayed(Duration(seconds: 1));
    return "Data from API";
  }
}

Step 2: Provide the Dependencies Using Provider

Next, provide these dependencies using Provider at the top of your widget tree. This makes them available to all descendant widgets. Wrap your MaterialApp with a Provider widget:


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

void main() {
  runApp(
    Provider(
      create: (context) => ApiService(),
      child: MyApp(),
    ),
  );
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Provider Example',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: MyHomePage(),
    );
  }
}

Step 3: Consume the Dependencies

Consume the provided dependencies in your widgets using Provider.of<T>(context). For example, in MyHomePage:


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

import 'api_service.dart'; // Import your ApiService

class MyHomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final apiService = Provider.of(context, listen: false); // listen: false to prevent rebuilds

    return Scaffold(
      appBar: AppBar(
        title: Text('Provider Example'),
      ),
      body: Center(
        child: FutureBuilder(
          future: apiService.fetchData(),
          builder: (context, snapshot) {
            if (snapshot.connectionState == ConnectionState.waiting) {
              return CircularProgressIndicator();
            } else if (snapshot.hasError) {
              return Text('Error: ${snapshot.error}');
            } else {
              return Text('Data: ${snapshot.data}');
            }
          },
        ),
      ),
    );
  }
}

Here, Provider.of<ApiService>(context, listen: false) retrieves an instance of ApiService from the context. listen: false is used because we don’t want the widget to rebuild when the ApiService changes (it’s a service, not state).

State Management with Provider

Provider can also be used for state management, allowing widgets to react to changes in application state. There are several types of Providers available for state management:

  • ChangeNotifierProvider: For simple state that needs to notify listeners.
  • StreamProvider: For data coming from a Stream.
  • FutureProvider: For data coming from a Future.

Using ChangeNotifierProvider

ChangeNotifierProvider is the most commonly used type of Provider for state management. It’s ideal for managing simple state that needs to notify listeners when it changes.

Step 1: Create a ChangeNotifier Class

Define a class that extends ChangeNotifier. This class will hold your application state and notify listeners when the state changes.


import 'package:flutter/material.dart';

class CounterModel extends ChangeNotifier {
  int _counter = 0;

  int get counter => _counter;

  void incrementCounter() {
    _counter++;
    notifyListeners();
  }
}

The notifyListeners() method is called whenever the state changes. This tells all widgets listening to this ChangeNotifier to rebuild.

Step 2: Provide the ChangeNotifier

Wrap your MaterialApp (or a relevant part of your widget tree) with a ChangeNotifierProvider. This makes the CounterModel available to descendant widgets.


void main() {
  runApp(
    ChangeNotifierProvider(
      create: (context) => CounterModel(),
      child: MyApp(),
    ),
  );
}
Step 3: Consume the State

Consume the state in your widgets using Consumer, Provider.of, or context.watch/context.read (extension methods from Provider).

Using Consumer:


import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'counter_model.dart'; // Import your CounterModel

class MyHomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Provider Example'),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text(
              'You have pushed the button this many times:',
            ),
            Consumer(
              builder: (context, counterModel, child) {
                return Text(
                  '${counterModel.counter}',
                  style: Theme.of(context).textTheme.headline4,
                );
              },
            ),
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () => Provider.of(context, listen: false).incrementCounter(),
        tooltip: 'Increment',
        child: Icon(Icons.add),
      ),
    );
  }
}

The Consumer widget rebuilds whenever CounterModel changes. Provider.of<CounterModel>(context, listen: false).incrementCounter() is used to increment the counter without causing the FloatingActionButton to rebuild.

Using context.watch and context.read (Provider extension methods):


import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'counter_model.dart'; // Import your CounterModel
import 'package:provider/provider.dart'; // Import the provider package

class MyHomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    // context.watch() to listen for changes and rebuild.
    final counterValue = context.watch().counter;

    return Scaffold(
      appBar: AppBar(
        title: Text('Provider Example'),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text(
              'You have pushed the button this many times:',
            ),
            Text(
              '$counterValue',
              style: Theme.of(context).textTheme.headline4,
            ),
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        // context.read() to access the model without listening for changes.
        onPressed: () => context.read().incrementCounter(),
        tooltip: 'Increment',
        child: Icon(Icons.add),
      ),
    );
  }
}
  • context.watch<CounterModel>(): Watches the CounterModel for changes and rebuilds the widget when the model changes.
  • context.read<CounterModel>(): Provides access to the CounterModel without listening for changes, making it suitable for actions like incrementing the counter.

Using StreamProvider and FutureProvider

If you’re working with streams or futures, StreamProvider and FutureProvider are handy. Let’s see an example with StreamProvider.

Step 1: Create a Stream

Create a stream that emits data periodically. For example, a stream that emits a new number every second:


import 'dart:async';

Stream numberStream() {
  return Stream.periodic(
    Duration(seconds: 1),
    (count) => count,
  );
}
Step 2: Provide the Stream

Wrap your MaterialApp with a StreamProvider:


import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'stream_provider_example.dart';

void main() {
  runApp(
    StreamProvider(
      create: (context) => numberStream(),
      initialData: 0, // Provide an initial data value
      child: MyApp(),
    ),
  );
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'StreamProvider Example',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: MyHomePage(),
    );
  }
}
Step 3: Consume the Stream Data

Consume the stream data using Consumer or context.watch:


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

class MyHomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('StreamProvider Example'),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text(
              'Stream Value:',
            ),
            Consumer(
              builder: (context, streamValue, child) {
                return Text(
                  '$streamValue',
                  style: Theme.of(context).textTheme.headline4,
                );
              },
            ),
          ],
        ),
      ),
    );
  }
}

Best Practices When Using Provider

  • Keep it Simple: Avoid complex logic inside the ChangeNotifier. Move business logic to separate services.
  • Use listen: false When Necessary: When retrieving dependencies or triggering actions, use listen: false to prevent unnecessary rebuilds.
  • Properly Scope Providers: Provide state at the appropriate level of the widget tree to avoid unnecessary rebuilds.
  • Testing: Write unit and widget tests to ensure your state management logic works as expected.
  • Use Extensions Wisely: Use context.watch and context.read for a cleaner syntax, but ensure you understand when to use each.

Conclusion

The Provider package offers a straightforward yet powerful way to handle dependency injection and state management in Flutter applications. By understanding how to provide and consume dependencies, and by leveraging ChangeNotifierProvider, StreamProvider, and FutureProvider, you can build more maintainable, testable, and efficient Flutter applications. The Provider package’s simplicity and versatility make it an excellent choice for projects of all sizes. Implementing these practices will lead to cleaner code and a better overall app architecture.