Widgets: Stateless & Stateful
In Flutter you build an app by combining small widgets into bigger ones. Flutter gives you hundreds of ready-made widgets, but your app is made of your own widgets: a product card, a fee summary, a whole screen. This lesson shows how to write them.
There are two basic kinds. A stateless widget only shows the data it is given. A stateful widget also remembers data that can change while it is on screen, such as a counter or a checkbox, and redraws itself when that data changes.
StatelessWidget and the build method
A stateless widget is a class that extends StatelessWidget and overrides one method, build. Flutter calls build whenever it needs to know what the widget looks like, and build returns a tree of other widgets.
import 'package:flutter/material.dart';
class FeeCard extends StatelessWidget {
const FeeCard({super.key, required this.course, required this.fee, this.isPaid = false});
final String course;
final int fee;
final bool isPaid;
@override
Widget build(BuildContext context) {
return Card(
child: ListTile(
leading: Icon(isPaid ? Icons.check_circle : Icons.pending),
title: Text(course),
subtitle: Text('Fee: ₹$fee'),
trailing: Text(isPaid ? 'Paid' : 'Due'),
),
);
}
}
void main() {
runApp(
const MaterialApp(
home: Scaffold(
body: Column(
children: [
FeeCard(course: 'Class 12 Physics', fee: 4500, isPaid: true),
FeeCard(course: 'Class 12 Maths', fee: 5000),
],
),
),
),
);
}The screen shows two cards stacked at the top. The first has a check-circle icon, the title "Class 12 Physics", the line "Fee: ₹4500" and the word "Paid" on the right; the second has a pending (clock) icon, "Class 12 Maths", "Fee: ₹5000" and "Due".
The pattern is the one from Classes & Constructors: final fields, a const constructor with named parameters, and super.key. All fields of a widget must be final; a widget is an immutable description, and Flutter creates new widget objects whenever something changes. Making constructors const lets Flutter reuse identical widgets instead of rebuilding them.
BuildContext
The context parameter of build is a BuildContext: a handle to this widget's position in the tree. You use it to look up things provided higher up, such as the theme, the screen size or the navigator:
Theme.of(context).colorScheme.primaryreads the app's main colour.MediaQuery.sizeOf(context).widthreads the screen width.Navigator.of(context).push(...)opens a new screen.
Each widget has its own context, and a lookup searches upwards from that point. That is why a widget can only find a Theme or Navigator that sits above it.
StatefulWidget, State and setState
A stateful widget is split into two classes: the widget itself (immutable, holds configuration) and a State object (long-lived, holds data that changes). When you change that data, call setState, and Flutter runs build again to show the new values.
import 'package:flutter/material.dart';
class LadduCounter extends StatefulWidget {
const LadduCounter({super.key, required this.pricePerPiece});
final int pricePerPiece;
@override
State<LadduCounter> createState() => _LadduCounterState();
}
class _LadduCounterState extends State<LadduCounter> {
int _count = 0;
void _add() => setState(() => _count++);
void _remove() {
if (_count == 0) return;
setState(() => _count--);
}
@override
Widget build(BuildContext context) {
final total = _count * widget.pricePerPiece;
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton(onPressed: _remove, icon: const Icon(Icons.remove)),
Text('$_count laddu = ₹$total'),
IconButton(onPressed: _add, icon: const Icon(Icons.add)),
],
);
}
}Shown with const LadduCounter(pricePerPiece: 15), the row reads "0 laddu = ₹0" between a minus button and a plus button. Three taps on plus change it to "3 laddu = ₹45"; tapping minus at zero does nothing.
Key details:
createState()creates theStateobject once. TheStatesurvives even when the parent rebuilds and creates a newLadduCounterwidget.- Inside the
State,widgetgives you the current widget, sowidget.pricePerPiecereads its configuration. setState(() { ... })runs your change and marks the widget as needing a rebuild. Changing_countwithoutsetStateupdates the variable, but the screen does not change.- The State class name starts with
_, so it is private to the file; only the widget is public.
The State lifecycle
A State object goes through a few stages. Override these methods when you need them:
| Method | When it runs | Typical use |
|---|---|---|
initState() | Once, when the State is created | Create controllers, start loading data |
didChangeDependencies() | After initState and when an inherited value it uses changes | Rarely needed |
build() | Every time the widget must be drawn | Return the UI; keep it fast |
didUpdateWidget(old) | When the parent rebuilds with a new widget configuration | React to changed parameters |
dispose() | Once, when the State is removed for good | Dispose controllers, cancel timers and subscriptions |
import 'dart:async';
import 'package:flutter/material.dart';
class OtpTimer extends StatefulWidget {
const OtpTimer({super.key, this.seconds = 30});
final int seconds;
@override
State<OtpTimer> createState() => _OtpTimerState();
}
class _OtpTimerState extends State<OtpTimer> {
late int _left;
Timer? _timer;
@override
void initState() {
super.initState();
_left = widget.seconds;
_timer = Timer.periodic(const Duration(seconds: 1), (t) {
if (_left == 1) t.cancel();
setState(() => _left--);
});
}
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Text(_left > 0 ? 'Resend OTP in $_left s' : 'You can resend the OTP now');
}
}The text counts down "Resend OTP in 30 s", "29 s" and so on once per second, then changes to "You can resend the OTP now". Always call super.initState() first in initState and super.dispose() last in dispose. If you forget to cancel the timer in dispose, it keeps calling setState after the widget is gone, and Flutter reports an error about calling setState after dispose.
Passing data down and events up
Data flows down the tree through constructor parameters. Events flow up through callbacks: functions that a parent passes to a child.
import 'package:flutter/material.dart';
class SeatButton extends StatelessWidget {
const SeatButton({super.key, required this.number, required this.isBooked, required this.onTap});
final int number;
final bool isBooked;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return FilledButton.tonal(
onPressed: isBooked ? null : onTap,
child: Text(isBooked ? 'Booked' : 'Seat $number'),
);
}
}
class SeatPicker extends StatefulWidget {
const SeatPicker({super.key});
@override
State<SeatPicker> createState() => _SeatPickerState();
}
class _SeatPickerState extends State<SeatPicker> {
final Set<int> _booked = {2};
@override
Widget build(BuildContext context) {
return Wrap(
spacing: 8,
children: [
for (var n = 1; n <= 4; n++)
SeatButton(
number: n,
isBooked: _booked.contains(n),
onTap: () => setState(() => _booked.add(n)),
),
],
);
}
}Four buttons appear in a row: "Seat 1", a greyed-out "Booked" button, "Seat 3" and "Seat 4". Tapping "Seat 3" turns it into a disabled "Booked" button. SeatButton is stateless and reusable: it does not know how booking works, it just calls onTap. The parent owns the state (_booked) and decides what happens. Passing null to onPressed disables a button, which is how Flutter shows that an action is not available.
Common mistakes
- Changing state without
setState. The variable changes but the screen does not. - Putting mutable fields in the widget class. Widgets are rebuilt often; keep changing data in the
State. - Slow work in
build. Do not call the network or parse big files inbuild; start that work ininitStateor elsewhere. - Not disposing. Timers, controllers and stream subscriptions created in
initStatemust be cleaned up indispose. - Calling a callback while building.
onPressed: _add()runs_addduringbuild; writeonPressed: _add.
Key points
- A
StatelessWidgetshows data it receives; it overridesbuildand has onlyfinalfields. - A
StatefulWidgetcreates aStateobject that holds changing data. - Call
setStateto change state and trigger a rebuild. BuildContextlocates the widget in the tree and finds inherited things likeThemeandNavigator.- Use
initStateto set up anddisposeto clean up. - Pass data down through constructors and events up through callbacks like
VoidCallback.
Exercise
Build a StatefulWidget called AttendanceToggle that takes a required studentName. It shows the name and a Switch. When the switch is on, the text below says "Present", otherwise "Absent". Also accept an optional callback ValueChanged<bool>? onChanged and call it whenever the switch changes, so a parent screen could count attendance.
Show solution
import 'package:flutter/material.dart';
class AttendanceToggle extends StatefulWidget {
const AttendanceToggle({super.key, required this.studentName, this.onChanged});
final String studentName;
final ValueChanged<bool>? onChanged;
@override
State<AttendanceToggle> createState() => _AttendanceToggleState();
}
class _AttendanceToggleState extends State<AttendanceToggle> {
bool _present = false;
@override
Widget build(BuildContext context) {
return ListTile(
title: Text(widget.studentName),
subtitle: Text(_present ? 'Present' : 'Absent'),
trailing: Switch(
value: _present,
onChanged: (value) {
setState(() => _present = value);
widget.onChanged?.call(value);
},
),
);
}
}The tile shows the name with "Absent" underneath and a switch on the right. Turning the switch on changes the text to "Present". ValueChanged<bool> is Flutter's typedef for void Function(bool), and ?.call(value) calls the callback only if the parent provided one.
Quick Quiz
1. When should a widget be stateful?
Data that changes over time, like a counter, belongs in a State object.
2. What happens if you change a State variable without calling setState?
setState is what tells Flutter to run build again.
3. Where should you cancel a timer that was started in initState?
dispose runs once when the State is removed; clean up there.
4. Inside a State class, how do you read a parameter given to the widget?
The widget property refers to the current widget configuration.
Learn Flutter & Dart with a mentor
Free lessons get you started. Our Mobile App Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
