Skip to content
FlutterLearn

Beginner · Lesson 3 · 10 min read

Widgets and the Widget Tree

Everything in Flutter is a widget. Learn what widgets really are, how composition replaces inheritance, and how build methods work.

Updated July 18, 2026

What you will learn

  • Describe what a widget is and is not
  • Distinguish StatelessWidget from StatefulWidget
  • Understand how build() is called and when
  • Compose small widgets instead of writing giant build methods

A widget is not a view object. It is an immutable description of part of the UI at a moment in time. Flutter builds a fresh widget tree on every frame that changes, compares it to the previous one, and updates the underlying render objects only where something actually differs.

That is why creating widgets in a build method is cheap — they are lightweight configuration objects, not heavyweight views.

Three trees, not one

TreeWhat it holdsLifetime
Widget treeImmutable configuration you writeRecreated constantly
Element treeThe runtime instances that hold state and positionLong-lived, reused across rebuilds
Render treeObjects that measure, lay out and paintLong-lived, mutated in place

You mostly work with the widget tree. The element tree matters because it is what preserves state across rebuilds — and it is why Keys exist, which you will meet when reordering lists.

StatelessWidget

A StatelessWidget renders purely from its constructor arguments. Given the same inputs, it always produces the same output.

lib/widgets/price_tag.dart
import 'package:flutter/material.dart';

class PriceTag extends StatelessWidget {
  const PriceTag({
    super.key,
    required this.label,
    required this.amount,
    this.highlighted = false,
  });

  final String label;
  final double amount;
  final bool highlighted;

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);

    return Container(
      padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
      decoration: BoxDecoration(
        color: highlighted
            ? theme.colorScheme.primaryContainer
            : theme.colorScheme.surfaceContainerHighest,
        borderRadius: BorderRadius.circular(12),
      ),
      child: Column(
        mainAxisSize: MainAxisSize.min,
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Text(label, style: theme.textTheme.labelMedium),
          Text(
            '\$${amount.toStringAsFixed(2)}',
            style: theme.textTheme.titleLarge,
          ),
        ],
      ),
    );
  }
}

Note Theme.of(context). BuildContext is your widget's handle on its position in the tree, and .of(context) walks up that tree to find the nearest ancestor of a given type. This is how theming, media queries, navigation and localisation all reach down to you without being passed manually.

StatefulWidget

When a widget needs to remember something between builds — a counter, a text field's contents, whether a panel is expanded — you need a StatefulWidget. It comes in two classes: the immutable widget, and a State object that survives rebuilds.

Dart
class ExpandablePanel extends StatefulWidget {
  const ExpandablePanel({super.key, required this.title, required this.body});

  final String title;
  final Widget body;

  @override
  State<ExpandablePanel> createState() => _ExpandablePanelState();
}

class _ExpandablePanelState extends State<ExpandablePanel> {
  bool _open = false;

  @override
  Widget build(BuildContext context) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.stretch,
      children: [
        ListTile(
          title: Text(widget.title), // note: widget.<field>
          trailing: Icon(_open ? Icons.expand_less : Icons.expand_more),
          onTap: () => setState(() => _open = !_open),
        ),
        if (_open) Padding(
          padding: const EdgeInsets.all(16),
          child: widget.body,
        ),
      ],
    );
  }
}

Inside the State class you reach the widget's fields through widget.title. Fields on the State (like _open) are your mutable state. setState marks the element dirty so Flutter schedules a rebuild.

The State lifecycle

  1. initState() — called once when the State is created. Set up controllers, subscriptions, initial fetches. You cannot use InheritedWidget lookups that depend on context changes here safely.
  2. didChangeDependencies() — after initState, and again whenever an inherited dependency changes.
  3. build() — called often. Keep it fast and free of side effects.
  4. didUpdateWidget(old) — the parent rebuilt with new configuration. Compare old with widget and react.
  5. dispose() — the State is being removed permanently. Cancel timers, close streams, dispose controllers here or you will leak memory.

Composition over configuration

Flutter widgets do one thing each. Instead of a Button with forty properties, you wrap: Padding adds space, Center centres, DecoratedBox draws a background, GestureDetector handles taps. You build the widget you want by nesting.

The practical consequence: extract widgets, do not extract build methods. A helper method like Widget _buildHeader() still rebuilds with the whole parent. A separate StatelessWidget with a const constructor can be skipped entirely when its inputs have not changed.

Dart
// Works, but rebuilds with the parent every time
Widget _buildHeader() => const Text('Dashboard');

// Better: its own widget, const-constructible, independently skippable
class _Header extends StatelessWidget {
  const _Header();

  @override
  Widget build(BuildContext context) => const Text('Dashboard');
}

Key takeaways

  • Widgets are immutable descriptions; the element tree holds the long-lived state.
  • StatelessWidget renders from its inputs; StatefulWidget keeps a State object across rebuilds.
  • Always clean up controllers and subscriptions in dispose().
  • Extract real widgets rather than build helper methods so Flutter can skip unchanged subtrees.

Practice

Build a reusable stat card

Create a `StatCard` StatelessWidget that takes a label, a value and an icon, and renders them in a rounded container using the current theme's colours. Then create a StatefulWidget that shows three StatCards and a button that increments one of the values.

Show hints
  • Use Theme.of(context).colorScheme rather than hard-coded colours.
  • The StatCard should stay stateless — the count lives in the parent.