
As your Flutter app grows in complexity, managing state becomes more important—and often more challenging. While Flutter has many state management options, Redux remains a popular and powerful choice for developers who want predictable state, centralized control, and a scalable architecture that teams can work with effectively.
In this comprehensive guide, you’ll learn the fundamentals of using Redux in Flutter, understand its core concepts, and build a complete real-world example with async actions, middleware, and proper architecture patterns.
What Is Redux in Flutter?
Redux is a state management pattern inspired by Facebook’s Flux architecture. It’s widely known in the web world, especially in React, and applies well to Flutter. The pattern provides a unidirectional data flow that makes state changes predictable and traceable.
Redux operates on three core principles:
- Single source of truth: Your app’s state lives in one centralized store
- State is read-only: You can’t modify state directly; you dispatch actions that describe what happened
- Changes through pure functions: Reducers are pure functions that take previous state and an action, returning the new state
This structure makes your app’s data flow easier to trace, debug, and test.
Understanding Redux Data Flow
// Redux data flow:
//
// ┌─────────────────────────────────────────────────────────┐
// │ STORE │
// │ (Single Source) │
// └──────────────┬────────────────────────────┬────────────┘
// │ │
// │ state │ dispatch(action)
// ▼ │
// ┌──────────────────────┐ ┌──────────┴───────────┐
// │ UI │────────▶│ ACTION │
// │ (StoreConnector) │ user │ (describes intent) │
// └──────────────────────┘ event └──────────┬───────────┘
// │
// ▼
// ┌──────────────────────┐
// │ MIDDLEWARE │
// │ (async, logging) │
// └──────────┬───────────┘
// │
// ▼
// ┌──────────────────────┐
// │ REDUCER │
// │ (pure function) │
// │ (state, action) │
// │ => newState │
// └──────────┬───────────┘
// │
// ▼
// NEW STATE
// (back to store)
Why Use Redux in Flutter?
Flutter developers choose Redux to manage complex, shared app state in a predictable way. Key benefits include:
- Centralized state – All app state in one place, no scattered state across widgets
- Predictable updates – Same action + same state = same result (pure functions)
- Time-travel debugging – Tools like Redux DevTools let you step through state changes
- Easy testing – Reducers are pure functions, easy to unit test
- Great for large teams – Clear separation of concerns, consistent patterns
- Middleware ecosystem – Handle async operations, logging, analytics cleanly
Setting Up Redux in Flutter
Step 1: Add Dependencies
# pubspec.yaml
dependencies:
flutter_redux: ^0.10.0
redux: ^5.0.0
redux_thunk: ^0.4.0 # For async actions
equatable: ^2.0.5 # For value equality
dev_dependencies:
redux_dev_tools: ^0.6.0 # Optional: for debugging
Step 2: Define App State
// lib/store/app_state.dart
import 'package:equatable/equatable.dart';
import '../features/auth/auth_state.dart';
import '../features/products/products_state.dart';
import '../features/cart/cart_state.dart';
class AppState extends Equatable {
final AuthState auth;
final ProductsState products;
final CartState cart;
const AppState({
required this.auth,
required this.products,
required this.cart,
});
factory AppState.initial() => AppState(
auth: AuthState.initial(),
products: ProductsState.initial(),
cart: CartState.initial(),
);
AppState copyWith({
AuthState? auth,
ProductsState? products,
CartState? cart,
}) => AppState(
auth: auth ?? this.auth,
products: products ?? this.products,
cart: cart ?? this.cart,
);
@override
List<Object?> get props => [auth, products, cart];
}
Step 3: Define Feature States
// lib/features/products/products_state.dart
import 'package:equatable/equatable.dart';
import 'models/product.dart';
enum ProductsStatus { initial, loading, loaded, error }
class ProductsState extends Equatable {
final List<Product> products;
final ProductsStatus status;
final String? errorMessage;
final String? selectedCategory;
final String searchQuery;
const ProductsState({
required this.products,
required this.status,
this.errorMessage,
this.selectedCategory,
this.searchQuery = '',
});
factory ProductsState.initial() => const ProductsState(
products: [],
status: ProductsStatus.initial,
);
ProductsState copyWith({
List<Product>? products,
ProductsStatus? status,
String? errorMessage,
String? selectedCategory,
String? searchQuery,
}) => ProductsState(
products: products ?? this.products,
status: status ?? this.status,
errorMessage: errorMessage,
selectedCategory: selectedCategory ?? this.selectedCategory,
searchQuery: searchQuery ?? this.searchQuery,
);
// Computed properties
List<Product> get filteredProducts {
var filtered = products;
if (selectedCategory != null) {
filtered = filtered.where((p) => p.category == selectedCategory).toList();
}
if (searchQuery.isNotEmpty) {
filtered = filtered.where((p) =>
p.name.toLowerCase().contains(searchQuery.toLowerCase())
).toList();
}
return filtered;
}
bool get isLoading => status == ProductsStatus.loading;
bool get hasError => status == ProductsStatus.error;
@override
List<Object?> get props => [
products, status, errorMessage, selectedCategory, searchQuery
];
}
Step 4: Define Actions
// lib/features/products/products_actions.dart
import 'models/product.dart';
// Synchronous actions
class LoadProductsAction {}
class LoadProductsSuccessAction {
final List<Product> products;
LoadProductsSuccessAction(this.products);
}
class LoadProductsFailureAction {
final String error;
LoadProductsFailureAction(this.error);
}
class SelectCategoryAction {
final String? category;
SelectCategoryAction(this.category);
}
class SearchProductsAction {
final String query;
SearchProductsAction(this.query);
}
class AddToCartAction {
final Product product;
final int quantity;
AddToCartAction(this.product, {this.quantity = 1});
}
class RemoveFromCartAction {
final String productId;
RemoveFromCartAction(this.productId);
}
class ClearCartAction {}
Step 5: Create Reducers
// lib/features/products/products_reducer.dart
import 'package:redux/redux.dart';
import 'products_state.dart';
import 'products_actions.dart';
final productsReducer = combineReducers<ProductsState>([
TypedReducer<ProductsState, LoadProductsAction>(_onLoadProducts),
TypedReducer<ProductsState, LoadProductsSuccessAction>(_onLoadProductsSuccess),
TypedReducer<ProductsState, LoadProductsFailureAction>(_onLoadProductsFailure),
TypedReducer<ProductsState, SelectCategoryAction>(_onSelectCategory),
TypedReducer<ProductsState, SearchProductsAction>(_onSearchProducts),
]);
ProductsState _onLoadProducts(ProductsState state, LoadProductsAction action) {
return state.copyWith(status: ProductsStatus.loading);
}
ProductsState _onLoadProductsSuccess(
ProductsState state,
LoadProductsSuccessAction action,
) {
return state.copyWith(
status: ProductsStatus.loaded,
products: action.products,
);
}
ProductsState _onLoadProductsFailure(
ProductsState state,
LoadProductsFailureAction action,
) {
return state.copyWith(
status: ProductsStatus.error,
errorMessage: action.error,
);
}
ProductsState _onSelectCategory(
ProductsState state,
SelectCategoryAction action,
) {
return state.copyWith(selectedCategory: action.category);
}
ProductsState _onSearchProducts(
ProductsState state,
SearchProductsAction action,
) {
return state.copyWith(searchQuery: action.query);
}
// lib/store/app_reducer.dart
import '../features/auth/auth_reducer.dart';
import '../features/products/products_reducer.dart';
import '../features/cart/cart_reducer.dart';
import 'app_state.dart';
AppState appReducer(AppState state, dynamic action) {
return AppState(
auth: authReducer(state.auth, action),
products: productsReducer(state.products, action),
cart: cartReducer(state.cart, action),
);
}
Step 6: Add Middleware for Async Actions
// lib/features/products/products_middleware.dart
import 'package:redux/redux.dart';
import '../../store/app_state.dart';
import '../../services/product_service.dart';
import 'products_actions.dart';
List<Middleware<AppState>> createProductsMiddleware(ProductService productService) {
return [
TypedMiddleware<AppState, LoadProductsAction>(
_createLoadProductsMiddleware(productService),
),
];
}
Middleware<AppState> _createLoadProductsMiddleware(ProductService service) {
return (Store<AppState> store, action, NextDispatcher next) async {
next(action);
try {
final products = await service.fetchProducts();
store.dispatch(LoadProductsSuccessAction(products));
} catch (error) {
store.dispatch(LoadProductsFailureAction(error.toString()));
}
};
}
// lib/middleware/logging_middleware.dart
import 'package:redux/redux.dart';
import '../store/app_state.dart';
Middleware<AppState> loggingMiddleware = (store, action, next) {
print('Action: ${action.runtimeType}');
print('State before: ${store.state}');
next(action);
print('State after: ${store.state}');
print('---');
};
// lib/middleware/analytics_middleware.dart
import 'package:redux/redux.dart';
import '../store/app_state.dart';
import '../services/analytics_service.dart';
import '../features/cart/cart_actions.dart';
Middleware<AppState> createAnalyticsMiddleware(AnalyticsService analytics) {
return (store, action, next) {
// Track specific actions
if (action is AddToCartAction) {
analytics.trackEvent('add_to_cart', {
'product_id': action.product.id,
'product_name': action.product.name,
'quantity': action.quantity,
});
}
if (action is CheckoutSuccessAction) {
analytics.trackEvent('purchase_complete', {
'order_id': action.orderId,
'total': action.total,
});
}
next(action);
};
}
Step 7: Configure the Store
// lib/store/store.dart
import 'package:redux/redux.dart';
import 'package:redux_thunk/redux_thunk.dart';
import 'app_state.dart';
import 'app_reducer.dart';
import '../features/products/products_middleware.dart';
import '../features/auth/auth_middleware.dart';
import '../middleware/logging_middleware.dart';
import '../middleware/analytics_middleware.dart';
import '../services/service_locator.dart';
Store<AppState> createStore() {
final productService = sl<ProductService>();
final authService = sl<AuthService>();
final analyticsService = sl<AnalyticsService>();
return Store<AppState>(
appReducer,
initialState: AppState.initial(),
middleware: [
thunkMiddleware,
...createProductsMiddleware(productService),
...createAuthMiddleware(authService),
createAnalyticsMiddleware(analyticsService),
// Only in debug mode
if (kDebugMode) loggingMiddleware,
],
);
}
Step 8: Initialize in main.dart
// lib/main.dart
import 'package:flutter/material.dart';
import 'package:flutter_redux/flutter_redux.dart';
import 'store/store.dart';
import 'store/app_state.dart';
import 'app.dart';
import 'services/service_locator.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// Setup dependency injection
await setupServiceLocator();
// Create Redux store
final store = createStore();
runApp(
StoreProvider<AppState>(
store: store,
child: const MyApp(),
),
);
}
Connecting UI to Redux
Using StoreConnector
// lib/features/products/presentation/pages/products_page.dart
import 'package:flutter/material.dart';
import 'package:flutter_redux/flutter_redux.dart';
import '../../../../store/app_state.dart';
import '../../products_state.dart';
import '../../products_actions.dart';
import '../widgets/product_card.dart';
import '../widgets/category_filter.dart';
import '../widgets/search_bar.dart';
class ProductsPage extends StatelessWidget {
const ProductsPage({super.key});
@override
Widget build(BuildContext context) {
return StoreConnector<AppState, _ProductsViewModel>(
onInit: (store) => store.dispatch(LoadProductsAction()),
converter: _ProductsViewModel.fromStore,
builder: (context, vm) {
return Scaffold(
appBar: AppBar(
title: const Text('Products'),
bottom: PreferredSize(
preferredSize: const Size.fromHeight(60),
child: Padding(
padding: const EdgeInsets.all(8),
child: ProductSearchBar(
query: vm.searchQuery,
onChanged: vm.onSearch,
),
),
),
),
body: _buildBody(vm),
);
},
);
}
Widget _buildBody(_ProductsViewModel vm) {
if (vm.isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (vm.hasError) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(vm.errorMessage ?? 'An error occurred'),
const SizedBox(height: 16),
ElevatedButton(
onPressed: vm.onRetry,
child: const Text('Retry'),
),
],
),
);
}
return Column(
children: [
CategoryFilter(
selectedCategory: vm.selectedCategory,
onCategorySelected: vm.onCategorySelected,
),
Expanded(
child: GridView.builder(
padding: const EdgeInsets.all(16),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
childAspectRatio: 0.7,
crossAxisSpacing: 16,
mainAxisSpacing: 16,
),
itemCount: vm.products.length,
itemBuilder: (context, index) {
final product = vm.products[index];
return ProductCard(
product: product,
onAddToCart: () => vm.onAddToCart(product),
);
},
),
),
],
);
}
}
// ViewModel pattern for clean separation
class _ProductsViewModel {
final List<Product> products;
final bool isLoading;
final bool hasError;
final String? errorMessage;
final String? selectedCategory;
final String searchQuery;
final void Function() onRetry;
final void Function(String) onSearch;
final void Function(String?) onCategorySelected;
final void Function(Product) onAddToCart;
_ProductsViewModel({
required this.products,
required this.isLoading,
required this.hasError,
this.errorMessage,
this.selectedCategory,
required this.searchQuery,
required this.onRetry,
required this.onSearch,
required this.onCategorySelected,
required this.onAddToCart,
});
static _ProductsViewModel fromStore(Store<AppState> store) {
final state = store.state.products;
return _ProductsViewModel(
products: state.filteredProducts,
isLoading: state.isLoading,
hasError: state.hasError,
errorMessage: state.errorMessage,
selectedCategory: state.selectedCategory,
searchQuery: state.searchQuery,
onRetry: () => store.dispatch(LoadProductsAction()),
onSearch: (query) => store.dispatch(SearchProductsAction(query)),
onCategorySelected: (cat) => store.dispatch(SelectCategoryAction(cat)),
onAddToCart: (product) => store.dispatch(AddToCartAction(product)),
);
}
}
Using StoreBuilder for Simple Cases
// For simpler widgets that just need the store
class CartBadge extends StatelessWidget {
@override
Widget build(BuildContext context) {
return StoreConnector<AppState, int>(
converter: (store) => store.state.cart.itemCount,
builder: (context, itemCount) {
if (itemCount == 0) return const SizedBox.shrink();
return Badge(
label: Text('$itemCount'),
child: const Icon(Icons.shopping_cart),
);
},
);
}
}
Handling Async Actions with Thunks
// lib/features/auth/auth_thunks.dart
import 'package:redux/redux.dart';
import 'package:redux_thunk/redux_thunk.dart';
import '../../store/app_state.dart';
import '../../services/auth_service.dart';
import 'auth_actions.dart';
// Thunk action for login
ThunkAction<AppState> login(String email, String password) {
return (Store<AppState> store) async {
store.dispatch(LoginRequestAction());
try {
final authService = sl<AuthService>();
final user = await authService.login(email, password);
store.dispatch(LoginSuccessAction(user));
} catch (error) {
store.dispatch(LoginFailureAction(error.toString()));
}
};
}
// Thunk action for fetching user profile
ThunkAction<AppState> fetchUserProfile() {
return (Store<AppState> store) async {
final userId = store.state.auth.user?.id;
if (userId == null) return;
store.dispatch(FetchProfileRequestAction());
try {
final userService = sl<UserService>();
final profile = await userService.getProfile(userId);
store.dispatch(FetchProfileSuccessAction(profile));
} catch (error) {
store.dispatch(FetchProfileFailureAction(error.toString()));
}
};
}
// Usage in UI:
// store.dispatch(login('user@example.com', 'password123'));
Testing Redux Components
// test/features/products/products_reducer_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:your_app/features/products/products_reducer.dart';
import 'package:your_app/features/products/products_state.dart';
import 'package:your_app/features/products/products_actions.dart';
import 'package:your_app/features/products/models/product.dart';
void main() {
group('ProductsReducer', () {
test('returns initial state', () {
final state = ProductsState.initial();
expect(state.products, isEmpty);
expect(state.status, ProductsStatus.initial);
});
test('LoadProductsAction sets loading state', () {
final state = ProductsState.initial();
final newState = productsReducer(state, LoadProductsAction());
expect(newState.status, ProductsStatus.loading);
});
test('LoadProductsSuccessAction updates products', () {
final state = ProductsState.initial().copyWith(
status: ProductsStatus.loading,
);
final products = [
Product(id: '1', name: 'Test', price: 9.99, category: 'test'),
];
final newState = productsReducer(
state,
LoadProductsSuccessAction(products),
);
expect(newState.status, ProductsStatus.loaded);
expect(newState.products, products);
});
test('filteredProducts filters by category', () {
final products = [
Product(id: '1', name: 'Shirt', price: 29.99, category: 'clothing'),
Product(id: '2', name: 'Phone', price: 699.99, category: 'electronics'),
];
final state = ProductsState.initial().copyWith(
products: products,
selectedCategory: 'clothing',
);
expect(state.filteredProducts.length, 1);
expect(state.filteredProducts.first.name, 'Shirt');
});
});
}
Common Mistakes to Avoid
- Mutating state directly – Always return a new state object; never modify the existing one
- Putting UI logic in reducers – Reducers should be pure functions with no side effects
- Too fine-grained actions – Group related changes into single actions when they always happen together
- Not using selectors – Create computed properties in state or separate selector functions
- Forgetting to unsubscribe – StoreConnector handles this, but be careful with manual subscriptions
- Not normalizing state – For complex data, normalize into maps keyed by ID
- Async logic in reducers – Use middleware or thunks for async operations
When to Use Redux
| Scenario | Redux? | Alternative |
|---|---|---|
| Small app (few screens) | Overkill | Provider, Riverpod |
| Complex shared state | Good fit | – |
| Large team project | Good fit | BLoC |
| Need time-travel debug | Great fit | – |
| React developers on team | Great fit | – |
| Simple forms/local state | Overkill | StatefulWidget |
Conclusion
Redux provides a reliable and scalable solution for managing state in Flutter apps. While it introduces more structure and boilerplate than simpler solutions, the long-term benefits in larger apps are significant. With predictable data flow, centralized control, and excellent debugging tools, Redux makes complex state management tractable.
Key takeaways:
- Single source of truth makes state predictable
- Actions describe intent, reducers implement changes
- Middleware handles side effects like async operations
- ViewModel pattern keeps UI components clean
- Pure reducers are easy to test
If you’re looking for consistency and control in your state management, Redux is a solid choice. Want to explore alternatives? Read our comparison of Flutter State Management Packages or learn about Clean Architecture with BLoC.
For official documentation, check out the flutter_redux package and the Redux core concepts guide.