EthicsComputerCourses
Join training +91 90532 10052

Widgets: Stateless & Stateful

Flutter & Dart · Lesson 14 of 29 Intermediate 7 min read Updated

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.

Dart
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.primary reads the app's main colour.
  • MediaQuery.sizeOf(context).width reads 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.

Dart
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 the State object once. The State survives even when the parent rebuilds and creates a new LadduCounter widget.
  • Inside the State, widget gives you the current widget, so widget.pricePerPiece reads its configuration.
  • setState(() { ... }) runs your change and marks the widget as needing a rebuild. Changing _count without setState updates 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:

MethodWhen it runsTypical use
initState()Once, when the State is createdCreate controllers, start loading data
didChangeDependencies()After initState and when an inherited value it uses changesRarely needed
build()Every time the widget must be drawnReturn the UI; keep it fast
didUpdateWidget(old)When the parent rebuilds with a new widget configurationReact to changed parameters
dispose()Once, when the State is removed for goodDispose controllers, cancel timers and subscriptions
Dart
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.

Dart
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 in build; start that work in initState or elsewhere.
  • Not disposing. Timers, controllers and stream subscriptions created in initState must be cleaned up in dispose.
  • Calling a callback while building. onPressed: _add() runs _add during build; write onPressed: _add.

Key points

  • A StatelessWidget shows data it receives; it overrides build and has only final fields.
  • A StatefulWidget creates a State object that holds changing data.
  • Call setState to change state and trigger a rebuild.
  • BuildContext locates the widget in the tree and finds inherited things like Theme and Navigator.
  • Use initState to set up and dispose to 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
Dart
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?

2. What happens if you change a State variable without calling setState?

3. Where should you cancel a timer that was started in initState?

4. Inside a State class, how do you read a parameter given to the widget?

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