Skip to content
ExponaBLOG
FLUTTER · DAY 008 / 100

Stateless vs Stateful widgets: a practical Flutter guide

Where state lives decides which one you use.

Expona Admin8 min read

After five years building production platforms, I've seen countless bugs trace back to widgets holding state they shouldn't, or state scattered across a codebase with no clear owner. The decision between stateless vs stateful widgets isn't academic—it shapes how your app rebuilds, performs, and scales. Where state lives decides which one you use, and getting this wrong early creates technical debt that compounds.

StatelessWidget — Built from inputs only

A StatelessWidget is immutable. It has no mutable state. Everything it renders comes from its constructor parameters and the context it receives. Once you create a StatelessWidget instance, you cannot change it. If you want different output, you create a new instance.

This is the default choice. Most widgets in a well-designed Flutter app are stateless. They're predictable, easy to test, and cheap to rebuild. If your widget's UI depends only on its properties and external data, use StatelessWidget.

Real-world scenario: imagine a product card in an e-commerce app. It receives a product object, a callback for the tap action, and optional styling parameters. The card never changes its own appearance—it only reacts to the data passed in. That's a perfect StatelessWidget.

dart
class ProductCard extends StatelessWidget {
  final Product product;
  final VoidCallback onTap;
  final Color backgroundColor;

  const ProductCard({
    required this.product,
    required this.onTap,
    this.backgroundColor = Colors.white,
  });

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: onTap,
      child: Container(
        color: backgroundColor,
        child: Column(
          children: [
            Image.network(product.imageUrl),
            Text(product.name),
            Text('\$${product.price}'),
          ],
        ),
      ),
    );
  }
}

// Usage
ProductCard(
  product: myProduct,
  onTap: () => navigateToDetails(myProduct),
)

Notice: ProductCard doesn't manage its own state. It doesn't know whether the product is favorited, whether it's loading, or whether the user just tapped it. All that lives elsewhere. The card is a pure function of its inputs.

StatefulWidget — Holds mutable state that can change over time

A StatefulWidget is a container that manages a separate State object. That State object can change, triggering rebuilds. Use StatefulWidget when your widget needs to track internal changes—form input, toggle visibility, animation progress, timers, real-time subscriptions.

The pattern is slightly different from StatelessWidget. You create a StatefulWidget subclass, then create an associated State subclass where the actual logic and build method live.

Real-world scenario: a login form that tracks whether the password is visible, manages form validation, shows loading state during API calls, and displays error messages. This widget owns and controls its own internal state.

dart
class LoginForm extends StatefulWidget {
  const LoginForm();

  @override
  State<LoginForm> createState() => _LoginFormState();
}

class _LoginFormState extends State<LoginForm> {
  final emailController = TextEditingController();
  final passwordController = TextEditingController();
  bool obscurePassword = true;
  bool isLoading = false;
  String? errorMessage;

  @override
  void dispose() {
    emailController.dispose();
    passwordController.dispose();
    super.dispose();
  }

  Future<void> _handleLogin() async {
    setState(() {
      isLoading = true;
      errorMessage = null;
    });

    try {
      await authService.login(
        email: emailController.text,
        password: passwordController.text,
      );
      if (mounted) {
        Navigator.of(context).pushReplacementNamed('/home');
      }
    } catch (e) {
      setState(() {
        errorMessage = e.toString();
      });
    } finally {
      if (mounted) {
        setState(() {
          isLoading = false;
        });
      }
    }
  }

  @override
  Widget build(BuildContext context) {
    return SingleChildScrollView(
      child: Column(
        children: [
          TextField(
            controller: emailController,
            decoration: const InputDecoration(labelText: 'Email'),
          ),
          TextField(
            controller: passwordController,
            obscureText: obscurePassword,
            decoration: InputDecoration(
              labelText: 'Password',
              suffixIcon: IconButton(
                icon: Icon(
                  obscurePassword ? Icons.visibility_off : Icons.visibility,
                ),
                onPressed: () {
                  setState(() {
                    obscurePassword = !obscurePassword;
                  });
                },
              ),
            ),
          ),
          if (errorMessage != null)
            Padding(
              padding: const EdgeInsets.only(top: 8.0),
              child: Text(
                errorMessage!,
                style: const TextStyle(color: Colors.red),
              ),
            ),
          const SizedBox(height: 16),
          isLoading
              ? const CircularProgressIndicator()
              : ElevatedButton(
                  onPressed: _handleLogin,
                  child: const Text('Login'),
                ),
        ],
      ),
    );
  }
}

This form owns three pieces of state: visibility toggle, loading flag, and error message. Each belongs to this widget because they're tightly coupled to the form's presentation and behavior.

setState — Marks the widget dirty so it rebuilds

setState is a method you call inside a State object's build method lifecycle. When you call setState, you're telling Flutter: "Something changed, rebuild this widget." Flutter marks the State as dirty, schedules a rebuild on the next frame, and calls build() again.

The pattern is simple but has nuances. You wrap your state mutations inside the setState callback.

dart
setState(() {
  // Mutate state here
  obscurePassword = !obscurePassword;
  isLoading = true;
  count++;
});
// After this callback returns, build() will be called

