Skip to content

State Management ​

Managing application state is crucial to building scalable Flutter applications. This guide explores how to manage local and global state using Flutter Compositions, share state using provide/inject, and common state patterns.

Types of State ​

In Flutter Compositions, we distinguish between three types of state:

1. Local State ​

State that is only used within a single widget. Should be defined in that widget's setup() method.

dart
class CounterButton extends CompositionWidget {
  @override
  Widget Function(BuildContext) setup() {
    // Local state - only this widget needs it
    final count = ref(0);

    return (context) => ElevatedButton(
      onPressed: () => count.value++,
      child: Text('Clicks: ${count.value}'),
    );
  }
}

2. Shared State (Widget Tree Scoped) ​

State that multiple widgets need, but limited to a specific part of the widget tree. Share using provide/inject.

dart
const todosKey = InjectionKey<Ref<List<Todo>>>('todos');

// Provide in parent widget
class TodoListPage extends CompositionWidget {
  @override
  Widget Function(BuildContext) setup() {
    final todos = ref(<Todo>[]);
    provide(todosKey, todos); // Provide to child widgets

    return (context) => Column(
      children: [
        TodoInput(),
        TodoList(),
        TodoStats(),
      ],
    );
  }
}

// Inject in child widget
class TodoStats extends CompositionWidget {
  @override
  Widget Function(BuildContext) setup() {
    final todos = inject(todosKey); // Inject shared state

    final count = computed(() => todos.value.length);

    return (context) => Text('Total ${count.value} todos');
  }
}

3. Global State (Application Scoped) ​

State that the entire application needs. Provide at the application root, inject anywhere.

dart
// Define global state
class AuthState {
  final user = ref<User?>(null);
  final isAuthenticated = ref(false);

  Future<void> login(String email, String password) async {
    // Login logic
    user.value = await api.login(email, password);
    isAuthenticated.value = true;
  }

  void logout() {
    user.value = null;
    isAuthenticated.value = false;
  }
}

const authStateKey = InjectionKey<AuthState>('authState');

// Provide at application root
class MyApp extends CompositionWidget {
  @override
  Widget Function(BuildContext) setup() {
    final authState = AuthState();
    provide(authStateKey, authState);

    return (context) => MaterialApp(
      home: HomePage(),
    );
  }
}

// Use in any child widget
class ProfilePage extends CompositionWidget {
  @override
  Widget Function(BuildContext) setup() {
    final authState = inject(authStateKey);

    return (context) {
      if (!authState.isAuthenticated.value) {
        return LoginPage();
      }

      return Column(
        children: [
          Text('Welcome, ${authState.user.value?.name}'),
          ElevatedButton(
            onPressed: authState.logout,
            child: Text('Logout'),
          ),
        ],
      );
    };
  }
}

State Sharing with Provide/Inject ​

Basic Usage ​

dart
const themeKey = InjectionKey<ThemeState>('theme');

// 1. Define state to share
class ThemeState {
  final isDark = ref(false);

  void toggleTheme() {
    isDark.value = !isDark.value;
  }
}

// 2. Provide in parent widget
class App extends CompositionWidget {
  @override
  Widget Function(BuildContext) setup() {
    final theme = ThemeState();
    provide(themeKey, theme);

    return (context) => MaterialApp(
      home: HomePage(),
    );
  }
}

// 3. Inject in child widget
class ThemeToggle extends CompositionWidget {
  @override
  Widget Function(BuildContext) setup() {
    final theme = inject(themeKey);

    return (context) => Switch(
      value: theme.isDark.value,
      onChanged: (_) => theme.toggleTheme(),
    );
  }
}

Type-Safe Injection ​

To avoid type collisions and provide better developer experience, use InjectionKey:

dart
// Define injection key
class ThemeStateKey extends InjectionKey<ThemeState> {
  const ThemeStateKey();
}

const themeKey = ThemeStateKey();

// Provide using key
class App extends CompositionWidget {
  @override
  Widget Function(BuildContext) setup() {
    final theme = ThemeState();
    provide(themeKey, theme);

    return (context) => MaterialApp(home: HomePage());
  }
}

