Choosing a State Management Approach in Flutter
Choosing the right state management approach is an important part of building maintainable, scalable, and responsive Flutter applications. Flutter provides several built-in ways to manage state, while the Flutter ecosystem also offers packages such as Provider, Riverpod, and Bloc.
There is no single state management solution that is best for every application. The appropriate choice depends on the application's size, complexity, type of state, team experience, architecture, testing requirements, and long-term maintenance needs.
1. What Is State Management?
State management is the process of storing, updating, sharing, and reacting to application data so that the user interface always represents the current state of the application.
Examples of state include:
- Login status
- Shopping cart items
- Selected products
- Form input values
- Theme preference
- Loading status
- API response data
- Error messages
- Selected navigation tab
- User profile information
Basic State Flow
User Action
↓
State Changes
↓
State Management Layer
↓
UI Rebuilds
↓
Updated Screen
2. Why Choosing the Right Approach Matters
A small Flutter application can often manage state with setState(). As an application grows, however, state may need to be shared between multiple screens and separated from UI code.
A suitable state management approach can help with:
- Sharing state between widgets
- Separating business logic from UI
- Handling asynchronous operations
- Reducing unnecessary widget rebuilds
- Testing application logic
- Managing dependencies
- Improving application architecture
- Making large applications easier to maintain
3. First Question: What Type of State Are You Managing?
Before selecting a state management solution, identify the type and scope of the state.
Ephemeral or Local State
Ephemeral state is usually temporary state that belongs to a particular widget or small section of the UI.
Examples:
- Whether a password field is visible
- Current animation progress
- Selected tab inside a small widget
- Whether a button is expanded
- Temporary checkbox selection
This type of state can often be handled with StatefulWidget and setState().
Application or Shared State
Application state is state that needs to be accessed by multiple widgets, screens, or parts of an application.
Examples:
- Authenticated user
- Shopping cart
- Application theme
- Language preference
- Notifications
- Product catalog
- API data
4. Start With the Simplest Approach
A useful principle is to avoid introducing a complex state management package before the application actually needs it.
If state belongs to one widget, setState() may be enough.
If several widgets need the same state, consider lifting the state to a common parent or using an appropriate shared-state solution.
As an application grows, state can be refactored into a more structured architecture.
5. Option 1: setState()
setState() is Flutter's basic mechanism for updating widget-specific state.
Example
class CounterPage extends StatefulWidget {
const CounterPage({super.key});
@override
State createState() => _CounterPageState();
}
class _CounterPageState extends State {
int counter = 0;
void increment() {
setState(() {
counter++;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Counter'),
),
body: Center(
child: Text(
'$counter',
style: const TextStyle(fontSize: 32),
),
),
floatingActionButton: FloatingActionButton(
onPressed: increment,
child: const Icon(Icons.add),
),
);
}
}
Advantages
- Built into Flutter
- Easy to learn
- Very little boilerplate
- Excellent for local widget state
Limitations
- Not ideal for complex shared state
- Business logic can become mixed with UI code
- Sharing state across distant screens becomes difficult
- Large widgets can become difficult to maintain
6. Option 2: Lifting State Up
When two or more widgets need the same state, the state can be moved to a common parent.
Parent Widget
|
+-- Child A
|
+-- Child B
The parent owns the state and passes the required data or callbacks to its children.
Example
class ParentWidget extends StatefulWidget {
const ParentWidget({super.key});
@override
State createState() => _ParentWidgetState();
}
class _ParentWidgetState extends State {
int count = 0;
@override
Widget build(BuildContext context) {
return Column(
children: [
Text('Count: $count'),
ElevatedButton(
onPressed: () {
setState(() {
count++;
});
},
child: const Text('Increment'),
),
],
);
}
}
When to Use It
- State is shared by nearby widgets
- The widget tree is relatively small
- Business logic is simple
- A package would add unnecessary complexity
7. Option 3: ValueNotifier
ValueNotifier is useful when a small value needs to notify listeners when it changes.
Example
final ValueNotifier counter = ValueNotifier(0);
ValueListenableBuilder(
valueListenable: counter,
builder: (context, value, child) {
return Text('Count: $value');
},
);
It can be useful for small, focused pieces of reactive state without introducing a third-party package.
8. Option 4: InheritedWidget
InheritedWidget is a Flutter mechanism for efficiently passing data down the widget tree.
It is a lower-level approach and can be useful for understanding how several state management solutions work internally.
However, manually building a complete application architecture around InheritedWidget can require more boilerplate than higher-level solutions.
9. Option 5: Provider
Provider is a popular Flutter package that simplifies access to shared objects and state. It builds on Flutter's widget-tree mechanisms and is commonly used with ChangeNotifier.
Common Provider Components
Provider
ChangeNotifierProvider
Consumer
Selector
MultiProvider
context.read()
context.watch()
context.select()
Example ChangeNotifier
class CounterModel extends ChangeNotifier {
int count = 0;
void increment() {
count++;
notifyListeners();
}
}
Providing the State
void main() {
runApp(
ChangeNotifierProvider(
create: (_) => CounterModel(),
child: const MyApp(),
),
);
}
Reading the State
class CounterScreen extends StatelessWidget {
const CounterScreen({super.key});
@override
Widget build(BuildContext context) {
final counter = context.watch();
return Text(
'Count: ${counter.count}',
);
}
}
Provider Advantages
- Easy to learn
- Works naturally with Flutter widgets
- Useful for dependency injection
- Can separate state from UI
- Supports selective listening and rebuild optimization
- Suitable for many small and medium applications
Provider Considerations
- Large applications may require additional architectural conventions
- Incorrect provider placement can cause lookup errors
- Complex ChangeNotifier classes can become difficult to maintain
- Developers need to understand listening and non-listening access patterns
10. Option 6: Riverpod
Riverpod is a provider-based state management and dependency management solution for Dart and Flutter.
It provides mechanisms for declaring state and dependencies separately from the widget tree and includes support for synchronous and asynchronous state.
Common Riverpod Concepts
ProviderScope
Provider
ConsumerWidget
Consumer
ref.watch()
ref.read()
NotifierProvider
FutureProvider
StreamProvider
AsyncValue
Basic Example
final counterProvider = StateProvider((ref) => 0);
ProviderScope
void main() {
runApp(
const ProviderScope(
child: MyApp(),
),
);
}
Using ConsumerWidget
class CounterScreen extends ConsumerWidget {
const CounterScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final count = ref.watch(counterProvider);
return Scaffold(
body: Center(
child: Text('Count: $count'),
),
floatingActionButton: FloatingActionButton(
onPressed: () {
ref.read(counterProvider.notifier).state++;
},
child: const Icon(Icons.add),
),
);
}
}
Riverpod Advantages
- Good support for dependency management
- Supports asynchronous state
- Provides different provider types for different use cases
- Supports testing and provider overrides
- Can help organize complex application state
- Provides tools for controlling dependencies and rebuilds
Riverpod Considerations
- Has more concepts to learn than simple
setState()
- Beginners need time to understand providers and references
- Teams should establish consistent conventions for provider organization
11. Option 7: Bloc and Cubit
Bloc is a structured state management approach commonly used when applications require clear event and state flows.
Cubit provides a simpler API where methods can directly emit new states.
Conceptual Flow
User Action
↓
Event or Cubit Method
↓
Business Logic
↓
New State
↓
UI Rebuild
Simple Cubit Example
class CounterCubit extends Cubit {
CounterCubit() : super(0);
void increment() {
emit(state + 1);
}
}
When Bloc/Cubit Can Be Useful
- Large applications
- Complex business logic
- Applications with many state transitions
- Teams that prefer explicit state flows
- Projects requiring structured event-driven architecture
12. Comparing Common Approaches
| Approach |
Best Suited For |
Complexity |
Third-Party Package |
| setState() |
Local widget state |
Low |
No |
| ValueNotifier |
Small reactive state |
Low |
No |
| InheritedWidget |
Low-level shared state |
Medium |
No |
| Provider |
Simple to medium shared application state |
Low to Medium |
Yes |
| Riverpod |
Shared, reactive, asynchronous, and dependency-based state |
Medium |
Yes |
| Bloc/Cubit |
Structured and complex application state |
Medium to High |
Yes |
13. Decision Tree for Choosing State Management
Do you have state?
|
v
Is it used by one widget?
|
Yes
|
v
Use setState()
No
|
v
Is it shared by nearby widgets?
|
Yes
|
v
Lift state to parent
or use a simple notifier
No
|
v
Does the application require shared
application-level state?
|
Yes
|
v
Consider Provider, Riverpod,
Bloc/Cubit, or another solution
|
v
Is the application highly complex?
|
Yes
|
v
Choose an approach that provides
clear architecture, testability,
and predictable state flow
14. Choosing Based on Application Size
Small Application
For a small application, starting with setState() may be appropriate when most state is local.
Example applications:
- Counter application
- Simple calculator
- Small form
- Single-screen utility
Medium Application
For a medium application, shared state may benefit from Provider or Riverpod.
Examples:
- Shopping application
- News application
- Task management application
- Booking application
Large Application
Large applications usually need clearly separated state, business logic, dependencies, repositories, and UI layers. Provider, Riverpod, Bloc/Cubit, or another structured solution can be considered depending on project requirements and team conventions.
15. Choosing Based on State Scope
| State Scope |
Possible Approach |
| One widget |
setState() |
| Small widget subtree |
Parent state, ValueNotifier, InheritedWidget, Provider |
| Multiple screens |
Provider, Riverpod, Bloc/Cubit |
| Application-wide state |
Provider, Riverpod, Bloc/Cubit |
| Complex asynchronous state |
Riverpod, Bloc/Cubit, or another structured solution |
16. Choosing Based on Team Experience
The team's experience is an important factor when selecting a state management approach.
Beginner Team
- Start with Flutter's built-in state management.
- Learn
StatefulWidget and setState().
- Understand widget rebuilding.
- Learn how state flows through the widget tree.
- Then introduce a package when the application requires it.
Experienced Team
- Evaluate existing team knowledge.
- Consider the application's architecture.
- Define conventions before creating many state objects.
- Choose an approach that the team can test and maintain consistently.
17. Choosing Based on Business Logic Complexity
If an application has very simple state, a simple approach is usually easier to maintain.
If business rules become complex, separating business logic from widgets becomes increasingly important.
Simple Example
bool isDarkMode = false;
This can often be handled with simple state management.
Complex Example
Load Products
↓
Check Authentication
↓
Fetch API Data
↓
Apply Filters
↓
Apply Sorting
↓
Handle Pagination
↓
Handle Loading/Error/Success
↓
Update UI
For this type of flow, a structured state management solution can make the application easier to organize.
18. Handling Loading, Success, and Error States
Applications that communicate with APIs or Firebase often need more than a single data value.
A useful state model can represent:
- Initial state
- Loading state
- Success state
- Error state
- Empty state
Example
enum RequestStatus {
initial,
loading,
success,
error,
}
UI Flow
Initial
↓
Loading
↓
Success
or
Loading
↓
Error
19. Avoid Multiple Unrelated Boolean States
A common mistake is using several Boolean variables to represent one state.
Problematic Example
bool isLoading = false;
bool hasError = false;
bool hasData = false;
This can create invalid combinations such as loading and error both being true.
Better Concept
enum Status {
initial,
loading,
success,
error,
}
A single state representation can make the application's state transitions easier to understand.
20. Choosing Based on Async Operations
If your application frequently performs asynchronous operations, consider how the chosen solution represents loading, success, and error states.
Common asynchronous operations include:
- REST API requests
- Firebase operations
- Database queries
- File uploads
- Authentication
- Streams
Riverpod provides asynchronous provider patterns such as FutureProvider, StreamProvider, and AsyncValue. Other solutions provide their own ways of modeling asynchronous state.
21. Choosing Based on Testing Requirements
State management becomes especially important when application logic needs automated testing.
A good architecture should allow business logic to be tested without requiring the entire UI.
Example Structure
UI
↓
ViewModel / State Controller
↓
Repository
↓
API / Database
This separation allows the state layer and repository layer to be tested independently.
22. Choosing Based on Rebuild Performance
Flutter rebuilds widgets when their relevant state changes. A good state management solution should make it possible to control which widgets listen to which state.
For example:
Application State
|
+---- Header
|
+---- Product List
|
+---- Cart
|
+---- Footer
If only the cart changes, an efficient architecture should avoid rebuilding unrelated parts of the UI whenever possible.
Optimization Techniques
- Keep state close to where it is needed.
- Avoid unnecessarily global state.
- Listen only to required values.
- Split large widgets into smaller widgets.
- Use selectors or equivalent mechanisms when appropriate.
- Keep expensive calculations outside frequently rebuilt widgets.
23. Avoid Making Everything Global
Global state is not automatically better than local state.
For example, an animation controller generally does not need to become application-wide state.
A useful question is:
Who actually needs this state?
If only one widget needs it, keep it local. If several unrelated areas need it, consider shared state.
24. State Management and Architecture
State management should work together with application architecture rather than becoming a replacement for architecture.
Example Architecture
lib/
├── models/
├── views/
├── viewmodels/
├── repositories/
├── services/
├── providers/
└── main.dart
Responsibilities
| Layer |
Responsibility |
| View |
Displays UI and handles simple UI interactions |
| ViewModel / Controller |
Manages UI state and application logic |
| Repository |
Provides application data |
| Service |
Communicates with external systems |
| Model |
Represents application data |
25. Repository Pattern With State Management
A repository can act as the source of application data while the state management layer manages how that data is presented to the UI.
UI
↓
State Controller / ViewModel
↓
Repository
↓
API / Firebase / Local Database
Example Repository
class ProductRepository {
Future> getProducts() async {
// Fetch products from an API
return [];
}
}
State Layer
class ProductViewModel extends ChangeNotifier {
final ProductRepository repository;
ProductViewModel(this.repository);
bool isLoading = false;
List products = [];
Future loadProducts() async {
isLoading = true;
notifyListeners();
products = await repository.getProducts();
isLoading = false;
notifyListeners();
}
}
26. Choosing Between Provider and Riverpod
| Factor |
Provider |
Riverpod |
| Learning Curve |
Generally simple to start |
More concepts to learn |
| Flutter Integration |
Strong |
Strong |
| Dependency Management |
Supported |
Core capability |
| Async State |
Requires appropriate patterns |
Dedicated provider patterns available |
| Testing |
Supported |
Strong provider override/testing capabilities |
| Small Projects |
Suitable |
Suitable |
| Larger Projects |
Suitable with good architecture |
Suitable with good architecture |
The choice should be based on project requirements and team preferences rather than selecting a package only because it is popular.
27. Choosing Between Riverpod and Bloc/Cubit
| Factor |
Riverpod |
Bloc/Cubit |
| Core Idea |
Provider-based state and dependency management |
Structured state transitions |
| Async State |
Strong support |
Strong support |
| Learning |
Requires understanding providers and references |
Requires understanding Cubits, events, and states |
| Architecture |
Flexible |
Highly structured |
| Best Fit |
Reactive state and dependency-driven applications |
Applications needing explicit state flows |
28. Common Mistakes When Choosing State Management
Mistake 1: Choosing Based Only on Popularity
A popular package is not automatically the correct solution for every project.
Mistake 2: Using Complex State Management for Tiny Features
Adding a large architecture for a simple counter can create unnecessary complexity.
Mistake 3: Keeping Everything in setState()
As an application grows, putting all business logic inside widgets can make the code difficult to maintain.
Mistake 4: Making Everything Global
Global state can make dependencies harder to understand and can increase coupling.
Mistake 5: Mixing Multiple Patterns Without a Reason
Using different state management packages for every screen can make an application difficult for a team to understand.
Mistake 6: Ignoring Testing
A state management architecture should make important business logic testable.
Mistake 7: Ignoring Team Knowledge
A theoretically powerful solution can still be difficult to maintain if the development team does not understand it.
29. A Practical Selection Checklist
Before choosing a state management approach, ask the following questions:
- How large is the application?
- Is the state local or shared?
- How many screens need the state?
- Is the state synchronous or asynchronous?
- How complex is the business logic?
- Does the application communicate with APIs or Firebase?
- How important is automated testing?
- Does the application require dependency injection?
- How much control over rebuilds is required?
- What does the development team already know?
- Will the project grow significantly in the future?
- Which approach fits the team's architecture conventions?
30. Example: Choosing State Management for a Shopping App
Imagine a shopping application with the following features:
- Product listing
- Product search
- Product filters
- User authentication
- Shopping cart
- Wishlist
- Orders
- Payment status
Different state can have different scopes.
| Feature |
Possible State Scope |
| Search text |
Local or feature-level state |
| Product list |
Shared application state |
| Cart |
Shared application state |
| Authentication |
Application-level state |
| Payment status |
Feature/application state |
| Animation |
Local widget state |
The application could combine local state with a structured state management solution rather than forcing every state into one global store.
31. Example: Choosing State Management for a News App
A news application may have:
- Articles
- Categories
- Search
- Bookmarks
- Read/unread status
- Authentication
- Network loading state
API-related state could be managed using a structured asynchronous state approach, while small UI states such as selected tabs can remain local.
32. Example: Choosing State Management for a Simple Form
A simple login form may not require a large state management package.
class LoginPage extends StatefulWidget {
const LoginPage({super.key});
@override
State createState() => _LoginPageState();
}
class _LoginPageState extends State {
bool isLoading = false;
Future login() async {
setState(() {
isLoading = true;
});
await Future.delayed(const Duration(seconds: 2));
setState(() {
isLoading = false;
});
}
@override
Widget build(BuildContext context) {
return ElevatedButton(
onPressed: isLoading ? null : login,
child: Text(
isLoading ? 'Logging in...' : 'Login',
),
);
}
}
For a simple screen, this can be sufficient. If authentication state must be shared across many screens, a dedicated application-level state solution may become useful.
33. Combining Multiple State Management Techniques
You do not necessarily have to use one technique for every piece of state.
Example
Application
│
├── Authentication → Riverpod / Provider / Bloc
├── Cart → Riverpod / Provider / Bloc
├── Products → Riverpod / Provider / Bloc
│
└── Product Details
├── Selected Image → setState()
├── Animation → local state
└── Quantity → local or shared state
The important principle is to keep each state at an appropriate scope.
34. State Management With Firebase
Firebase-based applications commonly need to manage asynchronous data and authentication state.
Examples include:
- Authentication status
- Firestore documents
- Firestore collections
- Realtime updates
- File upload progress
- Cloud Storage results
- Loading and error states
A structured state management approach can help keep Firebase logic outside the UI layer.
35. State Management With REST APIs
For API-driven applications, a typical flow is:
UI
↓
State Controller
↓
Repository
↓
API Service
↓
HTTP Request
↓
Response
↓
Repository
↓
State Controller
↓
UI Update
This structure keeps network communication separate from the presentation layer.
36. Performance Considerations
State management can affect application performance because state changes may trigger widget rebuilds.
Good Practices
- Keep state as local as practical.
- Do not make large widget trees listen to frequently changing state unnecessarily.
- Split complex screens into smaller widgets.
- Use selective listening where supported.
- Avoid expensive calculations during every build.
- Use immutable state where appropriate.
- Dispose resources correctly.
37. Maintainability Considerations
Maintainable state management should make it easy to answer:
- Where does this state live?
- Who can change it?
- Who listens to it?
- What causes the state to change?
- What happens when an API request fails?
- How can this behavior be tested?
If these questions are difficult to answer, the architecture may need simplification or restructuring.
38. Recommended Learning Path
- Understand Flutter's declarative UI model.
- Learn
StatefulWidget.
- Learn
setState().
- Understand local versus application state.
- Learn how to lift state to a parent.
- Learn
ValueNotifier and InheritedWidget concepts.
- Learn Provider and ChangeNotifier.
- Learn Riverpod or Bloc/Cubit.
- Learn asynchronous state management.
- Learn repositories and separation of concerns.
- Practice testing state logic.
- Build a real-world project.
39. Recommended Approach by Scenario
| Scenario |
Possible Choice |
| Simple counter |
setState() |
| Widget-specific UI state |
setState() |
| Small reactive value |
ValueNotifier |
| Shared state in a small/medium application |
Provider |
| Dependency-driven application |
Riverpod |
| Complex asynchronous state |
Riverpod or Bloc/Cubit |
| Explicit event/state architecture |
Bloc |
| Simpler Bloc-style architecture |
Cubit |
| Highly local state |
Keep it local |
These are practical starting points rather than strict rules. Flutter's documentation emphasizes that there is no universal rule for classifying or managing every kind of state.
40. Important Principle: Choose Based on the Problem
Do not start with the question:
"Which state management package should I use?"
Start with:
"What state does my application have, who needs it, and how complex is its lifecycle?"
Then select the simplest approach that satisfies those requirements.
41. Practical Project Structure
lib/
├── main.dart
├── models/
│ ├── user.dart
│ └── product.dart
├── views/
│ ├── login_screen.dart
│ ├── home_screen.dart
│ └── product_screen.dart
├── viewmodels/
│ ├── auth_viewmodel.dart
│ └── product_viewmodel.dart
├── repositories/
│ ├── auth_repository.dart
│ └── product_repository.dart
├── services/
│ ├── api_service.dart
│ └── firebase_service.dart
└── providers/
└── app_providers.dart
The exact folder structure can vary depending on the project and chosen architecture. The important goal is clear separation of responsibilities.
42. Interview Questions
Q1. What is state management in Flutter?
State management is the process of managing application data and ensuring that the UI reflects the current state.
Q2. When should you use setState()?
It is commonly appropriate for local, widget-specific, ephemeral state.
Q3. What is the difference between local and application state?
Local state is generally limited to a widget or small widget subtree, while application state may need to be shared across multiple parts of an application.
Q4. What is Provider?
Provider is a Flutter package that simplifies exposing and consuming values and objects through the widget tree.
Q5. What is Riverpod?
Riverpod is a provider-based state and dependency management solution for Dart and Flutter.
Q6. What is Bloc?
Bloc is a structured state management pattern that commonly represents application behavior through events and states.
Q7. What is Cubit?
Cubit is a simpler Bloc-family approach in which methods can directly emit new states.
Q8. Should every state be global?
No. State should generally be kept as close as practical to the widgets or features that need it.
Q9. How do you choose a state management solution?
Consider application size, state scope, complexity, asynchronous operations, architecture, testing, performance requirements, and team experience.
43. Quick Revision Table
| Concept |
Key Point |
| setState() |
Simple local widget state |
| ValueNotifier |
Small reactive state |
| InheritedWidget |
Low-level widget-tree state sharing |
| Provider |
Simple and flexible shared state and dependency access |
| Riverpod |
Provider-based state and dependency management |
| Bloc |
Event/state-oriented architecture |
| Cubit |
Simpler direct state emission |
| Local State |
State required by a small part of the UI |
| App State |
State shared across application features |
| Repository |
Provides application data |
44. Best Practices
- Start simple.
- Keep local state local when possible.
- Do not introduce a package without a reason.
- Separate UI from business logic as complexity grows.
- Keep API and database operations outside UI widgets.
- Model loading, success, error, and empty states clearly.
- Use immutable state where it improves predictability.
- Avoid unnecessary global state.
- Use selective listening to reduce unnecessary rebuilds.
- Choose one primary approach for shared state in a project when practical.
- Follow consistent team conventions.
- Write tests for important state and business logic.
- Choose architecture based on actual application requirements.
45. Learning Outcome
After studying this topic, you should be able to:
- Explain what state management means in Flutter.
- Differentiate local and application state.
- Use
setState() for simple local state.
- Understand when state should be lifted to a parent.
- Understand ValueNotifier and InheritedWidget concepts.
- Explain Provider and ChangeNotifier.
- Explain Riverpod and its provider-based architecture.
- Understand Bloc and Cubit at a conceptual level.
- Compare common state management approaches.
- Select an approach based on application requirements.
- Separate UI, state logic, repositories, and services.
- Handle asynchronous loading, success, and error states.
- Consider performance, testing, scalability, and maintainability.
46. Useful Flutter Resources
47. JustAcademy Flutter Resources
Learn more about Flutter development and practical Flutter training through the following resources:
48. Summary
Choosing a state management approach in Flutter is not about finding one universally correct package. The right approach depends on the scope of state, application complexity, asynchronous requirements, architecture, performance needs, testing requirements, and team experience.
For simple local state, setState() may be sufficient. For shared state, solutions such as Provider, Riverpod, or Bloc/Cubit can provide additional structure. As applications become larger, separating UI, state logic, repositories, and services can improve maintainability.
The most important principle is to choose the simplest approach that solves the actual problem while keeping state predictable, testable, maintainable, and easy for the development team to understand.