İçeriğe geç / Skip to content / Zum Inhalt

Flutter: State Management with Provider

Ahmet Balaman

Last updated:

5 min read

FlutterProviderState ManagementChangeNotifierConsumer
Flutter: State Management with Provider

Provider is one of the most popular packages for state management in Flutter; the Flutter team's own simple state management guide teaches its examples with this package. It is used to share data across the widget tree and listen to changes.

What is State and Why Should It Be Managed?

State is the data condition your application has at any given moment. For example:

  • Whether the user is logged in
  • Number of items in the cart
  • Theme preference (light/dark)
  • Values in form fields
  • Data from APIs

Without state management, sharing this data between widgets and reflecting updates becomes very difficult.

Limitations of setState

Flutter uses setState for simple state management; where setState sits inside a StatefulWidget and when it runs is covered in the life cycle post. However, as your app grows, you face serious problems:

1. Prop Drilling Problem

// You have to pass data 5 levels down
GrandParent(
  child: Parent(
    child: Child(
      child: GrandChild(
        child: GreatGrandChild(
          userData: userData, // Must be passed all the way!
        ),
      ),
    ),
  ),
)

2. Unnecessary Rebuilds

When you use setState, the entire widget rebuilds - not just the changed part.

3. State Loss

State can be lost when navigating the widget tree. Data resets when you change pages and return.

4. Untestability

Writing unit tests becomes difficult when UI and business logic are intertwined.

5. Code Repetition

You need to constantly pass parameters to use the same data in multiple places.

How Provider Solves These Problems

When ChangeNotifier calls notifyListeners only the Consumer is rebuilt, while the other widgets in the tree are left alone

✅ Centralized State Management

Data is managed from a single place, accessible from everywhere.

✅ Efficient Rebuilds

Only changed widgets are rebuilt.

✅ Separation of Concerns

Business logic and UI are separated from each other.

✅ Easy Testability

You can easily write tests by mocking Providers.

✅ No Prop Drilling

Widgets that need data read directly from Provider.

When Should You Use Provider?

Situation setState Provider
Simple counter in single widget ✅ ❌
Form validation ✅ ❌
User info across multiple pages ❌ ✅
Cart management ❌ ✅
Theme/Language settings ❌ ✅
Data from APIs ❌ ✅
Complex form states ❌ ✅

General Rule: If state is used by multiple widgets or pages, consider Provider.

Why Provider?

Feature setState Provider
Scope Single widget Entire widget tree
Complexity Simple Medium
Scalability Hard Easy
Testability Hard Easy
Prop drilling Required Not needed

If the BuildContext dependency and the lack of compile-time safety bother you, the next step on the same idea is Riverpod: providers are declared globally, no context is needed, and reading the wrong type fails at compile time.

⚠️ Important Note: Provider is an external package and does not work in DartPad. To run the examples on your computer, follow the installation steps below.

Installation

You can check the current version on the provider package page on pub.dev; add it to your pubspec.yaml file:

dependencies:
  flutter:
    sdk: flutter
  provider: ^6.1.1

Then run in terminal:

flutter pub get

Core Concepts

1. ChangeNotifier

Class that holds state and notifies changes; the ChangeNotifier API docs, part of Flutter itself, describe adding listeners and how notifyListeners behaves:

import 'package:flutter/foundation.dart';

class CounterProvider extends ChangeNotifier {
  int _count = 0;
  
  int get count => _count;
  
  void increment() {
    _count++;
    notifyListeners(); // Notify listeners
  }
  
  void decrement() {
    _count--;
    notifyListeners();
  }
  
  void reset() {
    _count = 0;
    notifyListeners();
  }
}

2. ChangeNotifierProvider

Adding Provider to widget tree:

import 'package:provider/provider.dart';

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

3. Consumer

Listening to state and updating UI:

Consumer<CounterProvider>(
  builder: (context, counter, child) {
    return Text(
      '${counter.count}',
      style: TextStyle(fontSize: 48),
    );
  },
)

4. context.read and context.watch

// Read (doesn't listen to changes) - Use in buttons
context.read<CounterProvider>().increment();

// Watch (listens to changes) - Use in build method
final count = context.watch<CounterProvider>().count;

Full Example: Counter App

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

// 1. State class
class CounterProvider extends ChangeNotifier {
  int _count = 0;
  