// Inject using key
class ThemeToggle extends CompositionWidget {
  @override
  Widget Function(BuildContext) setup() {
    final theme = inject(themeKey);

    return (context) => Switch(
      value: theme.isDark.value,
      onChanged: (_) => theme.toggleTheme(),
    );
  }
}

Providing Default Values ​

dart
const settingsKey = InjectionKey<SettingsState>('settings');

class SettingsState {
  final fontSize = ref(14.0);
}

class MyWidget extends CompositionWidget {
  @override
  Widget Function(BuildContext) setup() {
    // Use default value if not found
    final settings = inject(
      settingsKey,
      defaultValue: SettingsState(),
    );

    return (context) => Text(
      'Hello',
      style: TextStyle(fontSize: settings.fontSize.value),
    );
  }
}

State Patterns ​

Pattern 1: Repository Pattern ​

Separate data access logic from UI.

dart
// Repository
class UserRepository {
  final _users = ref(<User>[]);
  final _isLoading = ref(false);
  final _error = ref<String?>(null);

  ReadonlyRef<List<User>> get users => _users;
  ReadonlyRef<bool> get isLoading => _isLoading;
  ReadonlyRef<String?> get error => _error;

  Future<void> fetchUsers() async {
    _isLoading.value = true;
    _error.value = null;

    try {
      final result = await api.getUsers();
      _users.value = result;
    } catch (e) {
      _error.value = e.toString();
    } finally {
      _isLoading.value = false;
    }
  }

  Future<void> addUser(User user) async {
    try {
      await api.createUser(user);
      _users.value = [..._users.value, user];
    } catch (e) {
      _error.value = e.toString();
    }
  }

  Future<void> deleteUser(String userId) async {
    try {
      await api.deleteUser(userId);
      _users.value = _users.value.where((u) => u.id != userId).toList();
    } catch (e) {
      _error.value = e.toString();
    }
  }
}

const userRepositoryKey = InjectionKey<UserRepository>('userRepository');

// Provide in application
class App extends CompositionWidget {
  @override
  Widget Function(BuildContext) setup() {
    final userRepo = UserRepository();
    provide(userRepositoryKey, userRepo);

    return (context) => MaterialApp(home: UserListPage());
  }
}

// Use in UI
class UserListPage extends CompositionWidget {
  @override
  Widget Function(BuildContext) setup() {
    final userRepo = inject(userRepositoryKey);

    onMounted(() {
      userRepo.fetchUsers();
    });

    return (context) {
      if (userRepo.isLoading.value) {
        return Center(child: CircularProgressIndicator());
      }

      if (userRepo.error.value != null) {
        return Center(child: Text('Error: ${userRepo.error.value}'));
      }

      return ListView.builder(
        itemCount: userRepo.users.value.length,
        itemBuilder: (context, index) {
          final user = userRepo.users.value[index];
          return ListTile(
            title: Text(user.name),
            trailing: IconButton(
              icon: Icon(Icons.delete),
              onPressed: () => userRepo.deleteUser(user.id),
            ),
          );
        },
      );
    };
  }
}

Pattern 2: Store Pattern (Similar to Vuex/Pinia) ​

Centrally manage state, getters, actions, and mutations.

dart
// Store
class TodoStore {
  // State
  final _todos = ref(<Todo>[]);
  final _filter = ref<TodoFilter>(TodoFilter.all);

  // Getters (readonly)
  ReadonlyRef<List<Todo>> get todos => _todos;
  ReadonlyRef<TodoFilter> get filter => _filter;

  // Computed
  late final activeTodos = computed(() {
    return _todos.value.where((t) => !t.completed).toList();
  });

  late final completedTodos = computed(() {
    return _todos.value.where((t) => t.completed).toList();
  });

  late final filteredTodos = computed(() {
    switch (_filter.value) {
      case TodoFilter.active:
        return activeTodos.value;
      case TodoFilter.completed:
        return completedTodos.value;
      case TodoFilter.all:
        return _todos.value;
    }
  });

  late final stats = computed(() => TodoStats(
    total: _todos.value.length,
    active: activeTodos.value.length,
    completed: completedTodos.value.length,
  ));

  // Actions
  void addTodo(String title) {
    final todo = Todo(
      id: DateTime.now().toString(),
      title: title,
      completed: false,
    );
    _todos.value = [..._todos.value, todo];
  }

