Introduction
State management is the backbone of any non-trivial mobile application. As apps grow in complexity — handling authentication, offline data, real-time updates, and optimistic UI — choosing the right state management strategy becomes critical. In 2026, the ecosystem offers mature solutions across both React Native and Flutter, each with distinct philosophies around reactivity, immutability, and boilerplate. This guide covers local vs global state, solution comparisons, and implementation patterns for production-grade apps.
Understanding State in Mobile Apps
Local vs Global State
State in mobile applications exists at multiple levels. Understanding when to use each type prevents architectural over-engineering:
| State Type | Scope | Persistence | Examples |
|---|---|---|---|
| Local component state | Single component | None | Form input, toggle states |
| Local widget state | Widget subtree | None | Scroll position, animation values |
| Shared state | Multiple screens | None | Auth token, user profile |
| Persistent state | App-wide | Disk/DB | Preferences, cached data |
| Server state | Remote origin | Cache | API responses, real-time data |
| URL/Route state | Navigation | Navigation tree | Current screen, params |
Local state belongs to a single component or widget. Use useState in React or StatefulWidget in Flutter for transient UI state like text input values, dropdown selections, or animation progress.
Global state is shared across multiple screens or components. This includes authentication status, user preferences, cart contents, and application settings. Global state requires careful management to avoid unnecessary re-renders and to maintain consistency across the app.
When to Use Each Approach
Deciding between local and global state depends on where the data is consumed:
// ✅ Local state - belongs to one component
function SearchInput() {
const [query, setQuery] = useState('');
return (
<TextInput
value={query}
onChangeText={setQuery}
placeholder="Search..."
/>
);
}
// ❌ Premature global state
// Don't put search input in global store unless needed across screens
// ✅ Global state - shared across screens
// authStore.ts
interface AuthState {
user: User | null;
token: string | null;
isLoading: boolean;
}
// Consumed by: ProfileScreen, SettingsScreen, CheckoutScreen
function useAuth() {
return useAuthStore(state => ({
user: state.user,
isAuthenticated: !!state.token,
}));
}
State Management Solutions Overview
| Solution | Platform | Paradigm | Bundle Size | Learning Curve |
|---|---|---|---|---|
| Redux Toolkit | RN/Web | Flux/Immer | ~12 KB | Medium |
| Zustand | RN/Web | Hook-based stores | ~1 KB | Low |
| MobX | RN/Web | Observable/Mutable | ~16 KB | Low-Medium |
| Jotai | RN/Web | Atomic atoms | ~3 KB | Low |
| Provider | Flutter | InheritedWidget | Built-in | Low |
| Riverpod | Flutter | Compile-safe providers | ~50 KB | Low-Medium |
| Bloc | Flutter | Event/State stream | ~30 KB | Medium-High |
Redux Toolkit in React Native
Redux remains the most widely adopted state management solution for React Native. Redux Toolkit (RTK) significantly reduces boilerplate compared to vanilla Redux by providing createSlice, configureStore, and built-in Thunk middleware.
Store Configuration
Set up the store with RTK’s configureStore:
import { configureStore } from '@reduxjs/toolkit';
import { useDispatch, useSelector, TypedUseSelectorHook } from 'react-redux';
import authReducer from './slices/authSlice';
import cartReducer from './slices/cartSlice';
import productsReducer from './slices/productsSlice';
export const store = configureStore({
reducer: {
auth: authReducer,
cart: cartReducer,
products: productsReducer,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
serializableCheck: {
ignoredActions: ['auth/login/fulfilled'],
},
}),
devTools: __DEV__,
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
export const useAppDispatch = () => useDispatch<AppDispatch>();
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
Creating Slices with createSlice
Define state slices with reducers and async thunks:
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
import { authApi } from '../../api/auth';
interface AuthState {
user: User | null;
token: string | null;
isLoading: boolean;
error: string | null;
}
const initialState: AuthState = {
user: null,
token: null,
isLoading: false,
error: null,
};
export const login = createAsyncThunk(
'auth/login',
async (credentials: LoginCredentials, { rejectWithValue }) => {
try {
const response = await authApi.login(credentials);
await SecureStore.setItemAsync('token', response.token);
return response;
} catch (error) {
return rejectWithValue(error.message);
}
}
);
const authSlice = createSlice({
name: 'auth',
initialState,
reducers: {
logout(state) {
state.user = null;
state.token = null;
},
updateProfile(state, action: PayloadAction<Partial<User>>) {
if (state.user) {
state.user = { ...state.user, ...action.payload };
}
},
},
extraReducers: (builder) => {
builder
.addCase(login.pending, (state) => {
state.isLoading = true;
state.error = null;
})
.addCase(login.fulfilled, (state, action) => {
state.isLoading = false;
state.user = action.payload.user;
state.token = action.payload.token;
})
.addCase(login.rejected, (state, action) => {
state.isLoading = false;
state.error = action.payload as string;
});
},
});
export const { logout, updateProfile } = authSlice.actions;
export default authSlice.reducer;
Using Redux in Components
Connect components with typed hooks:
import { useAppDispatch, useAppSelector } from '../../store';
function ProfileScreen() {
const dispatch = useAppDispatch();
const { user, isLoading } = useAppSelector((state) => state.auth);
const handleLogout = () => {
dispatch(logout());
};
if (isLoading) {
return <LoadingSpinner />;
}
return (
<View>
<Text>{user?.name}</Text>
<Text>{user?.email}</Text>
<Button title="Logout" onPress={handleLogout} />
</View>
);
}
Redux Persistence
Persist and rehydrate Redux state across app restarts:
import { persistStore, persistReducer } from 'redux-persist';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { PersistGate } from 'redux-persist/integration/react';
const persistConfig = {
key: 'root',
storage: AsyncStorage,
whitelist: ['auth', 'settings'], // Only persist these slices
blacklist: ['products'], // Don't persist server cache
};
const persistedReducer = persistReducer(persistConfig, rootReducer);
export const store = configureStore({
reducer: persistedReducer,
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
serializableCheck: {
ignoredActions: ['persist/PERSIST', 'persist/REHYDRATE'],
},
}),
});
export const persistor = persistStore(store);
// In App.tsx
function App() {
return (
<Provider store={store}>
<PersistGate loading={<SplashScreen />} persistor={persistor}>
<RootNavigator />
</PersistGate>
</Provider>
);
}
Zustand for React Native
Zustand offers a minimal, hook-based alternative to Redux with significantly less boilerplate. Its API is built around small, independent stores.
Creating a Zustand Store
Define stores as hooks with built-in selectors:
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import AsyncStorage from '@react-native-async-storage/async-storage';
interface CartItem {
id: string;
name: string;
price: number;
quantity: number;
}
interface CartStore {
items: CartItem[];
total: number;
addItem: (item: Omit<CartItem, 'quantity'>) => void;
removeItem: (id: string) => void;
updateQuantity: (id: string, quantity: number) => void;
clearCart: () => void;
}
export const useCartStore = create<CartStore>()(
persist(
(set, get) => ({
items: [],
total: 0,
addItem: (item) =>
set((state) => {
const existing = state.items.find((i) => i.id === item.id);
if (existing) {
return {
items: state.items.map((i) =>
i.id === item.id
? { ...i, quantity: i.quantity + 1 }
: i
),
};
}
return {
items: [...state.items, { ...item, quantity: 1 }],
};
}),
removeItem: (id) =>
set((state) => ({
items: state.items.filter((i) => i.id !== id),
})),
updateQuantity: (id, quantity) =>
set((state) => ({
items: quantity <= 0
? state.items.filter((i) => i.id !== id)
: state.items.map((i) =>
i.id === id ? { ...i, quantity } : i
),
})),
clearCart: () => set({ items: [], total: 0 }),
}),
{
name: 'cart-storage',
storage: createJSONStorage(() => AsyncStorage),
partialize: (state) => ({
items: state.items,
}),
}
)
);
// Computed value subscription
export const useCartTotal = () =>
useCartStore((state) =>
state.items.reduce((sum, item) => sum + item.price * item.quantity, 0)
);
Using Zustand in Components
Zustand integrates naturally with React’s hooks model. Selectors prevent unnecessary re-renders by subscribing only to specific state fragments:
function CartScreen() {
const items = useCartStore((state) => state.items);
const total = useCartTotal();
const { addItem, removeItem } = useCartStore();
return (
<FlatList
data={items}
renderItem={({ item }) => (
<CartItemRow
item={item}
onIncrement={() => addItem(item)}
onDecrement={() => removeItem(item.id)}
/>
)}
ListFooterComponent={<Text>Total: ${total.toFixed(2)}</Text>}
/>
);
}
Zustand Middleware
Zustand supports middleware for logging, persistence, and devtools:
import { create } from 'zustand';
import { devtools, subscribeWithSelector } from 'zustand/middleware';
interface AnalyticsStore {
events: AnalyticsEvent[];
track: (event: AnalyticsEvent) => void;
flush: () => Promise<void>;
}
export const useAnalyticsStore = create<AnalyticsStore>()(
devtools(
subscribeWithSelector((set, get) => ({
events: [],
track: (event) =>
set((state) => ({
events: [...state.events, event],
})),
flush: async () => {
const events = get().events;
if (events.length > 0) {
await analyticsApi.sendBatch(events);
set({ events: [] });
}
},
})),
{ name: 'AnalyticsStore' }
)
);
// Subscribe to specific state changes
useAnalyticsStore.subscribe(
(state) => state.events.length,
(length) => {
if (length >= 10) {
useAnalyticsStore.getState().flush();
}
}
);
MobX in React Native
MobX takes a reactive, observable-based approach where state changes automatically propagate to all observers. Its mutable API feels natural to many developers.
Observable Stores
Define stores with makeAutoObservable:
import { makeAutoObservable, runInAction } from 'mobx';
import { observer } from 'mobx-react-lite';
class ProductStore {
products: Product[] = [];
selectedCategory: string | null = null;
isLoading = false;
searchQuery = '';
constructor() {
makeAutoObservable(this);
}
// Computed property - auto-derives from state
get filteredProducts(): Product[] {
let result = this.products;
if (this.selectedCategory) {
result = result.filter((p) => p.category === this.selectedCategory);
}
if (this.searchQuery) {
result = result.filter((p) =>
p.name.toLowerCase().includes(this.searchQuery.toLowerCase())
);
}
return result;
}
get categories(): string[] {
return [...new Set(this.products.map((p) => p.category))];
}
setSearchQuery(query: string) {
this.searchQuery = query;
}
setSelectedCategory(category: string | null) {
this.selectedCategory = category;
}
async fetchProducts() {
this.isLoading = true;
try {
const response = await api.getProducts();
runInAction(() => {
this.products = response.data;
this.isLoading = false;
});
} catch (error) {
runInAction(() => {
this.isLoading = false;
});
}
}
}
const productStore = new ProductStore();
export default productStore;
Observer Components
Wrap components with observer to auto-track dependencies:
import { observer } from 'mobx-react-lite';
import productStore from './stores/productStore';
const ProductList = observer(() => {
const { filteredProducts, isLoading, setSearchQuery, searchQuery } =
productStore;
return (
<View>
<TextInput
value={searchQuery}
onChangeText={setSearchQuery}
placeholder="Search products..."
style={styles.searchInput}
/>
{isLoading ? (
<LoadingSpinner />
) : (
<FlatList
data={filteredProducts}
renderItem={({ item }) => <ProductCard product={item} />}
keyExtractor={(item) => item.id}
/>
)}
</View>
);
});
MobX tracks which observables each component accesses and only re-renders when those specific values change. This granular reactivity prevents the unnecessary re-renders common in Redux without requiring manual selector optimization.
Jotai for Atomic State
Jotai provides an atomic state model where atoms are independent units of state that can be composed together. This approach scales naturally from simple to complex applications.
import { atom, useAtom, useAtomValue } from 'jotai';
import { atomWithStorage } from 'jotai/utils';
import AsyncStorage from '@react-native-async-storage/async-storage';
// Base atoms
const searchQueryAtom = atom('');
const selectedFiltersAtom = atom<Filter[]>([]);
const pageAtom = atom(1);
// Persistent atom
const recentSearchesAtom = atomWithStorage<string[]>(
'recent-searches',
[],
{
getItem: async (key) => {
const item = await AsyncStorage.getItem(key);
return item ? JSON.parse(item) : [];
},
setItem: async (key, value) => {
await AsyncStorage.setItem(key, JSON.stringify(value));
},
removeItem: async (key) => {
await AsyncStorage.removeItem(key);
},
}
);
// Derived atoms
const searchParamsAtom = atom((get) => ({
query: get(searchQueryAtom),
filters: get(selectedFiltersAtom),
page: get(pageAtom),
}));
const searchUrlAtom = atom((get) => {
const params = get(searchParamsAtom);
const query = new URLSearchParams({
q: params.query,
filters: JSON.stringify(params.filters),
page: String(params.page),
});
return `/api/search?${query}`;
});
// Async atom
const searchResultsAtom = atom(async (get) => {
const url = get(searchUrlAtom);
const response = await fetch(url);
return response.json();
});
// Usage
function SearchScreen() {
const [query, setQuery] = useAtom(searchQueryAtom);
const results = useAtomValue(searchResultsAtom);
return (
<View>
<TextInput value={query} onChangeText={setQuery} />
<SearchResults data={results} />
</View>
);
}
Flutter State Management
Provider
Provider is the simplest state management solution for Flutter, wrapping InheritedWidget to expose values down the widget tree:
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
// Model
class CounterModel extends ChangeNotifier {
int _count = 0;
int get count => _count;
void increment() {
_count++;
notifyListeners();
}
}
// Provide the model at the app root
void main() {
runApp(
ChangeNotifierProvider(
create: (context) => CounterModel(),
child: const MyApp(),
),
);
}
// Consume in widgets
class CounterWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Column(
children: [
// Rebuilds when CounterModel changes
Text('Count: ${context.watch<CounterModel>().count}'),
ElevatedButton(
onPressed: () => context.read<CounterModel>().increment(),
child: const Text('Increment'),
),
],
);
}
}
Provider works well for simple apps but lacks compile-time safety — accessing a non-existent provider throws a runtime error.
Riverpod
Riverpod addresses Provider’s limitations with compile-time safety, better testability, and support for multiple provider types:
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'main.g.dart';
// Simple provider
final greetingProvider = Provider<String>((ref) => 'Hello, Riverpod!');
// StateNotifier for mutable state
class AuthNotifier extends StateNotifier<AuthState> {
AuthNotifier(this._authService) : super(AuthState.initial());
final AuthService _authService;
Future<void> login(String email, String password) async {
state = state.copyWith(isLoading: true);
try {
final user = await _authService.login(email, password);
state = AuthState.authenticated(user);
} catch (e) {
state = state.copyWith(
isLoading: false,
error: e.toString(),
);
}
}
}
final authProvider =
StateNotifierProvider<AuthNotifier, AuthState>((ref) {
return AuthNotifier(ref.watch(authServiceProvider));
});
// Async provider with auto-dispose
final userPostsProvider = FutureProvider.family<List<Post>, String>(
(ref, userId) async {
final api = ref.watch(apiServiceProvider);
return api.getUserPosts(userId);
},
name: 'userPosts',
);
// Computed provider
final filteredPostsProvider = Provider.family<List<Post>, String>(
(ref, searchTerm) {
final allPosts = ref.watch(allPostsProvider);
if (searchTerm.isEmpty) return allPosts;
return allPosts
.where((p) => p.title.toLowerCase().contains(searchTerm.toLowerCase()))
.toList();
},
);
Consume providers in widgets:
class LoginScreen extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final authState = ref.watch(authProvider);
final greeting = ref.watch(greetingProvider);
return Scaffold(
appBar: AppBar(title: Text(greeting)),
body: authState.when(
initial: () => LoginForm(),
loading: () => const CircularProgressIndicator(),
authenticated: (user) => Dashboard(user: user),
error: (message) => ErrorWidget(message: message),
),
);
}
}
Bloc
Bloc (Business Logic Component) enforces a strict event-driven architecture. State changes are triggered by events processed through streams:
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:equatable/equatable.dart';
// Events
abstract class TodoEvent extends Equatable {
const TodoEvent();
@override
List<Object?> get props => [];
}
class AddTodo extends TodoEvent {
final String title;
const AddTodo(this.title);
@override
List<Object?> get props => [title];
}
class ToggleTodo extends TodoEvent {
final String id;
const ToggleTodo(this.id);
@override
List<Object?> get props => [id];
}
// State
class TodoState extends Equatable {
final List<Todo> todos;
final bool isLoading;
const TodoState({this.todos = const [], this.isLoading = false});
TodoState copyWith({List<Todo>? todos, bool? isLoading}) {
return TodoState(
todos: todos ?? this.todos,
isLoading: isLoading ?? this.isLoading,
);
}
@override
List<Object?> get props => [todos, isLoading];
}
// Bloc
class TodoBloc extends Bloc<TodoEvent, TodoState> {
TodoBloc() : super(const TodoState()) {
on<AddTodo>(_onAddTodo);
on<ToggleTodo>(_onToggleTodo);
}
Future<void> _onAddTodo(AddTodo event, Emitter<TodoState> emit) async {
emit(state.copyWith(isLoading: true));
final newTodo = Todo(id: uuid.v4(), title: event.title);
emit(state.copyWith(
todos: [...state.todos, newTodo],
isLoading: false,
));
}
void _onToggleTodo(ToggleTodo event, Emitter<TodoState> emit) {
emit(state.copyWith(
todos: state.todos.map((todo) {
if (todo.id == event.id) {
return todo.copyWith(completed: !todo.completed);
}
return todo;
}).toList(),
));
}
}
// Usage
class TodoPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => TodoBloc(),
child: BlocBuilder<TodoBloc, TodoState>(
builder: (context, state) {
if (state.isLoading) {
return const CircularProgressIndicator();
}
return ListView.builder(
itemCount: state.todos.length,
itemBuilder: (context, index) {
final todo = state.todos[index];
return ListTile(
title: Text(todo.title),
leading: Checkbox(
value: todo.completed,
onChanged: (_) {
context.read<TodoBloc>().add(ToggleTodo(todo.id));
},
),
);
},
);
},
),
);
}
}
State Management Architecture Patterns
Clean Architecture with State Management
Divide state responsibilities into layers:
// Domain layer - entities and use cases
interface UserEntity {
id: string;
name: string;
email: string;
}
// Data layer - repositories and data sources
class UserRepositoryImpl implements UserRepository {
async getUser(id: string): Promise<UserEntity> {
const response = await api.get(`/users/${id}`);
return UserMapper.fromApi(response.data);
}
}
// Presentation layer - state management
class UserBloc {
private repository: UserRepository;
private userState = signal<UserState>({ loading: true });
async loadUser(id: string) {
try {
const user = await this.repository.getUser(id);
this.userState.set({ user, loading: false });
} catch (error) {
this.userState.set({ error, loading: false });
}
}
}
Server State with React Query/TanStack Query
Server state (API data) has different requirements than client state:
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
// Fetch with caching
function useProducts(category: string | null) {
return useQuery({
queryKey: ['products', category],
queryFn: () => api.getProducts(category),
staleTime: 5 * 60 * 1000, // 5 minutes
gcTime: 30 * 60 * 1000, // 30 minutes (formerly cacheTime)
retry: 3,
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
});
}
// Mutation with optimistic updates
function useCreateProduct() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (product: NewProduct) => api.createProduct(product),
onMutate: async (newProduct) => {
await queryClient.cancelQueries({ queryKey: ['products'] });
const previous = queryClient.getQueryData(['products']);
queryClient.setQueryData(['products'], (old: Product[]) => [
{ ...newProduct, id: 'temp-id', status: 'pending' },
...(old || []),
]);
return { previous };
},
onError: (err, newProduct, context) => {
queryClient.setQueryData(['products'], context?.previous);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['products'] });
},
});
}
Performance Considerations
Re-render Optimization
Prevent unnecessary re-renders with proper state selection:
// ❌ Bad - subscribes to entire store
function UserName() {
const state = useAppSelector((state) => state);
return <Text>{state.auth.user?.name}</Text>;
}
// ✅ Good - subscribes only to needed slice
function UserName() {
const userName = useAppSelector((state) => state.auth.user?.name);
return <Text>{userName}</Text>;
}
// ✅ Best - use shallow equality for objects
function UserProfile() {
const user = useAppSelector(
(state) => state.auth.user,
shallowEqual // Only re-render if user object changes reference
);
return <ProfileCard user={user} />;
}
State Normalization
Normalize nested data to prevent cascading updates:
// ❌ Bad - nested state
interface BlogPost {
id: string;
title: string;
author: { id: string; name: string };
comments: Comment[];
}
// ✅ Good - normalized state
interface NormalizedState {
posts: { [id: string]: Post };
authors: { [id: string]: Author };
comments: { [id: string]: Comment };
postIds: string[];
}
State Management Decision Guide
| App Complexity | React Native | Flutter |
|---|---|---|
| Simple (1-5 screens) | useState + Context | Provider |
| Medium (5-15 screens) | Zustand or Jotai | Riverpod |
| Complex (15+ screens) | Redux Toolkit | Bloc |
| Data-heavy (dashboards) | TanStack Query + Zustand | Riverpod + Freezed |
| Real-time (chat, games) | Zustand + WebSocket | Bloc + Streams |
Resources
- Redux Toolkit Documentation
- Zustand GitHub Repository
- MobX Documentation
- Riverpod Documentation
- Bloc State Management Library
- Jotai Documentation
- TanStack Query Documentation
- Flutter Provider Package
- React Native State Management Guide
Comments