6 Ways to Manage State in Flutter Apps (With Code Examples)

How to manage state in Flutter

Page content

In this article, we’ll explore six popular ways of managing state in Flutter apps, including real examples and best practices:

State management is one of the most important - and debated - topics in Flutter development. It determines how your app handles changes in data and updates the UI efficiently. Flutter gives you several strategies for managing state — from simple to highly scalable.

The strategies for state management in Flutter are:

  1. 🧱 setState() — The built-in, simplest approach
  2. 🏗️ InheritedWidget — Flutter’s foundation for state propagation
  3. 🪄 Provider — The recommended solution for most apps
  4. 🔒 Riverpod — Modern, compile-safe evolution of Provider
  5. 📦 Bloc — For scalable, enterprise-grade applications
  6. GetX — Lightweight all-in-one solution

flutter troubles mega tracktor


1. Using setState() — The Basics

The simplest way to manage state in Flutter is by using the built-in setState() method in a StatefulWidget.

This approach is ideal for local UI state — where the state belongs to one widget and doesn’t need to be shared across the app.

Example: Counter App with setState()

import 'package:flutter/material.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(home: CounterScreen());
  }
}

class CounterScreen extends StatefulWidget {
  @override
  _CounterScreenState createState() => _CounterScreenState();
}

class _CounterScreenState extends State<CounterScreen> {
  int _count = 0;

  void _increment() {
    setState(() {
      _count++;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('setState Counter')),
      body: Center(
        child: Text('Count: $_count', style: TextStyle(fontSize: 24)),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _increment,
        child: Icon(Icons.add),
      ),
    );
  }
}

Pros

  • Very simple to implement
  • Great for local or temporary UI state
  • No external dependencies

** Cons**

  • Doesn’t scale for large apps
  • Hard to share state between widgets
  • Logic mixed with UI

Use it when:

  • Prototyping or building small widgets
  • Handling isolated UI state (e.g., toggling a button, showing a modal)

2. InheritedWidget — Flutter’s Foundation

InheritedWidget is the low-level mechanism that Flutter uses to propagate data down the widget tree. Most state management solutions (including Provider) are built on top of it.

Understanding InheritedWidget helps you grasp how Flutter’s state management works under the hood.

Example: Theme Manager with InheritedWidget

import 'package:flutter/material.dart';

// The InheritedWidget that holds the theme data
class AppTheme extends InheritedWidget {
  final bool isDarkMode;
  final Function toggleTheme;

  const AppTheme({
    Key? key,
    required this.isDarkMode,
    required this.toggleTheme,
    required Widget child,
  }) : super(key: key, child: child);

  // Access method to get the nearest AppTheme up the widget tree
  static AppTheme? of(BuildContext context) {
    return context.dependOnInheritedWidgetOfExactType<AppTheme>();
  }

  @override
  bool updateShouldNotify(AppTheme oldWidget) {
    return isDarkMode != oldWidget.isDarkMode;
  }
}

// Stateful wrapper to manage state changes
class ThemeManager extends StatefulWidget {
  final Widget child;

  const ThemeManager({Key? key, required this.child}) : super(key: key);

  @override
  _ThemeManagerState createState() => _ThemeManagerState();
}

class _ThemeManagerState extends State<ThemeManager> {
  bool _isDarkMode = false;

  void _toggleTheme() {
    setState(() {
      _isDarkMode = !_isDarkMode;
    });
  }

  @override
  Widget build(BuildContext context) {
    return AppTheme(
      isDarkMode: _isDarkMode,
      toggleTheme: _toggleTheme,
      child: widget.child,
    );
  }
}

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return ThemeManager(
      child: Builder(
        builder: (context) {
          final theme = AppTheme.of(context);
          return MaterialApp(
            theme: theme!.isDarkMode ? ThemeData.dark() : ThemeData.light(),
            home: HomeScreen(),
          );
        },
      ),
    );
  }
}

class HomeScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final theme = AppTheme.of(context);
    
    return Scaffold(
      appBar: AppBar(title: Text('InheritedWidget Theme')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text(
              'Current Theme: ${theme!.isDarkMode ? "Dark" : "Light"}',
              style: TextStyle(fontSize: 20),
            ),
            SizedBox(height: 20),
            ElevatedButton(
              onPressed: () => theme.toggleTheme(),
              child: Text('Toggle Theme'),
            ),
          ],
        ),
      ),
    );
  }
}

Pros

  • Built into Flutter — no external dependencies
  • Efficient widget rebuilds
  • Foundation for understanding other solutions
  • Direct control over propagation logic

Cons

  • Verbose boilerplate code
  • Requires wrapper StatefulWidget
  • Easy to make mistakes
  • Not beginner-friendly

Use it when:

  • Learning how Flutter’s state management works internally
  • Building custom state management solutions
  • You need very specific control over state propagation

When your app’s state needs to be shared between multiple widgets, Provider comes to the rescue.

Provider is based on Inversion of Control — instead of widgets owning the state, a provider exposes it for others to consume. Flutter’s team officially recommends it for medium-sized apps.

Setup

Add this to your pubspec.yaml:

dependencies:
  provider: ^6.0.5

Example: Counter App with Provider

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';

void main() {
  runApp(
    ChangeNotifierProvider(
      create: (_) => CounterModel(),
      child: MyApp(),
    ),
  );
}

class CounterModel extends ChangeNotifier {
  int _count = 0;
  int get count => _count;

  void increment() {
    _count++;
    notifyListeners();
  }
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(home: CounterScreen());
  }
}

class CounterScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final counter = context.watch<CounterModel>();
    return Scaffold(
      appBar: AppBar(title: Text('Provider Counter')),
      body: Center(
        child: Text('Count: ${counter.count}', style: TextStyle(fontSize: 24)),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: context.read<CounterModel>().increment,
        child: Icon(Icons.add),
      ),
    );
  }
}

Pros

  • Reactive and efficient updates
  • Clean separation of UI and logic
  • Well-documented and community-supported

** Cons**

  • Slightly more boilerplate than setState()
  • Nested providers can get complex

Use it when:

  • You need shared state across multiple widgets
  • You want a reactive pattern without complexity
  • Your app is growing beyond prototype size

4. Riverpod — Provider’s Modern Evolution

Riverpod is a complete rewrite of Provider that removes its dependency on BuildContext and adds compile-time safety. It’s designed to solve Provider’s limitations while keeping the same philosophy.

Setup

Add this to your pubspec.yaml:

dependencies:
  flutter_riverpod: ^2.4.0

Example: User Profile with Riverpod

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

// Model
class UserProfile {
  final String name;
  final int age;
  
  UserProfile({required this.name, required this.age});
  
  UserProfile copyWith({String? name, int? age}) {
    return UserProfile(
      name: name ?? this.name,
      age: age ?? this.age,
    );
  }
}

// State Notifier
class ProfileNotifier extends StateNotifier<UserProfile> {
  ProfileNotifier() : super(UserProfile(name: 'Guest', age: 0));

  void updateName(String name) {
    state = state.copyWith(name: name);
  }

  void updateAge(int age) {
    state = state.copyWith(age: age);
  }
}

// Provider
final profileProvider = StateNotifierProvider<ProfileNotifier, UserProfile>((ref) {
  return ProfileNotifier();
});

// Computed provider
final greetingProvider = Provider<String>((ref) {
  final profile = ref.watch(profileProvider);
  return 'Hello, ${profile.name}! You are ${profile.age} years old.';
});

void main() {
  runApp(
    ProviderScope(
      child: MyApp(),
    ),
  );
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(home: ProfileScreen());
  }
}