  int get count => _count;
  
  void increment() {
    _count++;
    notifyListeners();
  }
  
  void decrement() {
    _count--;
    notifyListeners();
  }
}

// 2. Main
void main() {
  runApp(
    ChangeNotifierProvider(
      create: (context) => CounterProvider(),
      child: MaterialApp(
        home: CounterPage(),
      ),
    ),
  );
}

// 3. UI
class CounterPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Provider Counter')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text('Counter Value:'),
            Consumer<CounterProvider>(
              builder: (context, counter, child) {
                return Text(
                  '${counter.count}',
                  style: TextStyle(fontSize: 72, fontWeight: FontWeight.bold),
                );
              },
            ),
            SizedBox(height: 32),
            Row(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                FloatingActionButton(
                  onPressed: () => context.read<CounterProvider>().decrement(),
                  child: Icon(Icons.remove),
                ),
                SizedBox(width: 16),
                FloatingActionButton(
                  onPressed: () => context.read<CounterProvider>().increment(),
                  child: Icon(Icons.add),
                ),
              ],
            ),
          ],
        ),
      ),
    );
  }
}

MultiProvider

Using multiple providers:

void main() {
  runApp(
    MultiProvider(
      providers: [
        ChangeNotifierProvider(create: (_) => CounterProvider()),
        ChangeNotifierProvider(create: (_) => ThemeProvider()),
        ChangeNotifierProvider(create: (_) => UserProvider()),
      ],
      child: MyApp(),
    ),
  );
}

Real World Example: Theme Switching

// Theme Provider
class ThemeProvider extends ChangeNotifier {
  ThemeMode _themeMode = ThemeMode.light;
  
  ThemeMode get themeMode => _themeMode;
  
  bool get isDarkMode => _themeMode == ThemeMode.dark;
  
  void toggleTheme() {
    _themeMode = isDarkMode ? ThemeMode.light : ThemeMode.dark;
    notifyListeners();
  }
}

// Main
void main() {
  runApp(
    ChangeNotifierProvider(
      create: (_) => ThemeProvider(),
      child: MyApp(),
    ),
  );
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Consumer<ThemeProvider>(
      builder: (context, themeProvider, child) {
        return MaterialApp(
          themeMode: themeProvider.themeMode,
          theme: ThemeData.light(),
          darkTheme: ThemeData.dark(),
          home: HomePage(),
        );
      },
    );
  }
}

class HomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final themeProvider = context.watch<ThemeProvider>();
    
    return Scaffold(
      appBar: AppBar(title: Text('Theme Settings')),
      body: Center(
        child: SwitchListTile(
          title: Text('Dark Mode'),
          value: themeProvider.isDarkMode,
          onChanged: (_) => themeProvider.toggleTheme(),
        ),
      ),
    );
  }
}

As written, the preference only lives in memory and the app goes back to the light theme after a restart. To make the choice persistent, the local data storage with SharedPreferences post shows step by step how the same kind of ChangeNotifier writes the value to disk and reads it back at startup.

Real World Example: Shopping Cart

// Product model
class Product {
  final String id;
  final String name;
  final double price;
  
  Product({required this.id, required this.name, required this.price});
}

// Cart Provider
class CartProvider extends ChangeNotifier {
  final List<Product> _items = [];
  
  List<Product> get items => List.unmodifiable(_items);
  
  int get itemCount => _items.length;
  
  double get totalPrice => _items.fold(0, (sum, item) => sum + item.price);
  
  void addItem(Product product) {
    _items.add(product);
    notifyListeners();
  }
  
  void removeItem(String productId) {
    _items.removeWhere((item) => item.id == productId);
    notifyListeners();
  }
  
  void clearCart() {
    _items.clear();
    notifyListeners();
  }
}

// Usage
class ProductCard extends StatelessWidget {
  final Product product;
  
  ProductCard({required this.product});
  
  @override
  Widget build(BuildContext context) {
    return Card(
      child: ListTile(
        title: Text(product.name),
        subtitle: Text('\$${product.price}'),
        trailing: IconButton(
          icon: Icon(Icons.add_shopping_cart),
          onPressed: () {
            context.read<CartProvider>().addItem(product);
            ScaffoldMessenger.of(context).showSnackBar(
              SnackBar(content: Text('${product.name} added to cart')),
            );
          },
        ),
      ),
    );
  }
}

