
Why Mobile Devs Still Struggle With State Management (And How to Fix It)

Overview
State management remains one of the most challenging aspects of mobile application development, regardless of framework choice. From Flutter to React Native to .NET MAUI, developers continue to grapple with managing increasingly complex application state while maintaining code clarity and performance. This persistent challenge isn't merely a matter of technical implementation—it reflects the fundamental complexity of modern mobile applications that must handle offline functionality, synchronize with remote services, manage user interface state across multiple screens, and respond gracefully to interruptions in the mobile environment. As applications grow in complexity, state management approaches that worked for simpler use cases often buckle under the weight of real-world requirements.
The consequences of poor state management extend far beyond code maintainability. Users experience unexplained UI inconsistencies, data loss during navigation, unexpected app behavior after background resumption, and sluggish responsiveness as inefficient state updates trigger unnecessary rendering. These issues directly impact user satisfaction and retention, transforming what might seem like an abstract architectural concern into a business-critical consideration. This article explores why state management remains challenging even for experienced mobile developers, examines how different frameworks approach these challenges, and offers practical patterns that can be applied regardless of your technology stack. By understanding both the common pitfalls and effective solutions across frameworks, developers can implement state management architectures that support rather than hinder complex application development.
Understanding State Complexity
Before diving into framework-specific approaches, it's essential to understand the different types of state that mobile applications must manage. These distinct categories of state have different characteristics, lifecycles, and management requirements.
The State Classification Problem
Mobile applications typically deal with at least four fundamentally different categories of state:
UI State: Ephemeral state that affects only the presentation layer, such as scroll positions, animation progress, expanded/collapsed sections, and form input values before submission. This state is typically localized to specific components and has a short lifecycle tied to component visibility.
Application State: Persistent data that represents the current condition of the application, such as selected tabs, user preferences, authentication status, and feature flags. This state often needs to survive navigation between screens and sometimes even application restarts.
Domain State: The core business data of the application, such as user profiles, product listings, or transaction records. This state often originates from remote sources but requires local caching for offline functionality and performance.
Session State: Contextual information about the current user session, including network connection status, device capabilities, and runtime permissions. This state often affects how the application behaves across multiple features.
The boundaries between these categories frequently blur in real-world applications, leading to confusion about where and how specific pieces of state should be managed. For example, should a shopping cart be considered application state or domain state? The answer might depend on specific business requirements and persistence needs. This classification ambiguity often results in inconsistent state management approaches within the same application, creating maintenance challenges as the codebase evolves.
Common State Management Pitfalls
Regardless of framework, mobile developers consistently encounter several common pitfalls:
State Duplication: Storing the same information in multiple places, leading to synchronization issues when one copy updates but others don't. This frequently occurs when passing data between screens or when storing both remote and cached versions of data.
Inappropriate Scope: Managing state at the wrong level of the component hierarchy—either too locally, requiring complex prop/parameter drilling to share information, or too globally, creating unnecessary coupling between unrelated components.
Lifecycle Mismanagement: Failing to properly handle state during application lifecycle events such as backgrounding, foregrounding, or during configuration changes like device rotation.
Inefficient Updates: Triggering excessive rerenders by not properly isolating state changes, leading to performance degradation as the application grows in complexity.
Inconsistent Patterns: Using different state management approaches for similar problems throughout the application, increasing the cognitive load for developers who must context-switch between paradigms.
Testing Difficulties: Creating state management implementations that are tightly coupled to framework specifics, making them difficult to test in isolation without complex mocking.
These challenges are fundamental to state management regardless of framework, though different frameworks provide different tools and patterns to address them. Understanding these common pitfalls provides context for evaluating the various approaches offered by Flutter, React Native, and .NET MAUI.
Framework-Specific Approaches
Flutter State Management
Flutter's widget-based architecture and reactive programming model have led to a diverse ecosystem of state management solutions. The framework's approach to rebuilding UI in response to state changes shapes how Flutter developers think about state management.
Built-in State Management
Flutter provides several built-in mechanisms for handling state:
- StatefulWidget: The most basic approach, where a widget maintains its own state internally:
class CounterWidget extends StatefulWidget {
@override
_CounterWidgetState createState() => _CounterWidgetState();
}
class _CounterWidgetState extends State<CounterWidget> {
int _counter = 0;
void _incrementCounter() {
setState(() {
_counter++;
});
}
@override
Widget build(BuildContext context) {
return Column(
children: [
Text('Count: $_counter'),
ElevatedButton(
onPressed: _incrementCounter,
child: Text('Increment'),
),
],
);
}
}
- InheritedWidget: A mechanism for efficiently propagating information down the widget tree:
class CounterProvider extends InheritedWidget {
final int counter;
final Function() increment;
CounterProvider({
required this.counter,
required this.increment,
required Widget child,
}) : super(child: child);
static CounterProvider of(BuildContext context) {
return context.dependOnInheritedWidgetOfExactType<CounterProvider>()!;
}
@override
bool updateShouldNotify(CounterProvider oldWidget) {
return counter != oldWidget.counter;
}
}
While these built-in approaches work for simple cases, they become unwieldy as applications grow in complexity. StatefulWidget leads to tight coupling between UI and state logic, while InheritedWidget requires verbose boilerplate code.
Popular Flutter State Management Solutions
Several third-party solutions have emerged to address the limitations of Flutter's built-in state management:
- Provider: A wrapper around InheritedWidget that simplifies dependency injection and state propagation:
class CounterModel extends ChangeNotifier {
int _count = 0;
int get count => _count;
void increment() {
_count++;
notifyListeners();
}
}
// In widget tree
MultiProvider(
providers: [
ChangeNotifierProvider(create: (_) => CounterModel()),
],
child: MyApp(),
)
// In a widget
Consumer<CounterModel>(
builder: (context, counter, child) {
return Text('Count: ${counter.count}');
},
)
- Riverpod: An evolution of Provider that addresses some of its limitations with compile-time safety and improved testing capabilities:
final counterProvider = StateNotifierProvider<CounterNotifier, int>((ref) {
return CounterNotifier();
});
class CounterNotifier extends StateNotifier<int> {
CounterNotifier() : super(0);
void increment() => state = state + 1;
}
// In a widget
Consumer(
builder: (context, ref, child) {
final count = ref.watch(counterProvider);
return Text('Count: $count');
},
)
- BLoC (Business Logic Component): A pattern that separates business logic from UI using streams:
class CounterBloc extends Bloc<CounterEvent, int> {
CounterBloc() : super(0) {
on<IncrementEvent>((event, emit) => emit(state + 1));
}
}
// In a widget
BlocProvider(
create: (context) => CounterBloc(),
child: BlocBuilder<CounterBloc, int>(
builder: (context, count) {
return Text('Count: $count');
},
),
)
- GetX: An all-in-one solution that combines state management, dependency injection, and navigation:
class CounterController extends GetxController {
var count = 0.obs;
void increment() => count++;
}
// In a widget
GetX<CounterController>(
init: CounterController(),
builder: (controller) {
return Text('Count: ${controller.count.value}');
},
)
The diversity of these solutions reflects the multifaceted nature of state management challenges. Each approach makes different tradeoffs between simplicity, performance, testability, and scalability.
React Native State Management
React Native inherits React's component model and state management approaches, adapted for the mobile context. The framework's recent evolution toward hooks has significantly influenced how developers manage state in React Native applications.
React's Built-in State Management
React Native provides several built-in mechanisms for state management:
- useState: A hook for managing component-local state:
import React, { useState } from "react";
import { Text, Button, View } from "react-native";
function CounterComponent() {
const [count, setCount] = useState(0);
return (
<View>
<Text>Count: {count}</Text>
<Button title="Increment" onPress={() => setCount(count + 1)} />
</View>
);
}
- useReducer: A hook for more complex state logic that follows a reducer pattern:
import React, { useReducer } from "react";
import { Text, Button, View } from "react-native";
function counterReducer(state, action) {
switch (action.type) {
case "INCREMENT":
return { count: state.count + 1 };
default:
return state;
}
}
function CounterComponent() {
const [state, dispatch] = useReducer(counterReducer, { count: 0 });
return (
<View>
<Text>Count: {state.count}</Text>
<Button
title="Increment"
onPress={() => dispatch({ type: "INCREMENT" })}
/>
</View>
);
}
- Context API: A mechanism for passing data through the component tree without prop drilling:
import React, { createContext, useContext, useState } from "react";
const CounterContext = createContext();
function CounterProvider({ children }) {
const [count, setCount] = useState(0);
const increment = () => setCount(count + 1);
return (
<CounterContext.Provider value={{ count, increment }}>
{children}
</CounterContext.Provider>
);
}
function CounterDisplay() {
const { count } = useContext(CounterContext);
return <Text>Count: {count}</Text>;
}
function CounterButton() {
const { increment } = useContext(CounterContext);
return <Button title="Increment" onPress={increment} />;
}
These built-in approaches work well for simpler applications but can become unwieldy as application complexity grows, particularly when managing asynchronous operations or complex state interactions.
Popular React Native State Management Solutions
Several third-party libraries extend React's capabilities for more complex state management:
- Redux: A predictable state container that enforces unidirectional data flow:
import { createStore } from "redux";
import { Provider, useSelector, useDispatch } from "react-redux";
// Reducer
const counterReducer = (state = { count: 0 }, action) => {
switch (action.type) {
case "INCREMENT":
return { count: state.count + 1 };
default:
return state;
}
};
// Store
const store = createStore(counterReducer);
// In App component
function App() {
return (
<Provider store={store}>
<CounterComponent />
</Provider>
);
}
// In a component
function CounterComponent() {
const count = useSelector((state) => state.count);
const dispatch = useDispatch();
return (
<View>
<Text>Count: {count}</Text>
<Button
title="Increment"
onPress={() => dispatch({ type: "INCREMENT" })}
/>
</View>
);
}
- MobX: A library that uses observable state and reactions for automatic state tracking:
import { makeObservable, observable, action } from "mobx";
import { observer } from "mobx-react-lite";
class CounterStore {
count = 0;
constructor() {
makeObservable(this, {
count: observable,
increment: action,
});
}
increment() {
this.count += 1;
}
}
const counterStore = new CounterStore();
// In a component
const CounterComponent = observer(() => {
return (
<View>
<Text>Count: {counterStore.count}</Text>
<Button title="Increment" onPress={() => counterStore.increment()} />
</View>
);
});
- Recoil: A state management library designed specifically for React that enables fine-grained reactivity:
import { atom, useRecoilState } from "recoil";
const countState = atom({
key: "countState",
default: 0,
});
function CounterComponent() {
const [count, setCount] = useRecoilState(countState);
return (
<View>
<Text>Count: {count}</Text>
<Button title="Increment" onPress={() => setCount(count + 1)} />
</View>
);
}
- Zustand: A minimalist state management solution that works well with React hooks:
import create from "zustand";
const useCounterStore = create((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
}));
function CounterComponent() {
const { count, increment } = useCounterStore();
return (
<View>
<Text>Count: {count}</Text>
<Button title="Increment" onPress={increment} />
</View>
);
}
The React Native ecosystem has evolved toward simpler, more composable state management solutions that leverage React's hooks API. This trend reflects a general movement away from monolithic state management toward more focused, use-case-specific approaches.
.NET MAUI State Management
.NET MAUI builds on the strong foundation of MVVM (Model-View-ViewModel) that has been a staple in Microsoft's application development platforms. This architecture naturally separates concerns and provides a structured approach to state management.
MVVM Pattern in MAUI
The MVVM pattern is deeply integrated into MAUI's architecture:
// Model
public class CounterModel
{
public int Value { get; set; }
}
// ViewModel
public class CounterViewModel : INotifyPropertyChanged
{
private CounterModel _model = new CounterModel();
public int Count
{
get => _model.Value;
private set
{
if (_model.Value != value)
{
_model.Value = value;
OnPropertyChanged();
}
}
}
public ICommand IncrementCommand { get; }
public CounterViewModel()
{
IncrementCommand = new Command(() => Count++);
}
public event PropertyChangedEventHandler PropertyChanged;
protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
}
// View (in XAML)
<ContentPage ...>
<StackLayout>
<Label Text="{Binding Count, StringFormat='Count: {0}'}" />
<Button Text="Increment" Command="{Binding IncrementCommand}" />
</StackLayout>
</ContentPage>
MAUI simplifies this pattern with ObservableObject and RelayCommand from the Microsoft.Toolkit.Mvvm package:
using Microsoft.Toolkit.Mvvm.ComponentModel;
using Microsoft.Toolkit.Mvvm.Input;
public class CounterViewModel : ObservableObject
{
private int _count;
public int Count
{
get => _count;
set => SetProperty(ref _count, value);
}
public IRelayCommand IncrementCommand { get; }
public CounterViewModel()
{
IncrementCommand = new RelayCommand(() => Count++);
}
}
Advanced State Management in MAUI
For more complex state management needs, MAUI developers can leverage several approaches:
- Dependency Injection: MAUI includes built-in support for dependency injection, enabling the registration and consumption of services:
// In MauiProgram.cs
builder.Services.AddSingleton<ICounterService, CounterService>();
builder.Services.AddTransient<CounterViewModel>();
// In a ViewModel
public class CounterViewModel : ObservableObject
{
private readonly ICounterService _counterService;
public CounterViewModel(ICounterService counterService)
{
_counterService = counterService;
// ...
}
// ...
}
- Messenger Pattern: The MVVM Toolkit includes a messenger for loosely coupled component communication:
// Define a message
public class CounterIncrementedMessage
{
public int NewValue { get; }
public CounterIncrementedMessage(int newValue)
{
NewValue = newValue;
}
}
// In sender ViewModel
public class CounterViewModel : ObservableObject
{
private readonly IMessenger _messenger;
public CounterViewModel(IMessenger messenger)
{
_messenger = messenger;
IncrementCommand = new RelayCommand(() => {
Count++;
_messenger.Send(new CounterIncrementedMessage(Count));
});
}
// ...
}
// In receiver ViewModel
public class StatisticsViewModel : ObservableObject
{
public StatisticsViewModel(IMessenger messenger)
{
messenger.Register<CounterIncrementedMessage>(this, (r, m) => {
IncrementCount = m.NewValue;
// Update statistics
});
}
// ...
}
- State Containers: For more complex applications, developers can implement custom state containers inspired by patterns like Redux:
// State
public record AppState
{
public int Counter { get; init; }
// Other state properties
}
// Actions
public interface IAction { }
public record IncrementAction : IAction
{
public int Amount { get; init; } = 1;
}
// Reducer
public class Reducer
{
public AppState Reduce(AppState state, IAction action)
{
return action switch
{
IncrementAction inc => state with { Counter = state.Counter + inc.Amount },
_ => state
};
}
}
// Store
public class Store : ObservableObject
{
private readonly Reducer _reducer = new();
private AppState _state = new();
public AppState State
{
get => _state;
private set => SetProperty(ref _state, value);
}
public void Dispatch(IAction action)
{
State = _reducer.Reduce(State, action);
}
}
.NET MAUI's state management approaches benefit from C#'s strong typing and the mature patterns established in the .NET ecosystem. The built-in support for MVVM provides a solid foundation, while additional patterns can be integrated for more complex scenarios.
Universal State Management Principles
Despite the differences between frameworks, several core principles apply universally to effective state management in mobile applications. These principles transcend specific implementation details and provide guidance for architecting maintainable state management solutions.
Single Source of Truth
Maintaining a single authoritative source for each piece of state eliminates synchronization problems and simplifies debugging:
Define clear state boundaries: Explicitly categorize different types of state and assign ownership to specific components or services.
Avoid redundant state: Derive computed values from primary state rather than storing derived state that needs to be kept in sync.
Centralize remote data: Use a local cache or repository pattern to provide a unified interface for data access, abstracting away the details of remote fetching and local storage.
Example in Flutter using a repository pattern:
class UserRepository {
final ApiClient _apiClient;
final LocalStorage _localStorage;
// Cache for in-memory access
User? _currentUser;
Future<User> getCurrentUser() async {
// Return cached user if available
if (_currentUser != null) return _currentUser!;
// Try to get from local storage
try {
final userData = await _localStorage.getItem('current_user');
if (userData != null) {
_currentUser = User.fromJson(userData);
return _currentUser!;
}
} catch (e) {
// Handle local storage error
}
// Fetch from API if not in local storage
try {
final user = await _apiClient.fetchCurrentUser();
_currentUser = user;
await _localStorage.setItem('current_user', user.toJson());
return user;
} catch (e) {
throw Exception('Failed to get current user: $e');
}
}
// Other methods for updating user, etc.
}
Unidirectional Data Flow
Establishing a clear, one-way flow of data through the application makes state changes predictable and easier to reason about:
State→View→Action→State cycle: Structure applications so that state renders the view, user interactions generate actions, and actions update the state in a continuous cycle.
Immutable state updates: Make state changes through creation of new state objects rather than mutating existing ones to maintain clear history and prevent side effects.
Clear update paths: Define explicit paths for state updates, avoiding multiple components modifying the same state through different mechanisms.
Example in React Native using a unidirectional flow:
// Action creators
const incrementCounter = (amount = 1) => ({
type: "INCREMENT_COUNTER",
payload: amount,
});
// Reducer
const reducer = (state = { count: 0 }, action) => {
switch (action.type) {
case "INCREMENT_COUNTER":
return { ...state, count: state.count + action.payload };
default:
return state;
}
};
// Component
function CounterComponent({ count, dispatch }) {
return (
<View>
<Text>Count: {count}</Text>
<Button title="Increment" onPress={() => dispatch(incrementCounter())} />
<Button
title="Increment by 5"
onPress={() => dispatch(incrementCounter(5))}
/>
</View>
);
}
Isolation of Side Effects
Separating pure state management from side effects like API calls and local storage operations improves testability and maintainability:
Middleware pattern: Use middleware to handle side effects outside the main state management flow.
Async action creators: Encapsulate complex asynchronous operations in dedicated functions that manage their own loading and error states.
Effect hooks: Use framework-specific mechanisms to handle side effects at the appropriate lifecycle points.
Example in .NET MAUI isolating API call side effects:
public class ProductsViewModel : ObservableObject
{
private readonly IProductService _productService;
private ObservableCollection<ProductViewModel> _products;
private bool _isLoading;
private string _errorMessage;
// Properties with change notification
public ObservableCollection<ProductViewModel> Products
{
get => _products;
set => SetProperty(ref _products, value);
}
public bool IsLoading
{
get => _isLoading;
set => SetProperty(ref _isLoading, value);
}
public string ErrorMessage
{
get => _errorMessage;
set => SetProperty(ref _errorMessage, value);
}
public IAsyncRelayCommand LoadProductsCommand { get; }
public ProductsViewModel(IProductService productService)
{
_productService = productService;
_products = new ObservableCollection<ProductViewModel>();
// Side effects isolated in the command execution
LoadProductsCommand = new AsyncRelayCommand(LoadProductsAsync);
}
private async Task LoadProductsAsync()
{
try
{
IsLoading = true;
ErrorMessage = string.Empty;
var products = await _productService.GetProductsAsync();
Products = new ObservableCollection<ProductViewModel>(
products.Select(p => new ProductViewModel(p))
);
}
catch (Exception ex)
{
ErrorMessage = $"Failed to load products: {ex.Message}";
}
finally
{
IsLoading = false;
}
}
}
Selective Reactivity
Optimizing when and how components react to state changes is crucial for performance in complex mobile applications:
Granular subscriptions: Subscribe to only the specific state needed by each component rather than larger state objects.
Memoization: Cache computed values to prevent unnecessary recalculations when inputs haven't changed.
Batched updates: Group multiple state changes to minimize rendering cycles.
Example in Flutter using selective reactivity with Provider:
class UserProfileViewModel extends ChangeNotifier {
User? _user;
bool _isLoading = false;
String? _error;
User? get user => _user;
bool get isLoading => _isLoading;
String? get error => _error;
// Computed property
String get displayName => _user?.name ?? 'Guest';
Future<void> loadUser() async {
_isLoading = true;
_error = null;
notifyListeners();
try {
_user = await _userRepository.getCurrentUser();
} catch (e) {
_error = e.toString();
} finally {
_isLoading = false;
notifyListeners();
}
}
}
// In a widget - only listen to what's needed
Widget build(BuildContext context) {
// Only rebuilds when displayName changes
final displayName = context.select<UserProfileViewModel, String>(
(vm) => vm.displayName
);
// Only rebuilds when isLoading changes
final isLoading = context.select<UserProfileViewModel, bool>(
(vm) => vm.isLoading
);
return isLoading
? CircularProgressIndicator()
: Text('Welcome, $displayName');
}
Real-World Architecture Patterns
Beyond the framework-specific approaches and universal principles, several architectural patterns have proven effective for managing state in complex mobile applications. These patterns can be adapted to work with any of the frameworks discussed.
The Repository Pattern
The repository pattern provides a clean abstraction over data sources, centralizing data access logic and serving as a single source of truth:
// Flutter example with repository pattern
class ProductRepository {
final ApiClient _apiClient;
final LocalStorage _localStorage;
// In-memory cache
List<Product>? _products;
Future<List<Product>> getProducts() async {
// Return cached products if available
if (_products != null) return _products!;
// Try to get from local storage
try {
final cachedData = await _localStorage.getItem('products');
if (cachedData != null) {
_products = (cachedData as List)
.map((item) => Product.fromJson(item))
.toList();
return _products!;
}
} catch (e) {
// Handle local storage error
}
// Fetch from API if not in cache
try {
final products = await _apiClient.fetchProducts();
_products = products;
await _localStorage.setItem('products', products.map((p) => p.toJson()).toList());
return products;
} catch (e) {
throw Exception('Failed to get products: $e');
}
}
Future<void> refreshProducts() async {
// Clear cache and fetch fresh data
_products = null;
await getProducts();
}
// Methods for CRUD operations on products
}
This pattern works well with any state management solution and provides several benefits:
Consistent data access: All components get data through the same interface, ensuring consistency.
Caching strategy: The repository handles caching transparently, optimizing performance without complicating component logic.
Offline support: Local persistence can be managed within the repository, providing seamless offline functionality.
Testing simplicity: Repositories can be easily mocked for testing UI components without real data dependencies.
The Store and Slice Pattern
For applications with complex global state, organizing state into domain-specific "slices" managed by a central store provides scalability:
// React Native example with Redux Toolkit
import { configureStore, createSlice } from "@reduxjs/toolkit";
// User slice
const userSlice = createSlice({
name: "user",
initialState: {
profile: null,
isAuthenticated: false,
preferences: {},
},
reducers: {
setUser: (state, action) => {
state.profile = action.payload;
state.isAuthenticated = true;
},
clearUser: (state) => {
state.profile = null;
state.isAuthenticated = false;
},
updatePreferences: (state, action) => {
state.preferences = { ...state.preferences, ...action.payload };
},
},
});
// Cart slice
const cartSlice = createSlice({
name: "cart",
initialState: {
items: [],
total: 0,
},
reducers: {
addItem: (state, action) => {
state.items.push(action.payload);
state.total = calculateTotal(state.items);
},
removeItem: (state, action) => {
state.items = state.items.filter((item) => item.id !== action.payload);
state.total = calculateTotal(state.items);
},
clearCart: (state) => {
state.items = [];
state.total = 0;
},
},
});
// Combine slices into a store
const store = configureStore({
reducer: {
user: userSlice.reducer,
cart: cartSlice.reducer,
// Other slices...
},
});
export const { setUser, clearUser, updatePreferences } = userSlice.actions;
export const { addItem, removeItem, clearCart } = cartSlice.actions;
This pattern provides several advantages for complex applications:
Domain separation: State is organized by domain, making it easier to manage and reason about.
Selective updates: Components can subscribe to only the slices they need, improving performance.
Modular development: Teams can work on different slices independently, enabling parallel development.
Developer experience: Actions and reducers are co-located, making it easier to understand state transitions.
The Command Query Responsibility Segregation (CQRS) Pattern
For applications with complex data operations, separating read operations (queries) from write operations (commands) can simplify state management:
// .NET MAUI example with CQRS pattern
// Commands
public interface ICommand { }
public class AddProductToCartCommand : ICommand
{
public Product Product { get; }
public int Quantity { get; }
public AddProductToCartCommand(Product product, int quantity)
{
Product = product;
Quantity = quantity;
}
}
// Command handler
public class CartCommandHandler
{
private readonly ICartRepository _cartRepository;
private readonly IEventAggregator _eventAggregator;
public CartCommandHandler(ICartRepository cartRepository, IEventAggregator eventAggregator)
{
_cartRepository = cartRepository;
_eventAggregator = eventAggregator;
}
public async Task HandleAsync(AddProductToCartCommand command)
{
await _cartRepository.AddItemAsync(command.Product, command.Quantity);
_eventAggregator.Publish(new CartUpdatedEvent());
}
// Other command handlers
}
// Queries
public interface IQuery<TResult> { }
public class GetCartSummaryQuery : IQuery<CartSummary> { }
// Query handler
public class CartQueryHandler
{
private readonly ICartRepository _cartRepository;
public CartQueryHandler(ICartRepository cartRepository)
{
_cartRepository = cartRepository;
}
public async Task<CartSummary> HandleAsync(GetCartSummaryQuery query)
{
var items = await _cartRepository.GetItemsAsync();
return new CartSummary
{
ItemCount = items.Count,
TotalAmount = items.Sum(i => i.Price * i.Quantity),
HasDiscountedItems = items.Any(i => i.IsDiscounted)
};
}
// Other query handlers
}
// ViewModel using CQRS
public class CartViewModel : ObservableObject
{
private readonly CartCommandHandler _commandHandler;
private readonly CartQueryHandler _queryHandler;
private readonly IEventAggregator _eventAggregator;
private CartSummary _summary;
public CartSummary Summary
{
get => _summary;
private set => SetProperty(ref _summary, value);
}
public IAsyncRelayCommand<Product> AddProductCommand { get; }
public CartViewModel(
CartCommandHandler commandHandler,
CartQueryHandler queryHandler,
IEventAggregator eventAggregator)
{
_commandHandler = commandHandler;
_queryHandler = queryHandler;
_eventAggregator = eventAggregator;
AddProductCommand = new AsyncRelayCommand<Product>(AddProduct);
// Subscribe to cart updates
_eventAggregator.Subscribe<CartUpdatedEvent>(this, _ => RefreshSummary());
// Initial load
RefreshSummary();
}
private async Task AddProduct(Product product)
{
await _commandHandler.HandleAsync(new AddProductToCartCommand(product, 1));
// No need to refresh summary here as the event subscription will handle it
}
private async void RefreshSummary()
{
Summary = await _queryHandler.HandleAsync(new GetCartSummaryQuery());
}
}
The CQRS pattern provides several benefits for complex applications:
Separation of concerns: Read and write operations are separated, making each simpler to implement and test.
Scalability: Read and write operations can be optimized independently based on their different requirements.
Specialized models: Different data models can be used for reading and writing, optimizing each for its purpose.
Audit trail: Command handling provides a natural place to implement logging and audit trails for state changes.
Summary
Effective state management remains challenging in mobile development because it sits at the intersection of architecture, user experience, and performance considerations. While Flutter, React Native, and .NET MAUI each provide their own approaches, the fundamental principles of maintaining a single source of truth, establishing unidirectional data flow, isolating side effects, and implementing selective reactivity apply universally. By understanding both the framework-specific tools and these universal principles, developers can implement state management architectures that scale with application complexity.
For teams building cross-platform mobile applications, several practical recommendations emerge:
Start with framework conventions: Begin with the standard state management approaches for your framework before introducing additional complexity.
Classify your state: Explicitly identify which state belongs to which category (UI, application, domain, or session) to inform appropriate management strategies.
Embrace architectural patterns: Implement patterns like repositories, stores with slices, or CQRS based on application complexity and team experience.
Prioritize testability: Design state management that can be tested independently of UI components to enable comprehensive validation.
Consider developer experience: Choose approaches that your team understands and can maintain consistently over time.
The "right" state management approach isn't universal—it depends on application requirements, team expertise, and the specific strengths of your chosen framework. By focusing on clear architecture and consistent patterns rather than particular technologies, teams can build maintainable mobile applications that deliver excellent user experiences without drowning in state management complexity. The most successful approaches combine the structured patterns discussed in this article with pragmatic adaptations to each team's specific context and constraints.
Ready to streamline your internal app distribution?
Start sharing your app builds with your team and clients today.
No app store reviews, no waiting times.