← Articles

When to use local vs global state in Flutter

By John · 14 January 2025

One of the most common Flutter architecture questions is "should this state be local or global?" The answer isn't about widget count — it's about ownership and lifetime.

The rule of thumb

State belongs at the lowest level in the tree that needs it. Only lift state when two sibling subtrees need to share it, or when it needs to outlive the current widget.

Local state: setState and StatefulWidget

Local state is the right choice when:

  • Only one widget (and its descendants) needs the state
  • The state can reset when the widget is removed from the tree
  • The state is pure UI (animation, expansion, tab index, form input)
// Local: only this widget needs to know if it's expanded
class ExpandableCard extends StatefulWidget {
  // ...
}

class _ExpandableCardState extends State<ExpandableCard> {
  bool _expanded = false;

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        GestureDetector(
          onTap: () => setState(() => _expanded = !_expanded),
          child: widget.header,
        ),
        if (_expanded) widget.body,
      ],
    );
  }
}

Global state: Riverpod / BLoC

Global (shared) state is right when:

  • Multiple unrelated widgets read or write the same data
  • State needs to persist across navigation (cart, auth status, user profile)
  • The state represents server data that multiple screens display
  • You need to react to state changes in a parent without passing callbacks deep
// Global: multiple screens use the cart, it persists across navigation
@riverpod
class CartNotifier extends _$CartNotifier {
  @override
  Cart build() => Cart.empty();

  void addItem(Product product) {
    state = state.copyWith(
      items: [...state.items, CartItem(product: product, quantity: 1)],
    );
  }

  void removeItem(String productId) {
    state = state.copyWith(
      items: state.items.where((i) => i.product.id != productId).toList(),
    );
  }
}

// Home screen shows cart badge count
ref.watch(cartNotifierProvider.select((cart) => cart.items.length))

// Product screen adds to cart
ref.read(cartNotifierProvider.notifier).addItem(product);

// Checkout screen reads all cart items
ref.watch(cartNotifierProvider)

The decision tree

Is this state only used by one widget?
  ↳ Yes → Local (setState)
  ↳ No → Is it used by siblings?
       ↳ Yes → Lift to common ancestor, pass down
       ↳ No → Is the ancestor far up the tree?
            ↳ No → StatefulWidget + constructor params
            ↳ Yes → Global state (Riverpod / BLoC)

Prop drilling warning sign

If you're passing the same value through 3+ widgets just to get it to a leaf widget, that's prop drilling. Move the state to global:

// Warning sign: userId passed through 4 widgets just to reach a leaf
HomeScreen(userId: userId)
  → ProfileSection(userId: userId)
    → EditProfileButton(userId: userId)
      → ProfileEditSheet(userId: userId)  // Finally used here

// Better: leaf reads directly from global state
class ProfileEditSheet extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final userId = ref.watch(authStateProvider).userId;
    // ...
  }
}

UI state vs domain state

UI state (local): loading indicators, text field controllers, scroll positions, animation values. Lives and dies with the widget.

Domain state (global): user session, cart, feature flags, server data. Exists independently of which screen is showing.

// UI state — local
class _CheckoutScreenState extends State<CheckoutScreen> {
  bool _isSubmitting = false;         // Local: only this screen shows this
  final _formKey = GlobalKey<FormState>(); // Local: belongs to this form
  final _cardController = TextEditingController(); // Local: this input field
}

// Domain state — global
// Cart items, total, user address — all in CartNotifier
// Payment method — in PaymentNotifier
// User profile — in AuthNotifier

Scoped state with Riverpod

For state that's global to a specific flow (e.g., a multi-step form), use ProviderScope to scope it:

// Create order flow — state lives for the duration of the flow
class CreateOrderFlow extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return ProviderScope(
      overrides: [
        // Fresh instance — doesn't pollute global scope
        createOrderNotifierProvider.overrideWith(CreateOrderNotifier.new),
      ],
      child: const CreateOrderNavigator(),
    );
  }
}

Common pitfalls

Making everything global. If a loading state or form validation error is global, changing it in one screen affects others. Keep transient, screen-specific state local.

Making everything local and passing callbacks up. Once you have 3+ levels of callback threading (onAddToCart, onQuantityChanged, onRemoveItem all passed down), you need global state. The widget tree becomes unreadable.

Rebuilding too much. A widget that watches cartNotifierProvider fully rebuilds whenever any cart property changes, including ones it doesn't use. Use .select() to subscribe to a slice:

// Only rebuilds when count changes, not when item details change
final count = ref.watch(cartNotifierProvider.select((c) => c.items.length));

Sign in to like, dislike, or report.

When to use local vs global state in Flutter — ANN Tech