Common mistake: calling setState after the widget is disposed. Flutter will throw an error. Always check mounted before calling setState in async callbacks.

dart
Future<void> fetchData() async {
  final result = await api.getData();
  if (mounted) {
    // Safe to call setState
    setState(() {
      this.data = result;
    });
  }
}

setState is cheap when the rebuild is small. If you're rebuilding a massive widget tree frequently, you'll notice dropped frames. That's when you reach for state management solutions like Provider, Riverpod, or BLoC.

Lift or extract state — Shared state moves up or into a solution

State should live as close as possible to where it's used, but no closer. If two sibling widgets need the same state, don't duplicate it in both. Lift it to their common parent.

Real-world scenario: a product details screen with a favorites button and a quantity selector. Both might need to know the product's favorite status. Instead of managing that in each widget, the parent screen owns it.

dart
class ProductDetailsScreen extends StatefulWidget {
  final String productId;

  const ProductDetailsScreen({required this.productId});

  @override
  State<ProductDetailsScreen> createState() => _ProductDetailsScreenState();
}

class _ProductDetailsScreenState extends State<ProductDetailsScreen> {
  late Product product;
  bool isFavorite = false;

  @override
  void initState() {
    super.initState();
    _loadProduct();
  }

  Future<void> _loadProduct() async {
    final loaded = await api.getProduct(widget.productId);
    setState(() {
      product = loaded;
      isFavorite = loaded.isFavorite;
    });
  }

  void _toggleFavorite() {
    setState(() {
      isFavorite = !isFavorite;
    });
    api.toggleFavorite(product.id, isFavorite);
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Column(
        children: [
          FavoriteButton(
            isFavorite: isFavorite,
            onToggle: _toggleFavorite,
          ),
          QuantitySelector(
            onQuantityChanged: (qty) {
              // Use isFavorite to decide shipping tier, for example
              _updateCart(product, qty, isFavorite);
            },
          ),
        ],
      ),
    );
  }
}

class FavoriteButton extends StatelessWidget {
  final bool isFavorite;
  final VoidCallback onToggle;

  const FavoriteButton({
    required this.isFavorite,
    required this.onToggle,
  });

  @override
  Widget build(BuildContext context) {
    return IconButton(
      icon: Icon(
        isFavorite ? Icons.favorite : Icons.favorite_border,
      ),
      onPressed: onToggle,
    );
  }
}

The parent owns isFavorite. Child widgets are stateless and communicate changes back up through callbacks. This keeps data flow predictable and makes testing easier.

When state needs to be shared across many unrelated widgets or accessed from anywhere in the app (user auth, theme, app settings), extract it into a state management solution. I've used Provider extensively in production—it scales better than lifting state through 10 levels of widgets.

dart
// With Provider (simplified example)
class FavoritesProvider extends ChangeNotifier {
  final Set<String> _favoriteIds = {};

  bool isFavorite(String productId) => _favoriteIds.contains(productId);

  void toggleFavorite(String productId) {
    if (_favoriteIds.contains(productId)) {
      _favoriteIds.remove(productId);
    } else {
      _favoriteIds.add(productId);
    }
    notifyListeners();
  }
}

// Usage
Consumer(
  builder: (context, ref, child) {
    final favProvider = ref.watch(favoritesProvider);
    return FavoriteButton(
      isFavorite: favProvider.isFavorite(productId),
      onToggle: () {
        favProvider.toggleFavorite(productId);
      },
    );
  },
)

Common mistakes

  • Holding state in StatelessWidget. You can't. If you need state, convert to StatefulWidget. Trying to work around this by rebuilding the parent is wasteful and confusing.
  • Calling setState in initState or build. setState in initState can cause double rebuilds. Never call it in build—that's infinite recursion waiting to happen. Use initState or didUpdateWidget for initialization.
  • Forgetting to dispose controllers and listeners. TextEditingController, ScrollController, StreamSubscription—all need disposal in the dispose() method. Leaking these causes memory bloat in long-lived apps.
  • Lifting state too far up. If only one widget uses a piece of state, keep it there. Lifting it to a grandparent widget just to avoid a small StatefulWidget is premature optimization.
  • Not checking mounted before setState in async callbacks. If the widget navigates away before an API call completes, calling setState on a disposed State crashes the app. Always check if (mounted).
  • Treating state management solutions as overkill. I've seen teams avoid Provider or Riverpod because they think it's overengineering, then end up with setState scattered across 20 widgets. Use the right tool early.

Key takeaways

  • StatelessWidget is the default. Use it whenever your widget's UI is determined solely by its inputs and the current context.
  • StatefulWidget owns mutable state that changes over time. Use it for forms, toggles, animations, subscriptions, and anything that needs internal change tracking.
  • setState marks a State dirty and triggers a rebuild. Wrap all state mutations inside the setState callback and always check mounted in async code.
  • Lift state to a common parent when multiple children need it. Extract state into a state management solution (Provider, Riverpod, BLoC) when it's needed across distant parts of the app.
  • Dispose of resources (controllers, listeners, subscriptions) in the dispose() method to prevent memory leaks.
  • State should live as close as possible to where it's used, no closer and no farther. This principle keeps rebuilds minimal and data flow clear.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles