· 2 min read · Flutter, Dart, Mobile

Flutter state management: setState, Provider and Riverpod

A practical comparison of Flutter state management: when setState is enough, when to reach for Provider, and why Riverpod suits apps with shared state and API data.

Every Flutter project reaches the same question sooner or later: where should this piece of data live? The cart count shown in the app bar, the signed-in user, the list of orders loaded from an API. Flutter has many state management options, but for most apps the choice comes down to three: setState, Provider and Riverpod.

First, two kinds of state

  • Local state belongs to one widget: whether a password field is visible, which tab is selected, the text in a search box.
  • Shared state is needed by several screens: the user, the cart, settings, data from the server.

Most confusion comes from using a tool for one kind on the other.

setState: perfect for local state

class PasswordField extends StatefulWidget {
  const PasswordField({super.key});
  @override
  State<PasswordField> createState() => _PasswordFieldState();
}

class _PasswordFieldState extends State<PasswordField> {
  bool _obscure = true;

  @override
  Widget build(BuildContext context) {
    return TextField(
      obscureText: _obscure,
      decoration: InputDecoration(
        suffixIcon: IconButton(
          icon: Icon(_obscure ? Icons.visibility : Icons.visibility_off),
          onPressed: () => setState(() => _obscure = !_obscure),
        ),
      ),
    );
  }
}

There's nothing wrong with setState. It's built in, easy to follow, and right for any state that doesn't leave the widget. It becomes painful when you start passing values and callbacks down through several layers of widgets just so a distant screen can read them.

Provider: shared state without the plumbing

Provider puts an object above the widgets that need it. Any widget below can read it, and widgets that watch it rebuild when it changes.

class Cart extends ChangeNotifier {
  final List<Item> _items = [];
  List<Item> get items => List.unmodifiable(_items);

  void add(Item item) {
    _items.add(item);
    notifyListeners();
  }
}

// At the top of the app
ChangeNotifierProvider(create: (_) => Cart(), child: const MyApp());

// Anywhere below
final count = context.watch<Cart>().items.length;
context.read<Cart>().add(item);

Provider is small, well understood and fine for many apps. Its limits show as an app grows: it depends on the widget tree through BuildContext, a missing provider is only discovered at runtime, and loading and error states for API calls are left to you.

Riverpod: shared state and async data, done carefully

Riverpod, from the same author as Provider, fixes those limits. Providers are declared globally, don't depend on the widget tree, and are checked at compile time. Its async providers handle loading, data and error states for you, which covers most of what a real app does.

final ordersProvider = FutureProvider.autoDispose<List<Order>>((ref) async {
  final api = ref.watch(apiClientProvider);
  return api.fetchOrders();
});

class OrdersScreen extends ConsumerWidget {
  const OrdersScreen({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final orders = ref.watch(ordersProvider);

    return orders.when(
      loading: () => const Center(child: CircularProgressIndicator()),
      error: (e, _) => Center(child: Text('Could not load orders')),
      data: (list) => ListView(children: [for (final o in list) OrderTile(o)]),
    );
  }
}

Pull-to-refresh becomes ref.invalidate(ordersProvider). autoDispose cleans up when the screen closes. And because providers don't need a BuildContext, they are easy to override in tests with fake API clients.

How I choose

  • State used by one widget: setState, always.
  • A small app with a little shared state: Provider is fine.
  • An app built on API data, with sign-in, carts, live updates or several screens sharing data: Riverpod.

Using setState for local state inside a Riverpod app is normal and encouraged. The tools combine; you don't have to pick one for everything.

Checklist

  • Separate local state from shared state before choosing a tool
  • Keep local UI state in setState
  • Move shared state out of widgets and into providers
  • Use async providers for API data so loading and errors are handled consistently
  • Override providers in tests instead of calling real APIs

Planning a Flutter app alongside a website and admin panel? Tell me about it.

Keep reading