  void removeTodo(String id) {
    _todos.value = _todos.value.where((t) => t.id != id).toList();
  }

  void toggleTodo(String id) {
    _todos.value = _todos.value.map((t) {
      if (t.id == id) {
        return Todo(id: t.id, title: t.title, completed: !t.completed);
      }
      return t;
    }).toList();
  }

  void setFilter(TodoFilter newFilter) {
    _filter.value = newFilter;
  }

  void clearCompleted() {
    _todos.value = activeTodos.value;
  }
}

enum TodoFilter { all, active, completed }

class TodoStats {
  const TodoStats({
    required this.total,
    required this.active,
    required this.completed,
  });

  final int total;
  final int active;
  final int completed;
}

Pattern 3: Service Pattern ​

Encapsulate business logic in services.

dart
// Service
class AuthService {
  final _currentUser = ref<User?>(null);
  final _isAuthenticated = ref(false);

  ReadonlyRef<User?> get currentUser => _currentUser;
  ReadonlyRef<bool> get isAuthenticated => _isAuthenticated;

  Future<void> login(String email, String password) async {
    try {
      final user = await api.login(email, password);
      _currentUser.value = user;
      _isAuthenticated.value = true;

      // Save token
      await storage.saveToken(user.token);
    } catch (e) {
      rethrow;
    }
  }

  Future<void> logout() async {
    _currentUser.value = null;
    _isAuthenticated.value = false;
    await storage.deleteToken();
  }

  Future<void> checkAuth() async {
    final token = await storage.getToken();
    if (token != null) {
      try {
        final user = await api.getCurrentUser(token);
        _currentUser.value = user;
        _isAuthenticated.value = true;
      } catch (e) {
        await logout();
      }
    }
  }
}

class NotificationService {
  final _notifications = ref(<Notification>[]);
  final _unreadCount = ref(0);

  ReadonlyRef<List<Notification>> get notifications => _notifications;
  ReadonlyRef<int> get unreadCount => _unreadCount;

  void addNotification(Notification notification) {
    _notifications.value = [..._notifications.value, notification];
    if (!notification.read) {
      _unreadCount.value++;
    }
  }

  void markAsRead(String id) {
    _notifications.value = _notifications.value.map((n) {
      if (n.id == id && !n.read) {
        _unreadCount.value--;
        return Notification(id: n.id, message: n.message, read: true);
      }
      return n;
    }).toList();
  }

  void clearAll() {
    _notifications.value = [];
    _unreadCount.value = 0;
  }
}

const authServiceKey = InjectionKey<AuthService>('authService');
const notificationServiceKey =
    InjectionKey<NotificationService>('notificationService');

// Provide all services in application
class App extends CompositionWidget {
  @override
  Widget Function(BuildContext) setup() {
    final authService = AuthService();
    final notificationService = NotificationService();

    provide(authServiceKey, authService);
    provide(notificationServiceKey, notificationService);

    onMounted(() {
      authService.checkAuth();
    });

    return (context) => MaterialApp(home: HomePage());
  }
}

Pattern 4: Composed State ​

Compose multiple state sources.

dart
const appStateKey = InjectionKey<AppState>('appState');

class AppState {
  final auth = AuthService();
  final notifications = NotificationService();
  final theme = ThemeState();
  final settings = SettingsState();
}

class App extends CompositionWidget {
  @override
  Widget Function(BuildContext) setup() {
    final appState = AppState();
    provide(appStateKey, appState);

    return (context) => MaterialApp(home: HomePage());
  }
}

// Use in child widget
class Header extends CompositionWidget {
  @override
  Widget Function(BuildContext) setup() {
    final appState = inject(appStateKey);

    final userName = computed(() {
      return appState.auth.currentUser.value?.name ?? 'Guest';
    });

    return (context) => AppBar(
      title: Text('Welcome, ${userName.value}'),
      actions: [
        IconButton(
          icon: Badge(
            label: Text('${appState.notifications.unreadCount.value}'),
            child: Icon(Icons.notifications),
          ),
          onPressed: () {/* Show notifications */},
        ),
      ],
    );
  }
}

Real-World Example: Shopping Application ​