class ProfileScreen extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final profile = ref.watch(profileProvider);
    final greeting = ref.watch(greetingProvider);

    return Scaffold(
      appBar: AppBar(title: Text('Riverpod Profile')),
      body: Padding(
        padding: EdgeInsets.all(16),
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text(greeting, style: TextStyle(fontSize: 20)),
            SizedBox(height: 30),
            TextField(
              decoration: InputDecoration(labelText: 'Name'),
              onChanged: (value) {
                ref.read(profileProvider.notifier).updateName(value);
              },
            ),
            SizedBox(height: 20),
            Row(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                Text('Age: ${profile.age}', style: TextStyle(fontSize: 18)),
                SizedBox(width: 20),
                ElevatedButton(
                  onPressed: () {
                    ref.read(profileProvider.notifier).updateAge(profile.age + 1);
                  },
                  child: Text('+'),
                ),
                SizedBox(width: 10),
                ElevatedButton(
                  onPressed: () {
                    if (profile.age > 0) {
                      ref.read(profileProvider.notifier).updateAge(profile.age - 1);
                    }
                  },
                  child: Text('-'),
                ),
              ],
            ),
          ],
        ),
      ),
    );
  }
}

Pros

  • No BuildContext dependency — access state anywhere
  • Compile-time safety — catch errors before runtime
  • Better testability
  • Powerful state composition
  • No memory leaks

Cons

  • Different API from traditional Flutter patterns
  • Steeper learning curve than Provider
  • Smaller community (but growing fast)

Use it when:

  • Starting a new Flutter project
  • You want type-safety and compile-time guarantees
  • Complex state dependencies and compositions
  • Working on medium-to-large scale apps

5. BLoC (Business Logic Component) — For Enterprise Apps

The BLoC pattern is a more advanced architecture that completely separates the business logic from the UI using streams.

It’s ideal for large-scale or enterprise applications, where predictable and testable state transitions are essential.

Setup

Add this dependency to your project:

dependencies:
  flutter_bloc: ^9.0.0

Example: Counter App with BLoC

import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';

// Events
abstract class CounterEvent {}
class Increment extends CounterEvent {}

// Bloc
class CounterBloc extends Bloc<CounterEvent, int> {
  CounterBloc() : super(0) {
    on<Increment>((event, emit) => emit(state + 1));
  }
}

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return BlocProvider(
      create: (_) => CounterBloc(),
      child: MaterialApp(home: CounterScreen()),
    );
  }
}

class CounterScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Bloc Counter')),
      body: Center(
        child: BlocBuilder<CounterBloc, int>(
          builder: (context, count) {
            return Text('Count: $count', style: TextStyle(fontSize: 24));
          },
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () => context.read<CounterBloc>().add(Increment()),
        child: Icon(Icons.add),
      ),
    );
  }
}

Pros

  • Scalable and maintainable for complex apps
  • Clear separation of layers
  • Easy to unit test

Cons

  • More boilerplate
  • Learning curve is steeper

Use it when:

  • Building enterprise or long-term projects
  • You need predictable and testable logic
  • Multiple devs work on different app modules

6. GetX — The All-in-One Lightweight Solution

GetX is a minimalist yet powerful state management solution that also includes routing, dependency injection, and utilities. It’s known for having the least boilerplate of all solutions.

Setup

Add this to your pubspec.yaml:

dependencies:
  get: ^4.6.5

Example: Shopping List with GetX

import 'package:flutter/material.dart';
import 'package:get/get.dart';

// Controller
class ShoppingController extends GetxController {
  // Observable list
  var items = <String>[].obs;
  
  // Observable counter
  var totalItems = 0.obs;

  void addItem(String item) {
    if (item.isNotEmpty) {
      items.add(item);
      totalItems.value = items.length;
    }
  }

  void removeItem(int index) {
    items.removeAt(index);
    totalItems.value = items.length;
  }

  void clearAll() {
    items.clear();
    totalItems.value = 0;
  }
}

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return GetMaterialApp(
      title: 'GetX Shopping List',
      home: ShoppingScreen(),
    );
  }
}

