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.
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.
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.
setState(() {
// Mutate state here
obscurePassword = !obscurePassword;
isLoading = true;
count++;
});
// After this callback returns, build() will be calledCommon mistake: calling setState after the widget is disposed. Flutter will throw an error. Always check mounted before calling setState in async callbacks.
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.
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.
// 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
mountedin 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.






Comments
Loading comments…