Let's build a complete state management system for a shopping application.

Define Domain Models ​

dart
class Product {
  const Product({
    required this.id,
    required this.name,
    required this.price,
    required this.imageUrl,
  });

  final String id;
  final String name;
  final double price;
  final String imageUrl;
}

class CartItem {
  const CartItem({
    required this.product,
    required this.quantity,
  });

  final Product product;
  final int quantity;

  double get total => product.price * quantity;
}

class Order {
  const Order({
    required this.id,
    required this.items,
    required this.total,
    required this.status,
    required this.createdAt,
  });

  final String id;
  final List<CartItem> items;
  final double total;
  final OrderStatus status;
  final DateTime createdAt;
}

enum OrderStatus { pending, confirmed, shipped, delivered }

Create Stores ​

dart
// Product Store
class ProductStore {
  final _products = ref(<Product>[]);
  final _isLoading = ref(false);
  final _error = ref<String?>(null);

  ReadonlyRef<List<Product>> get products => _products;
  ReadonlyRef<bool> get isLoading => _isLoading;
  ReadonlyRef<String?> get error => _error;

  Future<void> fetchProducts() async {
    _isLoading.value = true;
    _error.value = null;

    try {
      final result = await api.getProducts();
      _products.value = result;
    } catch (e) {
      _error.value = e.toString();
    } finally {
      _isLoading.value = false;
    }
  }

  Product? getProductById(String id) {
    try {
      return _products.value.firstWhere((p) => p.id == id);
    } catch (e) {
      return null;
    }
  }
}

// Cart Store
class CartStore {
  final _items = ref(<String, CartItem>{}); // productId -> CartItem

  ReadonlyRef<Map<String, CartItem>> get items => _items;

  late final itemCount = computed(() {
    return _items.value.values.fold<int>(0, (sum, item) => sum + item.quantity);
  });

  late final total = computed(() {
    return _items.value.values.fold<double>(0.0, (sum, item) => sum + item.total);
  });

  late final isEmpty = computed(() => _items.value.isEmpty);

  void addItem(Product product) {
    final currentItems = Map<String, CartItem>.from(_items.value);

    if (currentItems.containsKey(product.id)) {
      final currentItem = currentItems[product.id]!;
      currentItems[product.id] = CartItem(
        product: product,
        quantity: currentItem.quantity + 1,
      );
    } else {
      currentItems[product.id] = CartItem(product: product, quantity: 1);
    }

    _items.value = currentItems;
  }

  void removeItem(String productId) {
    final currentItems = Map<String, CartItem>.from(_items.value);
    currentItems.remove(productId);
    _items.value = currentItems;
  }

  void updateQuantity(String productId, int quantity) {
    if (quantity <= 0) {
      removeItem(productId);
      return;
    }

    final currentItems = Map<String, CartItem>.from(_items.value);
    if (currentItems.containsKey(productId)) {
      final item = currentItems[productId]!;
      currentItems[productId] = CartItem(
        product: item.product,
        quantity: quantity,
      );
      _items.value = currentItems;
    }
  }

  void clear() {
    _items.value = {};
  }
}

// Order Store
class OrderStore {
  final _orders = ref(<Order>[]);
  final _isLoading = ref(false);

  ReadonlyRef<List<Order>> get orders => _orders;
  ReadonlyRef<bool> get isLoading => _isLoading;

  late final pendingOrders = computed(() {
    return _orders.value.where((o) => o.status == OrderStatus.pending).toList();
  });

  late final completedOrders = computed(() {
    return _orders.value.where((o) => o.status == OrderStatus.delivered).toList();
  });

  Future<void> fetchOrders() async {
    _isLoading.value = true;
    try {
      final result = await api.getOrders();
      _orders.value = result;
    } finally {
      _isLoading.value = false;
    }
  }

  Future<Order?> createOrder(List<CartItem> items, double total) async {
    try {
      final order = await api.createOrder(items, total);
      _orders.value = [..._orders.value, order];
      return order;
    } catch (e) {
      return null;
    }
  }
}

Application Root Setup ​

dart
const productStoreKey = InjectionKey<ProductStore>('productStore');
const cartStoreKey = InjectionKey<CartStore>('cartStore');
const orderStoreKey = InjectionKey<OrderStore>('orderStore');
// authServiceKey already defined in previous example

