
State management is a core concept in Flutter development. As your apps grow in complexity, you’ll need to manage how data is stored, updated, and shared across different parts of your UI. In this comprehensive guide, we’ll compare setState() and Provider with real-world examples, helping you understand when to use each approach and how to implement them effectively.
Why State Management Matters
State refers to the data that determines what your UI shows: whether a user is logged in, how many items are in a cart, form field values, loading indicators, and more. Flutter rebuilds widgets when state changes, so managing state effectively ensures your app behaves as expected, stays efficient, and remains maintainable as it grows.
Poorly managed state leads to:
- UI inconsistencies – Parts of the app showing outdated data
- Performance issues – Unnecessary widget rebuilds
- Difficult debugging – Hard to trace where state changes originated
- Spaghetti code – Business logic mixed with UI code
setState() – Simple and Built-In
The setState() method is Flutter’s simplest way to manage state locally within a widget. It’s built into StatefulWidget and requires no additional packages.
Basic Counter Example
// Simple counter with setState
class CounterScreen extends StatefulWidget {
const CounterScreen({super.key});
@override
State<CounterScreen> createState() => _CounterScreenState();
}
class _CounterScreenState extends State<CounterScreen> {
int _count = 0;
void _increment() {
setState(() {
_count++;
});
}
void _decrement() {
setState(() {
if (_count > 0) _count--;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Counter')),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'Count: $_count',
style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(height: 20),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ElevatedButton(
onPressed: _decrement,
child: const Icon(Icons.remove),
),
const SizedBox(width: 20),
ElevatedButton(
onPressed: _increment,
child: const Icon(Icons.add),
),
],
),
],
),
),
);
}
}
Form State Management with setState
class LoginForm extends StatefulWidget {
const LoginForm({super.key});
@override
State<LoginForm> createState() => _LoginFormState();
}
class _LoginFormState extends State<LoginForm> {
final _formKey = GlobalKey<FormState>();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
bool _isLoading = false;
bool _obscurePassword = true;
String? _errorMessage;
@override
void dispose() {
_emailController.dispose();
_passwordController.dispose();
super.dispose();
}
Future<void> _handleLogin() async {
if (!_formKey.currentState!.validate()) return;
setState(() {
_isLoading = true;
_errorMessage = null;
});
try {
// Simulate API call
await Future.delayed(const Duration(seconds: 2));
// On success, navigate away
if (mounted) {
Navigator.of(context).pushReplacementNamed('/home');
}
} catch (e) {
setState(() {
_errorMessage = 'Login failed. Please check your credentials.';
});
} finally {
if (mounted) {
setState(() {
_isLoading = false;
});
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Login')),
body: Padding(
padding: const EdgeInsets.all(16),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (_errorMessage != null)
Container(
padding: const EdgeInsets.all(12),
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: Colors.red.shade100,
borderRadius: BorderRadius.circular(8),
),
child: Text(
_errorMessage!,
style: TextStyle(color: Colors.red.shade900),
),
),
TextFormField(
controller: _emailController,
decoration: const InputDecoration(
labelText: 'Email',
prefixIcon: Icon(Icons.email),
),
keyboardType: TextInputType.emailAddress,
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter your email';
}
if (!value.contains('@')) {
return 'Please enter a valid email';
}
return null;
},
),
const SizedBox(height: 16),
TextFormField(
controller: _passwordController,
decoration: InputDecoration(
labelText: 'Password',
prefixIcon: const Icon(Icons.lock),
suffixIcon: IconButton(
icon: Icon(
_obscurePassword
? Icons.visibility_off
: Icons.visibility,
),
onPressed: () {
setState(() {
_obscurePassword = !_obscurePassword;
});
},
),
),
obscureText: _obscurePassword,
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter your password';
}
if (value.length < 6) {
return 'Password must be at least 6 characters';
}
return null;
},
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: _isLoading ? null : _handleLogin,
child: _isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Login'),
),
],
),
),
),
);
}
}
setState Pros and Cons
Pros:
- Easy to understand and use
- No setup or external packages required
- Great for local widget updates
- Perfect for form fields and UI toggles
Cons:
- Doesn’t scale well for large apps
- Hard to share state across multiple widgets or screens
- UI and business logic are tightly coupled
- Difficult to test business logic in isolation
Provider – Scalable and Maintainable
Provider is Flutter’s recommended state management package for simple to medium complexity apps. It separates business logic from UI and allows shared state across the widget tree.
Setup Provider
Add provider to your pubspec.yaml:
dependencies:
flutter:
sdk: flutter
provider: ^6.1.1
Counter with ChangeNotifier
// lib/providers/counter_provider.dart
import 'package:flutter/foundation.dart';
class CounterProvider with ChangeNotifier {
int _count = 0;
int get count => _count;
void increment() {
_count++;
notifyListeners();
}
void decrement() {
if (_count > 0) {
_count--;
notifyListeners();
}
}
void reset() {
_count = 0;
notifyListeners();
}
}
Wire up Provider in your app:
// lib/main.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'providers/counter_provider.dart';
import 'screens/counter_screen.dart';
void main() {
runApp(
MultiProvider(
providers: [
ChangeNotifierProvider(create: (_) => CounterProvider()),
// Add more providers here
],
child: const MyApp(),
),
);
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Provider Demo',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true,
),
home: const CounterScreen(),
);
}
}
Use Provider in the UI:
// lib/screens/counter_screen.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/counter_provider.dart';
class CounterScreen extends StatelessWidget {
const CounterScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Provider Counter'),
actions: [
IconButton(
icon: const Icon(Icons.refresh),
onPressed: () {
// Use read when you don't need to listen for updates
context.read<CounterProvider>().reset();
},
),
],
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Consumer rebuilds only this widget when count changes
Consumer<CounterProvider>(
builder: (context, counter, child) {
return Text(
'Count: ${counter.count}',
style: Theme.of(context).textTheme.headlineMedium,
);
},
),
const SizedBox(height: 20),
const CounterButtons(),
],
),
),
);
}
}
class CounterButtons extends StatelessWidget {
const CounterButtons({super.key});
@override
Widget build(BuildContext context) {
// This widget doesn't listen to changes, just triggers actions
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ElevatedButton(
onPressed: () => context.read<CounterProvider>().decrement(),
child: const Icon(Icons.remove),
),
const SizedBox(width: 20),
ElevatedButton(
onPressed: () => context.read<CounterProvider>().increment(),
child: const Icon(Icons.add),
),
],
);
}
}
Complete Authentication Provider Example
// lib/providers/auth_provider.dart
import 'package:flutter/foundation.dart';
enum AuthStatus { initial, loading, authenticated, unauthenticated, error }
class User {
final String id;
final String email;
final String name;
User({required this.id, required this.email, required this.name});
}
class AuthProvider with ChangeNotifier {
AuthStatus _status = AuthStatus.initial;
User? _user;
String? _errorMessage;
AuthStatus get status => _status;
User? get user => _user;
String? get errorMessage => _errorMessage;
bool get isAuthenticated => _status == AuthStatus.authenticated;
Future<void> login(String email, String password) async {
_status = AuthStatus.loading;
_errorMessage = null;
notifyListeners();
try {
// Simulate API call
await Future.delayed(const Duration(seconds: 2));
// Simulate validation
if (email == 'test@test.com' && password == 'password') {
_user = User(
id: '1',
email: email,
name: 'Test User',
);
_status = AuthStatus.authenticated;
} else {
throw Exception('Invalid credentials');
}
} catch (e) {
_status = AuthStatus.error;
_errorMessage = e.toString();
}
notifyListeners();
}
Future<void> logout() async {
_status = AuthStatus.loading;
notifyListeners();
await Future.delayed(const Duration(milliseconds: 500));
_user = null;
_status = AuthStatus.unauthenticated;
notifyListeners();
}
void clearError() {
_errorMessage = null;
notifyListeners();
}
}
// lib/screens/login_screen.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/auth_provider.dart';
class LoginScreen extends StatefulWidget {
const LoginScreen({super.key});
@override
State<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
@override
void dispose() {
_emailController.dispose();
_passwordController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Login')),
body: Consumer<AuthProvider>(
builder: (context, auth, child) {
// Navigate when authenticated
if (auth.isAuthenticated) {
WidgetsBinding.instance.addPostFrameCallback((_) {
Navigator.of(context).pushReplacementNamed('/home');
});
}
return Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (auth.errorMessage != null)
Container(
padding: const EdgeInsets.all(12),
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: Colors.red.shade100,
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Expanded(
child: Text(
auth.errorMessage!,
style: TextStyle(color: Colors.red.shade900),
),
),
IconButton(
icon: const Icon(Icons.close),
onPressed: auth.clearError,
),
],
),
),
TextField(
controller: _emailController,
decoration: const InputDecoration(
labelText: 'Email',
prefixIcon: Icon(Icons.email),
),
keyboardType: TextInputType.emailAddress,
enabled: auth.status != AuthStatus.loading,
),
const SizedBox(height: 16),
TextField(
controller: _passwordController,
decoration: const InputDecoration(
labelText: 'Password',
prefixIcon: Icon(Icons.lock),
),
obscureText: true,
enabled: auth.status != AuthStatus.loading,
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: auth.status == AuthStatus.loading
? null
: () {
auth.login(
_emailController.text,
_passwordController.text,
);
},
child: auth.status == AuthStatus.loading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Login'),
),
],
),
);
},
),
);
}
}
Shopping Cart Provider Example
// lib/models/product.dart
class Product {
final String id;
final String name;
final double price;
final String imageUrl;
Product({
required this.id,
required this.name,
required this.price,
required this.imageUrl,
});
}
class CartItem {
final Product product;
int quantity;
CartItem({required this.product, this.quantity = 1});
double get total => product.price * quantity;
}
// lib/providers/cart_provider.dart
import 'package:flutter/foundation.dart';
import '../models/product.dart';
class CartProvider with ChangeNotifier {
final Map<String, CartItem> _items = {};
Map<String, CartItem> get items => {..._items};
int get itemCount => _items.length;
int get totalQuantity =>
_items.values.fold(0, (sum, item) => sum + item.quantity);
double get totalAmount =>
_items.values.fold(0.0, (sum, item) => sum + item.total);
bool isInCart(String productId) => _items.containsKey(productId);
int getQuantity(String productId) =>
_items[productId]?.quantity ?? 0;
void addItem(Product product) {
if (_items.containsKey(product.id)) {
_items.update(
product.id,
(existing) => CartItem(
product: existing.product,
quantity: existing.quantity + 1,
),
);
} else {
_items.putIfAbsent(
product.id,
() => CartItem(product: product),
);
}
notifyListeners();
}
void removeItem(String productId) {
_items.remove(productId);
notifyListeners();
}
void decrementItem(String productId) {
if (!_items.containsKey(productId)) return;
if (_items[productId]!.quantity > 1) {
_items.update(
productId,
(existing) => CartItem(
product: existing.product,
quantity: existing.quantity - 1,
),
);
} else {
_items.remove(productId);
}
notifyListeners();
}
void clear() {
_items.clear();
notifyListeners();
}
}
// Usage in product card
class ProductCard extends StatelessWidget {
final Product product;
const ProductCard({required this.product, super.key});
@override
Widget build(BuildContext context) {
return Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Image.network(product.imageUrl, height: 150, fit: BoxFit.cover),
Padding(
padding: const EdgeInsets.all(8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(product.name, style: const TextStyle(fontWeight: FontWeight.bold)),
Text('\$${product.price.toStringAsFixed(2)}'),
const SizedBox(height: 8),
Consumer<CartProvider>(
builder: (context, cart, child) {
final inCart = cart.isInCart(product.id);
final quantity = cart.getQuantity(product.id);
if (inCart) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton(
icon: const Icon(Icons.remove),
onPressed: () => cart.decrementItem(product.id),
),
Text('$quantity'),
IconButton(
icon: const Icon(Icons.add),
onPressed: () => cart.addItem(product),
),
],
);
}
return ElevatedButton.icon(
onPressed: () => cart.addItem(product),
icon: const Icon(Icons.add_shopping_cart),
label: const Text('Add to Cart'),
);
},
),
],
),
),
],
),
);
}
}
Provider Pros and Cons
Pros:
- Ideal for medium to large apps
- Clean separation of logic and UI
- Easy to manage app-wide or shared state
- Works well with testing
- Scoped providers for modular state
Cons:
- Requires setup and boilerplate
- Slight learning curve
- Need to understand widget tree context
Selector for Performance Optimization
Use Selector to rebuild only when specific properties change:
// Only rebuilds when totalQuantity changes, not on every cart update
class CartBadge extends StatelessWidget {
const CartBadge({super.key});
@override
Widget build(BuildContext context) {
return Selector<CartProvider, int>(
selector: (_, cart) => cart.totalQuantity,
builder: (context, totalQuantity, child) {
return Badge(
label: Text('$totalQuantity'),
isLabelVisible: totalQuantity > 0,
child: child,
);
},
child: const Icon(Icons.shopping_cart),
);
}
}
Common Mistakes to Avoid
1. Using context.watch in callbacks
// Wrong: watch inside callback causes errors
onPressed: () {
context.watch<CounterProvider>().increment(); // Error!
}
// Correct: use read for actions
onPressed: () {
context.read<CounterProvider>().increment();
}
2. Rebuilding entire widgets unnecessarily
// Wrong: entire build method depends on provider
@override
Widget build(BuildContext context) {
final counter = context.watch<CounterProvider>();
return Scaffold(
// Everything rebuilds when counter changes
);
}
// Correct: use Consumer to scope rebuilds
@override
Widget build(BuildContext context) {
return Scaffold(
body: Consumer<CounterProvider>(
builder: (context, counter, child) {
return Text('${counter.count}');
},
),
);
}
3. Calling notifyListeners after dispose
// Wrong: can cause errors if provider is disposed
Future<void> fetchData() async {
await api.getData();
notifyListeners(); // Might be called after dispose!
}
// Provider doesn't have a built-in "mounted" check like StatefulWidget
// Consider using a flag or try-catch
When Should You Use Each?
Use setState() if:
- You’re building a small app or demo
- The state is only relevant to a single widget
- You want a quick solution without extra packages
- Managing form fields or UI toggles
Use Provider if:
- Your app needs to share state between multiple widgets
- You want a scalable, testable structure
- You care about separation of concerns
- Multiple screens need access to the same data
Related
- Flutter State Management: Provider vs Riverpod
- Why I Use Riverpod (Not Provider) in 2025
- Flutter Freezed: Immutable Data Classes and Union Types
Conclusion
Start small with setState() for simple screens and quick prototypes. As your app grows, transitioning to Provider will help you keep your code clean, maintainable, and testable. The key is understanding that setState is for local widget state, while Provider is for shared application state.
For even more advanced state management needs, consider exploring Riverpod, which builds on Provider’s concepts with better compile-time safety and more flexible dependency injection. But for most Flutter apps, Provider provides the right balance of simplicity and power.