class ShoppingScreen extends StatelessWidget {
  // Initialize controller
  final ShoppingController controller = Get.put(ShoppingController());
  final TextEditingController textController = TextEditingController();

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('GetX Shopping List'),
        actions: [
          Obx(() => Padding(
                padding: EdgeInsets.all(16),
                child: Center(
                  child: Text(
                    'Items: ${controller.totalItems}',
                    style: TextStyle(fontSize: 18),
                  ),
                ),
              )),
        ],
      ),
      body: Column(
        children: [
          Padding(
            padding: EdgeInsets.all(16),
            child: Row(
              children: [
                Expanded(
                  child: TextField(
                    controller: textController,
                    decoration: InputDecoration(
                      hintText: 'Enter item name',
                      border: OutlineInputBorder(),
                    ),
                  ),
                ),
                SizedBox(width: 10),
                ElevatedButton(
                  onPressed: () {
                    controller.addItem(textController.text);
                    textController.clear();
                  },
                  child: Icon(Icons.add),
                ),
              ],
            ),
          ),
          Expanded(
            child: Obx(() {
              if (controller.items.isEmpty) {
                return Center(
                  child: Text('No items in your list'),
                );
              }
              
              return ListView.builder(
                itemCount: controller.items.length,
                itemBuilder: (context, index) {
                  return ListTile(
                    leading: CircleAvatar(child: Text('${index + 1}')),
                    title: Text(controller.items[index]),
                    trailing: IconButton(
                      icon: Icon(Icons.delete, color: Colors.red),
                      onPressed: () => controller.removeItem(index),
                    ),
                  );
                },
              );
            }),
          ),
          if (controller.items.isNotEmpty)
            Padding(
              padding: EdgeInsets.all(16),
              child: ElevatedButton(
                onPressed: () {
                  Get.defaultDialog(
                    title: 'Clear List',
                    middleText: 'Are you sure you want to clear all items?',
                    textConfirm: 'Yes',
                    textCancel: 'No',
                    onConfirm: () {
                      controller.clearAll();
                      Get.back();
                    },
                  );
                },
                style: ElevatedButton.styleFrom(
                  backgroundColor: Colors.red,
                  minimumSize: Size(double.infinity, 50),
                ),
                child: Text('Clear All'),
              ),
            ),
        ],
      ),
    );
  }
}

Pros

  • Minimal boilerplate — fastest development
  • All-in-one solution (state, routing, DI, snackbars, dialogs)
  • No BuildContext needed
  • Very lightweight and fast
  • Easy to learn

Cons

  • Less “Flutter-like” compared to Provider/BLoC
  • Can lead to tight coupling if not careful
  • Smaller ecosystem than Provider
  • Some developers find it “too magical”

Use it when:

  • Rapid prototyping or MVP development
  • Small to medium apps
  • You want minimal boilerplate
  • Team prefers simplicity over strict architecture

Choosing the Right State Management Solution

Solution Complexity Boilerplate Learning Curve Best For
🧱 setState() Low Minimal Easy Simple local state, prototypes
🏗️ InheritedWidget Medium High Medium Learning Flutter internals, custom solutions
🪄 Provider Low-Medium Low Easy Most apps, shared state
🔒 Riverpod Medium Low-Medium Medium Modern apps, type safety
📦 BLoC High High Steep Enterprise apps, complex business logic
GetX Low Minimal Easy Rapid development, MVPs

Quick Decision Guide:

App Complexity Recommended Approach Example Use Case
🪶 Simple setState() or GetX Toggle buttons, animations, small widgets
⚖️ Medium Provider or Riverpod Shared themes, user settings, data caching
🏗️ Complex BLoC or Riverpod E-commerce, chat apps, financial dashboards

Final Thoughts

There’s no one-size-fits-all approach to state management in Flutter.

Here’s a practical approach:

  • Start small with setState() for simple widgets and prototypes
  • Learn the fundamentals with InheritedWidget to understand how Flutter works internally
  • Graduate to Provider as your app grows and needs shared state
  • Consider Riverpod for new projects where type safety and modern patterns matter
  • Adopt BLoC for large enterprise applications with complex business logic
  • Try GetX when rapid development and minimal boilerplate are priorities

Key Takeaways:

✅ Don’t over-engineer — use setState() until you need more
✅ Provider and Riverpod are excellent choices for most applications
✅ BLoC shines in large teams and complex apps
✅ GetX is great for speed, but be mindful of coupling
✅ Understanding InheritedWidget helps you master any solution

The key is to balance simplicity, scalability, and maintainability — and choose the right tool for your specific needs, team expertise, and project requirements.