class ShoppingApp extends CompositionWidget {
  @override
  Widget Function(BuildContext) setup() {
    // Create all stores
    final productStore = ProductStore();
    final cartStore = CartStore();
    final orderStore = OrderStore();
    final authService = AuthService();

    // Provide to entire application
    provide(productStoreKey, productStore);
    provide(cartStoreKey, cartStore);
    provide(orderStoreKey, orderStore);
    provide(authServiceKey, authService);

    // Initialize
    onMounted(() async {
      await authService.checkAuth();
      if (authService.isAuthenticated.value) {
        await productStore.fetchProducts();
        await orderStore.fetchOrders();
      }
    });

    // Watch cart changes to save locally
    watch(
      () => cartStore.items.value,
      (items, _) async {
        await storage.saveCart(items);
      },
    );

    return (context) => MaterialApp(
      home: HomePage(),
      routes: {
        '/products': (context) => ProductListPage(),
        '/cart': (context) => CartPage(),
        '/orders': (context) => OrderHistoryPage(),
      },
    );
  }
}

Using Stores in Pages ​

dart
class ProductListPage extends CompositionWidget {
  @override
  Widget Function(BuildContext) setup() {
    final productStore = inject(productStoreKey);
    final cartStore = inject(cartStoreKey);

    return (context) {
      if (productStore.isLoading.value) {
        return Center(child: CircularProgressIndicator());
      }

      return Scaffold(
        appBar: AppBar(
          title: Text('Products'),
          actions: [
            IconButton(
              icon: Badge(
                label: Text('${cartStore.itemCount.value}'),
                child: Icon(Icons.shopping_cart),
              ),
              onPressed: () => Navigator.pushNamed(context, '/cart'),
            ),
          ],
        ),
        body: GridView.builder(
          gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
            crossAxisCount: 2,
          ),
          itemCount: productStore.products.value.length,
          itemBuilder: (context, index) {
            final product = productStore.products.value[index];
            return ProductCard(
              product: product,
              onAddToCart: () => cartStore.addItem(product),
            );
          },
        ),
      );
    };
  }
}

class CartPage extends CompositionWidget {
  @override
  Widget Function(BuildContext) setup() {
    final cartStore = inject(cartStoreKey);
    final orderStore = inject(orderStoreKey);

    Future<void> checkout() async {
      final items = cartStore.items.value.values.toList();
      final total = cartStore.total.value;

      final order = await orderStore.createOrder(items, total);
      if (order != null) {
        cartStore.clear();
        // Navigate to order confirmation page
      }
    }

    return (context) {
      if (cartStore.isEmpty.value) {
        return Scaffold(
          appBar: AppBar(title: Text('Cart')),
          body: Center(child: Text('Cart is empty')),
        );
      }

      return Scaffold(
        appBar: AppBar(title: Text('Cart (${cartStore.itemCount.value})')),
        body: Column(
          children: [
            Expanded(
              child: ListView.builder(
                itemCount: cartStore.items.value.length,
                itemBuilder: (context, index) {
                  final item = cartStore.items.value.values.elementAt(index);
                  return CartItemTile(
                    item: item,
                    onUpdateQuantity: (qty) {
                      cartStore.updateQuantity(item.product.id, qty);
                    },
                    onRemove: () => cartStore.removeItem(item.product.id),
                  );
                },
              ),
            ),
            CartSummary(
              total: cartStore.total.value,
              onCheckout: checkout,
            ),
          ],
        ),
      );
    };
  }
}

Persisting State ​

Using SharedPreferences ​

dart
class SettingsStore {
  final theme = ref('light');
  final language = ref('en');
  final notifications = ref(true);

  Future<void> load() async {
    final prefs = await SharedPreferences.getInstance();
    theme.value = prefs.getString('theme') ?? 'light';
    language.value = prefs.getString('language') ?? 'en';
    notifications.value = prefs.getBool('notifications') ?? true;
  }

  Future<void> save() async {
    final prefs = await SharedPreferences.getInstance();
    await prefs.setString('theme', theme.value);
    await prefs.setString('language', language.value);
    await prefs.setBool('notifications', notifications.value);
  }
}