// Cart icon (in AppBar)
class CartIcon extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Stack(
      children: [
        IconButton(
          icon: Icon(Icons.shopping_cart),
          onPressed: () {
            // Navigate to cart page
          },
        ),
        Positioned(
          right: 0,
          top: 0,
          child: Consumer<CartProvider>(
            builder: (context, cart, child) {
              return cart.itemCount > 0
                  ? CircleAvatar(
                      radius: 10,
                      backgroundColor: Colors.red,
                      child: Text(
                        '${cart.itemCount}',
                        style: TextStyle(fontSize: 12, color: Colors.white),
                      ),
                    )
                  : SizedBox.shrink();
            },
          ),
        ),
      ],
    );
  }
}

Optimization with Selector

Listen to specific changes only:

// Listens to all changes (inefficient)
Consumer<CartProvider>(
  builder: (context, cart, child) {
    return Text('${cart.itemCount} items');
  },
)

// Rebuilds only when itemCount changes (efficient)
Selector<CartProvider, int>(
  selector: (context, cart) => cart.itemCount,
  builder: (context, itemCount, child) {
    return Text('$itemCount items');
  },
)

Provider Types

Type Usage
Provider Immutable values
ChangeNotifierProvider Mutable state (most common)
FutureProvider Async data
StreamProvider Stream data
ProxyProvider Dependent providers

FutureProvider Example

FutureProvider<List<Product>>(
  create: (_) => fetchProducts(),
  initialData: [],
  child: ProductList(),
)

Best Practices

1. Define Provider as high as possible

// ✅ Correct - In main
void main() {
  runApp(
    ChangeNotifierProvider(
      create: (_) => MyProvider(),
      child: MyApp(),
    ),
  );
}

// ❌ Wrong - Deep in tree
class SomePage extends StatelessWidget {
  Widget build(BuildContext context) {
    return ChangeNotifierProvider(
      create: (_) => MyProvider(), // New instance on every build!
      child: ...,
    );
  }
}

2. context.read vs context.watch

// ✅ Use watch in build method
Widget build(BuildContext context) {
  final count = context.watch<CounterProvider>().count;
  return Text('$count');
}

// ✅ Use read in event handlers
onPressed: () {
  context.read<CounterProvider>().increment();
}

// ❌ Don't use read in build method
Widget build(BuildContext context) {
  final count = context.read<CounterProvider>().count; // Won't update
  return Text('$count');
}

3. Keep Consumer as narrow as possible

// ✅ Correct - Only necessary part rebuilds
Scaffold(
  appBar: AppBar(title: Text('Page')),
  body: Consumer<CounterProvider>(
    builder: (context, counter, child) {
      return Text('${counter.count}');
    },
  ),
)

// ❌ Wrong - Entire page rebuilds
Consumer<CounterProvider>(
  builder: (context, counter, child) {
    return Scaffold(
      appBar: AppBar(title: Text('Page')),
      body: Text('${counter.count}'),
    );
  },
)

Summary

  • Provider: the recommended state management solution for Flutter
  • ChangeNotifier: the class that holds state and notifies listeners
  • notifyListeners(): called to update the UI
  • Consumer: listens to state changes
  • context.watch: use in build (listens)
  • context.read: use in events (doesn't listen)
  • MultiProvider: multiple providers
  • Selector: performance optimization

Provider is a powerful and flexible state management solution that can be used in Flutter projects of all sizes. When a single screen owns its own data, setState is still the simplest route, and for painting a one-off async result on screen, FutureBuilder is often all you need.

Frequently Asked Questions

Should I use Provider or setState?

If the state lives inside a single widget and nothing outside reads it, setState is the simplest and fastest option. Once several screens read or update the same data, move to Provider — the real win is getting rid of prop drilling.

What is the difference between context.read and context.watch?

context.watch reads the value and subscribes that widget to changes, so it belongs in build. context.read does a one-off read without subscribing and is the right choice inside event handlers such as a button's onPressed.

I called notifyListeners() but the UI doesn't update — why?

Two causes account for most cases: the listening widget uses context.read (which never subscribes), or you mutated a list in place and handed back the same reference. Make the change inside the ChangeNotifier, call notifyListeners(), and make sure the reading side uses context.watch or a Consumer.

Comments