// Usage
const settingsStoreKey = InjectionKey<SettingsStore>('settingsStore');

class App extends CompositionWidget {
  @override
  Widget Function(BuildContext) setup() {
    final settings = SettingsStore();
    provide(settingsStoreKey, settings);

    onMounted(() async {
      await settings.load();
    });

    // Watch changes and auto-save
    watchEffect(() {
      settings.save();
    });

    return (context) => MaterialApp(home: HomePage());
  }
}

Using Hive ​

dart
class UserStore {
  final _users = ref(<User>[]);

  Future<void> load() async {
    final box = await Hive.openBox<User>('users');
    _users.value = box.values.toList();
  }

  Future<void> addUser(User user) async {
    final box = await Hive.openBox<User>('users');
    await box.add(user);
    _users.value = [..._users.value, user];
  }

  Future<void> deleteUser(int index) async {
    final box = await Hive.openBox<User>('users');
    await box.deleteAt(index);
    _users.value = List.from(_users.value)..removeAt(index);
  }
}

Best Practices ​

1. Keep State Close to Where It's Used ​

dart
// ❌ Bad - All state is global
class GlobalState {
  final buttonColor = ref(Colors.blue);
  final textValue = ref('');
  // ... 100 other fields
}

// ✅ Good - Only share necessary state
class App extends CompositionWidget {
  @override
  Widget Function(BuildContext) setup() {
    // Only provide truly global state
    final authService = AuthService();
    provide(authServiceKey, authService);

    return (context) => MaterialApp(home: HomePage());
  }
}

2. Expose State as Readonly References ​

dart
// ✅ Good - Prevent external direct modification
class CounterStore {
  final _count = ref(0);

  // Expose as readonly
  ReadonlyRef<int> get count => _count;

  // Modify through methods
  void increment() => _count.value++;
}
dart
// ✅ Good - Logical grouping
class UserState {
  final profile = ref<UserProfile?>(null);
  final preferences = ref(UserPreferences());
  final settings = ref(UserSettings());
}

4. Use Computed Instead of Repeated Calculations ​

dart
class TodoStore {
  final _todos = ref(<Todo>[]);

  // ✅ Good - Cached computation
  late final activeTodos = computed(() {
    return _todos.value.where((t) => !t.completed).toList();
  });

  // ❌ Bad - Recompute every time
  List<Todo> getActiveTodos() {
    return _todos.value.where((t) => !t.completed).toList();
  }
}

5. Handle Error States ​

dart
class DataStore<T> {
  final _data = ref<T?>(null);
  final _isLoading = ref(false);
  final _error = ref<String?>(null);

  ReadonlyRef<T?> get data => _data;
  ReadonlyRef<bool> get isLoading => _isLoading;
  ReadonlyRef<String?> get error => _error;

  Future<void> fetch(Future<T> Function() fetcher) async {
    _isLoading.value = true;
    _error.value = null;

    try {
      _data.value = await fetcher();
    } catch (e) {
      _error.value = e.toString();
    } finally {
      _isLoading.value = false;
    }
  }
}

Testing State Management ​

dart
void main() {
  group('CartStore', () {
    late CartStore cartStore;

    setUp(() {
      cartStore = CartStore();
    });

    test('should add item to cart', () {
      final product = Product(
        id: '1',
        name: 'Test',
        price: 10.0,
        imageUrl: '',
      );

      cartStore.addItem(product);

      expect(cartStore.itemCount.value, 1);
      expect(cartStore.total.value, 10.0);
    });

    test('should update quantity', () {
      final product = Product(
        id: '1',
        name: 'Test',
        price: 10.0,
        imageUrl: '',
      );

      cartStore.addItem(product);
      cartStore.updateQuantity('1', 3);

      expect(cartStore.itemCount.value, 3);
      expect(cartStore.total.value, 30.0);
    });

    test('should remove item', () {
      final product = Product(
        id: '1',
        name: 'Test',
        price: 10.0,
        imageUrl: '',
      );

      cartStore.addItem(product);
      cartStore.removeItem('1');

      expect(cartStore.isEmpty.value, true);
    });
  });
}

Next Steps ​

Released under the MIT License.