Sitemap

Widgets in Flutter

34 min readJul 2, 2026

--

Flutter looks simple on the surface — describe your UI, and the framework handles the rest. But that simplicity hides a lot of moving parts, and the moment something doesn’t behave the way you expect, that hidden complexity becomes very real.

This article is a deep dive into what’s actually happening under the hood: why Flutter was built the way it was, how it’s structured, and how the pieces you use every day actually work together. The goal isn’t to turn you into a framework-internals expert for its own sake — it’s to give you a clearer mental model, so the next unexpected bug or performance issue feels less like a mystery and more like something you can reason through.

· Flutter advantages
·
Flutter History
·
The Flutter architecture consists of three main layers:
·
The Flutter framework consists of several sublayers:
·
Flutter Rendering Engine
·
Widgets
∘
What Is a Widget?
∘
Building widgets
∘
StatelessWidget — Set It and Forget It
∘
StatefulWidget — Built for Change
∘
Under the Hood: The Element Tree
∘
How Flutter Builds the Element Tree
∘
StatefulWidget: The Extra Step
∘
Triggering Updates with setState
∘
State Lifecycle
∘
The Full Lifecycle at a Glance
·
Keys
∘
When Do You Need Keys?
∘
How Keys Work?
∘
Swapping without Key
∘
Swapping With Key
∘
Where to use keys?
∘
Choosing the Right Key?
·
Inherited Widget
∘
InheritedWidget: Reach Up Instead of Passing Down “lifting state up”
·
Widget Performance considerations
∘
When Does build() Actually Run?
·
Conclusion

Flutter is a software development toolkit (framework) from Google for building cross-platform apps by using dart as its programming language.

Flutter advantages

1- open-source: That means you can create your own packages or contribute code.

2- uses the Dart programming language: which is a modern UI-focused language that’s compiled to native ARM or x86 (Mobile app) or to JavaScript (web app). It supports async/await for concurrency management and type inference for clean type-safe code.

3- Hot reload: which allows you to make updates to the code and the UI then deliver them live to emulators and devices without having to reload state or recompile your app.

4- Hot restart: which takes a little longer than hot reload because it loads the changes, restarts the app and resets the state, but it’s still faster than a full restart, which recompiles and redeploys.

These restart features use Dart’s VM to inject the updated code, so they’re only available in debug mode and not in a production app.

5- Declarative Ui: you describe the Ui you want and flutter framework handles building and updating it using Google’s Material Design for android apps or Cupertino widgets to get an iOS-like appearance.

6- Widgets based: Everything is a widget, you build your Ui by composing components for layout, styling, text and controls.

7- Platform integration: This is important for accessing libraries written in other languages or using platform-specific features that don’t have a Flutter support package yet. Flutter supports C and C++ interoperability as well as platform channels for connecting to Kotlin and Java on Android and Swift or Objective-C on iOS.

Press enter or click to view image in full size

Flutter History

Flutter comes from a tradition of trying to improve web performance. It’s built on top of several open-source technologies developed at Google to bring native performance and modern programming to the web through Chrome.

The Flutter architecture consists of three main layers:

1- The Framework layer is written in Dart and contains the high-level libraries that you’ll use directly to build apps.

2- Plugins: high-level features like JSON serialization, geolocation, camera access, in-app payments and so on.

3- The Engine layer contains the core C++ libraries that make up the primitives that support Flutter apps. The engine implements the low-level primitives of the Flutter API, such as I/O, graphics, text layout, accessibility, the plugin architecture and the Dart runtime.

4- The Embedder is different for each target platform and handles packaging the code as a stand-alone app or embedded module.

Press enter or click to view image in full size

The Flutter framework consists of several sublayers:

1- UI theme: which uses either the Material (Android) or Cupertino (iOS) design language. This affects how the controls appear, allowing you to make your app look just like a native one.

2- The widget layer: This is where you compose design and interactive elements to make up the app.

3- The rendering layer: which is the abstraction for building a layout.

4- The foundation layer: provides basic building blocks, like animations and gestures, that build up the higher layers.

Press enter or click to view image in full size

Flutter Rendering Engine

1- Skia is another Google-based open-source project that powers the graphics on Android, Chrome browsers, Chrome OS and Firefox. It runs directly on the GPU using Vulkan on Android and Metal on iOS, making the graphics layer fast on mobile devices.

2- Impeller it brings better performance and consistency because it has been made specifically for Flutter. It provides the same benefits as Skia and improves upon them.

As of the 3.27 release, Impeller is the default rendering engine for both iOS and Android API 29+.

Widgets

Flutter has one core promise: everything is a widget. Whether it’s a button, a padding offset, a text label, or the entire app structure — it’s all a widget.

What Is a Widget?

A widget is an immutable declaration of a piece of UI. Think of it as a blueprint — a description of what should appear on screen, not the thing itself.

Widgets describe everything: visual elements like text and buttons, as well as layout and structural elements like padding, alignment, and spacing. Because they’re immutable, they can’t be changed once created. Instead, when something needs to update, Flutter builds a new widget to replace the old one.

Widgets are also composable by design. Rather than building one large monolithic widget, you nest many small, single-purpose widgets together. This is what gives Flutter its characteristic deep widget tree — and its extraordinary flexibility.

Building widgets

Every widget must implement a build() method. This method returns another widget, and it's how Flutter knows what to display.

Press enter or click to view image in full size
import 'package:flutter/material.dart';

void main() => runApp(const MyApp());
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
appBar: AppBar(
title: const Text('My Home Page'),
),
body: Center(
child: Builder(
builder: (context) {
return Column(
children: [
const Text('Hello, World!'),
const SizedBox(height: 20),
ElevatedButton(
onPressed: () => print('Click!'),
child: const Text('A button'),
),
],
);
},
),
),
),
);
}
}

The build() method is called when the widget is first created and whenever its dependencies change (such as state passed into it). It should be a pure function — no side effects, just a description of the UI based on the current data.

StatelessWidget — Set It and Forget It

A StatelessWidget holds no mutable state. It receives data through its constructor and renders accordingly. Once built, it won't change unless its parent rebuilds it with different data.

class DogName extends StatelessWidget {
final String name;

const DogName(this.name);

@override
Widget build(BuildContext context) {
return DecoratedBox(
decoration: BoxDecoration(color: Colors.lightBlueAccent),
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Text(name),
), // Padding
); // DecoratedBox
}
}

The DogName widget was a perfect fit for a stateless widget because it only needed a string for a name, which would not change. It did not have any properties that would be updated over time, such as user input or animations.

class DogApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'My Dog App',
home: Scaffold(
appBar: AppBar(
title: Text('Yellow Lab'),
), // AppBar
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
DogName('Rocky'),
SizedBox(height: 8.0),
DogName('Lyla'),
SizedBox(height: 8.0),
DogName('Nico'),
],
), // Column
), // Center
), // Scaffold
); // MaterialApp
}
}

Use StatelessWidget whenever your widget’s output depends only on the configuration it receives — and that configuration won’t change during runtime.

StatefulWidget — Built for Change

When a widget needs to track data that changes over time — user input, animations, counters, network responses — you reach for StatefulWidget.

A StatefulWidget is actually split into two classes:

  • The widget itself, which holds immutable configuration.
  • A State object, which holds the mutable data and owns the build() method.
class ItemCounter extends StatefulWidget {
final String name;

ItemCounter({this.name});

@override
_ItemCounterState createState() => _ItemCounterState();
}

class _ItemCounterState extends State<ItemCounter> {
int count = 0;

@override
Widget build(BuildContext context) {
return Text('${widget.name}: $count');
}
}

StatefulWidget itself has no build() method — that responsibility lives entirely in the State object. The widget just holds the static data (name), while the state object owns the dynamic data (count) and uses both to build the UI.

Under the Hood: The Element Tree

Here’s a crucial insight that many Flutter developers overlook early on:

Widgets are not what you see on the screen. They are only blueprints.

What you actually see is the element tree — the “live” representation of the UI that Flutter constructs from those blueprints.

Widgets are extremely cheap. In the Dart programming language, creating a Widget is just allocating a tiny bit of memory for a configuration file. Dart can create thousands of Widgets in a millisecond. Flutter does not care about throwing away widgets; they are cheap, disposable blueprints.

Elements are heavy. Elements manage the actual screen, layout, and memory. Destroying and recreating Elements is slow and expensive.

How Flutter Builds the Element Tree

When Flutter processes a widget, it follows these steps:

  1. createElement — Flutter calls the widget’s createElement() method. For a StatelessWidget, this produces a StatelessElement. For a StatefulWidget, it produces a StatefulElement.
  2. Mounting — The element is placed onto the element tree, with a direct reference back to the widget that created it.
  3. Build children — The element asks the widget for its children by calling build(). Those children create their own elements, which get mounted in turn — and so on, recursively.
  4. Layout & Rendering — Once the full element tree is assembled, Flutter performs layout (computing sizes and positions) and rendering (painting to the screen).
Press enter or click to view image in full size

StatefulWidget: The Extra Step

For stateful widgets, mounting triggers one additional step: the StatefulElement calls createState() to generate a State object. This state object:

  • Holds a reference back to the widget (giving it access to immutable config like name).
  • Owns all mutable values (like count).
  • Implements build() to produce the widget's children.
class ItemCounter extends StatefulWidget {
final String name;

ItemCounter({this.name});

@override
_ItemCounterState createState() => _ItemCounterState();
}

class _ItemCounterState extends State<ItemCounter> {
int count = 0;

@override
Widget build(BuildContext context) {
return Text('${widget.name}: $count');
}
}
Press enter or click to view image in full size

Triggering Updates with setState

So how do you tell Flutter that something has changed and the UI needs to rebuild? You call setState().

class _ItemCounterState extends State<ItemCounter> {
int count = 0;

@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () {
setState(() {
count++;
});
},
child: Text('${widget.name}: $count'),
);
}
}

Here’s what happens when setState() is called:

  1. The function you pass in modifies the state (e.g., count++).
  2. The state object marks its element as “dirty”.
  3. On the next frame (Flutter targets 60fps), the element calls build() again to get a fresh widget.
  4. Flutter compares the new widget to the old one. If it’s the same type (e.g., still a Text), the existing element is reused and simply updated — no tear-down, no recreation.
Press enter or click to view image in full size

This diffing strategy is what keeps Flutter performant even as your widget tree grows.

State Lifecycle

State is data that can be read synchronously when a widget is built and may change during the widget’s lifetime.

While a StatefulWidget is itself immutable (like all widgets), its associated State object persists across rebuilds. This object is the bridge between your business logic and your UI logic — it holds the values that drive the UI and owns the build() method that describes it.

  • The Four Phases of a State Object’s Lifecycle

Flutter’s State lifecycle is divided into four phases. Each phase has specific methods that fire in a predictable order. Knowing when each runs — and what you should or shouldn't do inside them — is essential for writing correct, efficient Flutter code.

Press enter or click to view image in full size

Phase 1: Initialization

This phase runs once, when the widget is first inserted into the tree.

createState() The framework calls this on the StatefulWidget to produce a fresh State object. You define this method, but you rarely put logic inside it — it just returns the state instance.

mounted = true Once the state object is created, it is permanently associated with a BuildContext. The mounted property becomes true, meaning the widget is now part of the live tree.

initState() This is your one-time setup hook. Use it to initialize variables, subscribe to streams, or set up animation controllers — anything that should happen exactly once at the start of the widget's life.

@override
void initState() {
super.initState(); // Always call super first
_counter = 0;
}

Rule of thumb: initState() is for setup, not for reading context. The widget isn't fully mounted yet, so accessing context-dependent data here (like MediaQuery) will cause errors.

Phase 2: Dependencies & Rendering

After initialization, Flutter moves into the rendering phase. This is where your widget first appears on screen — and where it responds to external changes.

didChangeDependencies() Called immediately after initState(), and again whenever an InheritedWidget that this widget depends on changes (think MediaQuery, Theme, or a custom InheritedWidget). This is the right place to read context-dependent data.

@override
void didChangeDependencies() {
super.didChangeDependencies();

// Safe to use context here
// This is an instant calculation. NO setState() needed.
double _screenSize= MediaQuery.sizeOf(context);

// This is a slow internet request.
fetchDataFromInternet().then((data) {
// The build() method already ran while we were waiting for the internet!
// So, we MUST call setState() here to tell Flutter to redraw again now that the data arrived.
setState(() {
myData = data;
});
});
}
import 'package:flutter/material.dart';

class PhotoGallery extends StatefulWidget {
@override
_PhotoGalleryState createState() => _PhotoGalleryState();
}

class _PhotoGalleryState extends State<PhotoGallery> {
double _calculatedGridSize = 0.0;

@override
void initState() {
super.initState();
// ❌ WRONG: MediaQuery.sizeOf(context) will crash here!
// The widget isn't fully attached to the tree yet.
}

@override
void didChangeDependencies() {
super.didChangeDependencies();

// RIGHT: The context is perfectly safe to use here.
// This code only runs when the widget is born, OR if the screen size changes.
double screenWidth = MediaQuery.sizeOf(context).width;

// Imagine this is a heavy, slow mathematical formula
_calculatedGridSize = (screenWidth * 0.9) / 3;
print("didChangeDependencies ran! Grid size calculated: $_calculatedGridSize");
}

@override
Widget build(BuildContext context) {
// ❌ AVOID: Do not do heavy math calculations here.
// build() runs constantly, which would waste CPU power.

return Scaffold(
appBar: AppBar(title: Text("My Gallery")),
body: Center(
child: Container(
width: _calculatedGridSize, // Uses the pre-calculated size
height: 200,
color: Colors.blue,
child: Center(child: Text("Gallery Grid")),
),
),
);
}
}

build() The one unconditional method in the render phase. It's called after initState(), after didChangeDependencies(), and after every setState() call. It must be a pure function — no side effects — that simply returns a widget tree description

@override
Widget build(BuildContext context) {
return Text('Counter: $_counter');
}

Phase 3: Updates & Hot Reload

This phase handles the widget’s life while it remains in the tree: parent rebuilds, property changes, and development-time hot reloads.

didUpdateWidget() When a parent widget rebuilds and passes a new widget of the same runtimeType and key, Flutter updates the widget property on the state object and calls didUpdateWidget(). This gives you a chance to react to changed configuration.

A classic real-world example: a video player that should restart when the video ID changes.

1. The Scenario: You are watching a video with videoId: "ABC". Suddenly, the user clicks a "Next Video" button.

2. The Rebuild: The parent widget rebuilds and creates a brand-new VideoPlayer widget with videoId: "XYZ".

3. Flutter steps in: Flutter sees the widget type is still VideoPlayer, so it keeps your existing VideoPlayerState alive. It swaps out the old widget for the new one and calls: didUpdateWidget method

@override
void didUpdateWidget(covariant VideoPlayer oldWidget) {
super.didUpdateWidget(oldWidget);
// Only perform expensive logic if the relevant property actually changed
if (widget.videoId != oldWidget.videoId) {
developer.log(
'Video ID changed from ${oldWidget.videoId} to ${widget.videoId}',
name: 'video.player',
);
_stopPlayer();
_status = 'Re-initializing for: ${widget.videoId}';
_startPlayer();
}
}

Important: Do not call setState() inside didUpdateWidget(). The framework automatically calls build() afterward — calling setState() would schedule a redundant rebuild.

reassemble() This method exists purely for development. It's triggered during a hot reload, giving you a chance to reinitialize data that wouldn't otherwise update. You'll rarely override this in production code.

Phase 4: Destruction

When a widget is removed from the tree, Flutter fires two destruction methods in sequence.

deactivate()The “Hold On a Second” Stage : Called when the state is temporarily removed from the tree. Crucially, the widget might be reinserted before the end of the current frame (for example, when navigating between routes with animations). For this reason, you should not release long-lived resources here.

@override
void deactivate() {
// The widget might come back — don't release permanent resources here
developer.log(
'Widget deactivated: potentially moving in the tree',
name: 'lifecycle',
);
super.deactivate();
}

dispose()The “Permanent Trash Can” Stage: This is the terminal stage. Once dispose() is called, the widget will never be built again. This is the correct place to release all resources — controllers, subscriptions, timers, I/O handles.

@override
void dispose() {
// 1. Dispose any controllers
_controller.dispose();
_scrollController.dispose();
// 2. Cancel timers or stream subscriptions
// _mySubscription.cancel();
// 3. Always call super.dispose() last
developer.log('Widget disposed permanently.', name: 'lifecycle');
super.dispose();
}

Different between deactiivate and dispose

Imagine you are watching a YouTube video in a Flutter app.

  1. You are watching it in full screen.
  2. You swipe down, and the video shrinks into a tiny “Picture-in-Picture” mini-player in the corner of your screen.
  3. The video never stops playing during this shrink.

This is where deactivate saves the day!

To shrink the video, Flutter literally has to pick the VideoPlayer widget up out of the "Full Screen" part of the tree, and drop it into the "Mini Player" part of the tree.

1. The Pick-Up (deactivate) When Flutter picks the video up, it disconnects it from the screen. Flutter calls deactivate().

  • The widget says: “I have been removed from my spot! But I will keep the video playing just in case you put me back down.”

2. The Drop-Off (No dispose!) A split-second later, Flutter drops the widget into the corner mini-player. Because the widget was placed back onto the screen before the frame ended, dispose() is never called. The video continues playing smoothly without buffering or restarting.

3. The X Button (dispose) Finally, you get bored and click the "X" on the mini-player to close it entirely. Flutter removes the widget and waits. A fraction of a second passes, and the widget is not put anywhere else.

  • Flutter says: “Okay, you are officially trash.” It calls dispose(), and your code destroys the video controller to stop the audio and free up phone memory.
import 'package:flutter/material.dart';

class MyVideoPlayer extends StatefulWidget {
// A GlobalKey allows Flutter to move this exact widget anywhere on the screen
MyVideoPlayer({Key? key}) : super(key: key);

@override
_MyVideoPlayerState createState() => _MyVideoPlayerState();
}

class _MyVideoPlayerState extends State<MyVideoPlayer> {
late VideoController _controller;

@override
void initState() {
super.initState();
_controller = VideoController()..play();
}

@override
void deactivate() {
// 🟡 The widget was picked up!
// We DO NOT stop the video here. It might just be moving to the corner of the screen.
print("deactivate: I was picked up, but I am still playing!");
super.deactivate();
}

@override
void dispose() {
// 🔴 The widget was permanently deleted.
// NOW we stop the video so we don't waste the user's battery.
print("dispose: I am dead. Stopping the video.");
_controller.stop();
_controller.dispose();
super.dispose();
}

@override
Widget build(BuildContext context) {
return Container(
color: Colors.black,
child: Center(child: Text("Playing Video...")),
);
}
}

mounted = false After dispose(), the mounted property becomes false. Calling setState() at this point is an error and will throw an exception in debug mode. This check becomes critical in async operations.

  • The Critical async Pattern: Always Check mounted

Async operations are one of the most common sources of lifecycle bugs. If the user navigates away while a network request is in flight, the widget may be disposed before the response arrives. Calling setState() on a disposed widget throws an error.

Always guard async setState calls with a mounted check:

Future<void> _loadData() async {
final data = await _apiService.fetchData();
// The user may have navigated away while we were waiting.
// If the widget is no longer in the tree, do nothing.
if (!mounted) return;
// Safe to update the UI
setState(() {
_content = data;
});
}
  • setState Best Practices

Keep your setState() closures lean. Logic and calculations belong outside the closure; only pure state assignments go inside.

void _incrementCounter() {
// Perform calculations outside the closure
int newValue = _counter + 1;
setState(() {
// Only assign inside setState — no logic here
_counter = newValue;
});
}

This keeps your rebuild logic clear and makes it easy to audit exactly what changed.

The Full Lifecycle at a Glance

Widget inserted into tree
│
▼
createState() ← Produces the State object
│
▼
mounted = true ← State is now associated with a BuildContext
│
▼
initState() ← One-time setup
│
▼
didChangeDependencies() ← Called now, and when InheritedWidgets change
│
▼
build() ← Renders the UI ◄──────────────────┐
│ │
▼ │
didUpdateWidget() ← Parent passed a new widget │
│ │
└───── setState() called ──────────────────────────►┘

Widget removed from tree
│
▼
deactivate() ← Temporary removal (widget may return)
│
▼
dispose() ← Permanent removal — release all resources
│
▼
mounted = false ← setState() calls now illegal

Keys

Keys in Flutter: a specialized parameter used to preserve state when widgets move within your widget tree.

When Do You Need Keys?

In most cases, you do not need keys. They become necessary only when you are adding, removing, or reordering a collection of widgets of the same type that hold local state (like colors, scroll position, or user input) which must follow a widget as it moves.

If your entire widget subtree is stateless, keys are not needed

How Keys Work?

Flutter uses two trees to render your app: the Widget Tree and the Element Tree.

· The Element Tree (The Skeleton): This tree holds the structure of your app and references to the actual widgets and their state objects.

· Matching Logic: When a widget moves, Flutter walks the element tree to see if the structure is the same. It checks if the new widget has the same type and key as the old element.

Get Aly Route’s stories in your inbox

Join Medium for free to get updates from this writer.

· The Problem: Without keys, Flutter only checks the type. If the type matches (e.g., two widgets of the same class), Flutter updates the reference but may leave the old state attached to the wrong widget.

Swapping without Key

before swapping without key
after swapping without key

Swapping With Key

before swapping with key
after swapping with key
import 'dart:math';

import 'package:flutter/material.dart';

void main() {
runApp(const MyApp());
}

class MyApp extends StatelessWidget {
const MyApp({super.key});

// This widget is the root of your application.
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
// This is the theme of your application.
//
// TRY THIS: Try running your application with "flutter run". You'll see
// the application has a purple toolbar. Then, without quitting the app,
// try changing the seedColor in the colorScheme below to Colors.green
// and then invoke "hot reload" (save your changes or press the "hot
// reload" button in a Flutter-supported IDE, or press "r" if you used
// the command line to start the app).
//
// Notice that the counter didn't reset back to zero; the application
// state is not lost during the reload. To reset the state, use hot
// restart instead.
//
// This works for code too, not just values: Most code changes can be
// tested with just a hot reload.
colorScheme: .fromSeed(seedColor: Colors.deepPurple),
),
home: SwapTilesScreen(),
);
}
}

class SwapTilesScreen extends StatefulWidget {
@override
_SwapTilesScreenState createState() => _SwapTilesScreenState();
}

class _SwapTilesScreenState extends State<SwapTilesScreen> {
// We put two tiles in a list.
List<Widget> tiles = [
StatefulColorTile(key: UniqueKey()),
StatefulColorTile(key: UniqueKey()),
];

void swapTiles() {
setState(() {
// This swaps the position of the two tiles in the list
tiles.insert(1, tiles.removeAt(0));
});
}

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text("Swap Tiles Example")),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Row(mainAxisAlignment: MainAxisAlignment.center, children: tiles),
SizedBox(height: 20),
ElevatedButton(onPressed: swapTiles, child: Text("Swap Tiles")),
],
),
),
);
}
}

// ---------------------------------------------------
// The Stateful Tile (Where the color is trapped in State)
// ---------------------------------------------------
class StatefulColorTile extends StatefulWidget {
// Notice we accept a Key in the constructor
StatefulColorTile({Key? key}) : super(key: key);

@override
_StatefulColorTileState createState() => _StatefulColorTileState();
}

class _StatefulColorTileState extends State<StatefulColorTile> {
late Color myColor;

static const _palette = [
Colors.red,
Colors.blue,
Colors.orange,
Colors.green,
Colors.purple,
Colors.teal,
];

@override
void initState() {
super.initState();
// The color is saved into the State memory ONLY ONCE when created.
myColor = _palette[Random().nextInt(_palette.length)];
}

@override
Widget build(BuildContext context) {
return Container(
width: 100,
height: 100,
color: myColor, // It draws the color from the State, not the Widget!
);
}
}

Where to use keys?

A common mistake is placing the key too deep in the subtree. You must specify the key at the top of the widget subtree you need to preserve.

If you wrap a keyed widget in another widget (like a Padding), but leave the key on the inner widget, Flutter’s element-matching algorithm — which looks at one level of the tree at a time — will fail to find the match and will reset the state.

Press enter or click to view image in full size
Before Swapping with inner key

This is the widget tree and element tree before the tiles get shuffled. Nothing’s wrong yet — Row Widget matches Row Element, Padding Widget matches Padding Element, and each Stateful Tile Widget matches its Stateful Tile Element by key (2↔2, 1↔1). Tile 2 holds State red, tile 1 holds State blue. This is just the reference point before the button click swaps the children's positions.

Row(
children: [
Padding(
padding: const EdgeInsets.all(8),
child: StatefulTile(key: ValueKey(2)), // holds State: red
),
Padding(
padding: const EdgeInsets.all(8),
child: StatefulTile(key: ValueKey(1)), // holds State: blue
),
],
)
Press enter or click to view image in full size
After Swapping with inner Key

When the button is clicked, the two children swap positions. Flutter’s matching algorithm checks one level at a time:

  • First level (the Paddings): since the Padding widgets have no keys, they just match up by position — no problem here.
  • Second level (the tiles): now the slot that used to hold Stateful Tile Key: 2 (with State red) is being asked for Stateful Tile Widget Key: 1. Flutter compares the key of the existing element (2) against the incoming widget's key (1) — they don't match, so it rejects the reuse, because these are local keys, Flutter only searches for a match within that one level — it won't go hunting elsewhere in the tree for key 1.
Press enter or click to view image in full size
Create new element with new state because it fails to find the key matched on level of the tree

With no matching element found, Flutter creates a brand new element and initializes a brand new state for Stateful Tile Widget Key: 1 — which is why it turns into a random new color, orange, instead of keeping blue. The same mismatch happens for the other tile too. This is the bug in action: the state didn't transfer, it got reset.

  • The fix: Move the key up one level, onto the Padding — the actual top of the subtree you want preserved:
Padding(key: ValueKey(2), child: StatefulTile()), // State red preserved
Padding(key: ValueKey(1), child: StatefulTile()), // State blue preserved

Now when the children swap, Flutter matches the Padding elements correctly by their keys at that first level, updates the connections properly, and each tile keeps its correct color .

Choosing the Right Key?

A. ValueKey <T>

Use this when you have a specific piece of data (like an ID or a string) that makes the item unique. This is the most common key used in lists.

// Efficient and clear: identifies the widget by the item's ID
SizedBox(
key: ValueKey<String>(todoItem.id),
child: TodoItemWidget(item: todoItem),
);

B. ObjectKey

Use this when the object instance itself is the unique identifier, or when a combination of fields makes the object unique, but you don’t have a single unique ID.

// Uses the equality operator (==) of the User object
SizedBox(
key: ObjectKey(userObject),
child: UserProfile(user: userObject),
);

C. UniqueKey

Use this when you want to ensure the widget is treated as distinct every single time, or you don’t have unique data to bind to.

// Forces Flutter to destroy the old element and create a new one
Container(
key: UniqueKey(),
color: Colors.red,
);

Using UniqueKey inside a build method will cause the widget to lose its state on every frame because a new key is generated every time the parent rebuilds.

D. PageStorageKey

This is a specialized key used to persist a widget’s state (specifically scroll position) when the widget is destroyed and recreated later, such as in a TabBarView or BottomNavigationBar.

ListView.builder(
key: const PageStorageKey<String>('my_feed_list'),
itemBuilder: (context, index) => ListTile(title: Text('Item $index')),
)

Use Case: You have a long list in Tab A. You switch to Tab B, then back to Tab A. You want the list to still be scrolled to item #50.

E. GlobalKey

Global keys are unique across the entire app.

final GlobalKey<FormState> _formKey = GlobalKey<FormState>();

// In your build method:
Form(
key: _formKey,
child: // ...
);

// Later, to validate the form from a button:
void _submit() {
if (_formKey.currentState!.validate()) {
// Process data
}
}

Use Case 1 (Reparenting): Moving a widget from one screen to another while keeping its state alive (e.g., a video player continuing to play while navigating).

Use Case 2 (Accessing State): accessing the State object of a widget from outside its build context (though generally, you should use callbacks or state management instead).

In Flutter, keys help the framework keep track of specific widgets, especially when they move around in a list or when the screen updates. If you have ever created a custom widget and noticed your editor suggesting super.key, that is just a shortcut allowing your custom widget to accept these keys and pass them up to Flutter.

Inherited Widget

If you’ve built more than a couple of screens in Flutter, you’ve probably some piece of data — a theme, a logged-in user, a language setting — lives near the top of your widget tree, but the widget that actually needs it is five, six, ten levels deep. So you do the obvious thing: you add a parameter to every constructor in between, just to shuttle that value past widgets that don’t even use it.

That’s called prop drilling like below :

class TopWidget extends StatelessWidget {
final String username = 'Ali';

@override
Widget build(BuildContext context) {
return MiddleWidgetA(username: username);
}
}

class MiddleWidgetA extends StatelessWidget {
final String username;
const MiddleWidgetA({required this.username});

@override
Widget build(BuildContext context) {
// Doesn't use username at all — just passes it along.
return MiddleWidgetB(username: username);
}
}

class MiddleWidgetB extends StatelessWidget {
final String username;
const MiddleWidgetB({required this.username});

@override
Widget build(BuildContext context) {
// Still just passing it along.
return DeepChild(username: username);
}
}

class DeepChild extends StatelessWidget {
final String username;
const DeepChild({required this.username});

@override
Widget build(BuildContext context) {
return Text('Hello, $username'); // finally, someone uses it
}
}

InheritedWidget: Reach Up Instead of Passing Down “lifting state up”

Now picture an InheritedWidget sitting near the top of the tree instead of a plain Data object. Any descendant — no matter how deep — can call a lookup method and grab the data directly from the source, skipping every widget in between. No constructor parameters.

You’ve almost certainly used this without realizing it. Every time you write:

Theme.of(context);
MediaQuery.of(context);
Navigator.of(context);

you’re doing an inherited-widget lookup. Theme.of(context) walks up the tree to find the nearest Theme (which wraps an InheritedWidget internally) and hands you its data — no matter how deeply nested your widget is.

  • How to Implement an Inherited Widget
  1. Extend the Class: Create a new class that extends Inherited Widget.
  2. The Child Parameter: Your widget must accept at least one parameter: the child.
  3. Define Data: Add the specific data you want to share (e.g., a “nose” image asset) as a field within the class.

Immutability: Inherited widgets are immutable, meaning their fields should be marked as final. To change a field’s value, the entire widget must be rebuilt.

import 'package:flutter/material.dart';

// ==========================================
// 1. The InheritedWidget class
// ==========================================
class MyColorSettings extends InheritedWidget {
final Color myColor; // The data we want to share across the app

const MyColorSettings({
Key? key,
required this.myColor,
required Widget child,
}) : super(key: key, child: child);

// 2. A helper method to easily access the data from any child widget
static MyColorSettings? of(BuildContext context) {
return context.dependOnInheritedWidgetOfExactType<MyColorSettings>();
}

// 3. Determines if widgets should rebuild when the data changes
@override
bool updateShouldNotify(MyColorSettings oldWidget) {
return oldWidget.myColor != myColor;
}
}

// ==========================================
// 2. The entry point of the application
// ==========================================
void main() {
runApp(
// Wrap the entire app with our InheritedWidget and set the color to blue
MyColorSettings(
myColor: Colors.blue,
child: const MyApp(),
),
);
}

class MyApp extends StatelessWidget {
const MyApp({Key? key}) : super(key: key);

@override
Widget build(BuildContext context) {
return const MaterialApp(
debugShowCheckedModeBanner: false,
home: HomeScreen(), // The main screen of the app
);
}
}

// ==========================================
// 3. The screen that will use the color
// ==========================================
class HomeScreen extends StatelessWidget {
const HomeScreen({Key? key}) : super(key: key);

@override
Widget build(BuildContext context) {
// The magic happens here! We get the color directly from the InheritedWidget
// We use grey as a fallback color just in case the data is not found
final Color appColor = MyColorSettings.of(context)?.myColor ?? Colors.grey;

return Scaffold(
appBar: AppBar(
title: const Text('InheritedWidget Example'),
backgroundColor: appColor, // Using the color for the app bar
),
body: Center(
child: Container(
padding: const EdgeInsets.all(20),
color: appColor.withOpacity(0.3), // Using the same color with some transparency
child: Text(
'I got this color easily from the InheritedWidget!',
style: TextStyle(
fontSize: 18,
color: appColor, // Using the color for the text too
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
),
),
);
}
}
  • MediaQuery allows your app to “listen” to the physical properties of the screen and the system settings of the user’s device.

1. Getting Screen Dimensions (Width and Height)

Instead of giving a widget a fixed size (like 200 pixels), you use MediaQuery to make it a percentage of the screen. This ensures your app looks good on small phones, large tablets, and web browsers.

// Makes a widget take exactly 50% of the screen width.
MediaQuery.sizeOf(context).width * 0.5

2. Handling Device Orientation

It tells you if the user is holding their phone vertically or horizontally. You can use this to completely change your UI layout.

// Returns Orientation.portrait or Orientation.landscape.
MediaQuery.orientationOf(context)

3. Managing System UI and Safe Areas (Insets & Padding)

It helps you detect parts of the screen that are blocked by the operating system. This prevents your app’s content from hiding behind the camera notch, the top status bar, or the digital keyboard when it pops up.

/*tells you how much space the notch or status bar is taking
and MediaQuery.viewInsetsOf(context) tells you how much space the
keyboard is currently covering.*/
MediaQuery.paddingOf(context)

4. Respecting User Settings (Accessibility)

Users can change their system settings to make text larger or switch to a dark theme. MediaQuery reads these settings so your app can adjust automatically.

// ensures your app's font size scales up if the user needs larger text
// for better vision.
MediaQuery.textScalerOf(context)

// tells you if the phone is currently in Dark Mode or Light Mode.
MediaQuery.platformBrightnessOf(context)
  • Example using MediaQuery to listen for screen size
import 'package:flutter/material.dart';

void main() {
runApp(const MyApp());
}

class MyApp extends StatelessWidget {
const MyApp({Key? key}) : super(key: key);

@override
Widget build(BuildContext context) {
return const MaterialApp(
debugShowCheckedModeBanner: false,
home: ResponsiveScreen(),
);
}
}

class ResponsiveScreen extends StatelessWidget {
const ResponsiveScreen({Key? key}) : super(key: key);

@override
Widget build(BuildContext context) {
// 1. Get the screen size using MediaQuery (which is an InheritedWidget)
// Whenever the screen size changes, this build method runs again automatically!
final Size screenSize = MediaQuery.of(context).size;

// 2. Determine if the screen is in landscape or portrait mode
final bool isLandscape = screenSize.width > screenSize.height;

return Scaffold(
appBar: AppBar(
title: const Text('MediaQuery & InheritedWidget'),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
// Display the current width and height
'Width: ${screenSize.width.toInt()} \nHeight: ${screenSize.height.toInt()}',
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
),
const SizedBox(height: 20),

// 3. Change UI dynamically based on the screen width
Container(
// If landscape (wide), make it big. If portrait (tall), make it smaller.
width: isLandscape ? 300 : 150,
height: isLandscape ? 150 : 150,

// Change color based on screen orientation
color: isLandscape ? Colors.green : Colors.orange,

alignment: Alignment.center,
child: Text(
isLandscape ? 'Landscape Mode' : 'Portrait Mode',
style: const TextStyle(color: Colors.white, fontSize: 18),
),
),
],
),
),
);
}
}

let’s connect this code to what actually happens inside the device, step by step:

  • The Event: The user rotates the phone. The operating system (Android/iOS) sends a signal to the Flutter Engine that the screen dimensions have changed.
  • Catching the Event: A class called WidgetsBinding (which acts as the bridge between the Flutter Engine and the Framework) receives these new dimensions.
  • Updating the Data: The MaterialApp (which has a hidden MediaQuery wrapping your entire app) calls its build method. It passes a new MediaQueryData object with the new dimensions down to the MediaQuery widget.
  • The Quick Check (updateShouldNotify): The MediaQuery compares the old data with the new data. Since the width and height are different, it returns true.
  • The Smart Check (updateShouldNotifyDependent): Here is where the power of InheritedModel shines. Flutter asks itself: "Which widgets were watching the size specifically?"

Any widget that used MediaQuery.sizeOf(context) will be forced to Rebuild. If a widget was only watching the text size using MediaQuery.textScalerOf(context) and the text size did not change, it will NOT Rebuild!

  • Practical Application & Engineering Recommendation

Avoid using MediaQuery.of(context).size as much as possible.

Why? Because of(context) makes your screen subscribe to everything inside MediaQueryData. If the dark mode theme changes, or if the keyboard opens up (which changes the Padding), your screen will rebuild even if you only needed the width and height.

The Correct Modern Usage: Use the specific methods that take advantage of InheritedModel (the smart part of the code):

// BEST PRACTICE: Only rebuilds when the screen size changes
final Size screenSize = MediaQuery.sizeOf(context);

// BEST PRACTICE: Only rebuilds when the padding changes (like when the keyboard opens)
final EdgeInsets padding = MediaQuery.paddingOf(context);

Widget Performance considerations

Performance depends heavily on how efficiently your widgets rebuild, build method is the core engine. Understanding exactly when it fires is the first step toward keeping your UI fluid.

When Does build() Actually Run?

Before optimizing anything, it helps to know what you’re optimizing against. A widget’s build method runs in three situations:

  • Initial insertion — the first time the widget is added to the widget tree.
  • Parent configuration changes — when the parent’s own state or configuration changes, triggering an Element.rebuild.
  • InheritedWidget dependencies — when the widget depends on an InheritedWidget (like Theme or MediaQuery) whose data changes.

Here are five techniques for minimizing the cost of all three.

1. Flatten the Widget Tree

Every widget you write becomes a node in a tree. Nest a Padding inside a Center inside a Container inside a Column, and Flutter has to manage, lay out, and paint each of those layers on its own.

Flattening the tree means reaching the same visual result with fewer widgets — often by folding one widget’s job into a property of another. It pays off three ways: better performance from fewer elements to traverse and render, cleaner code that avoids the “pyramid of doom,” and better memory efficiency from fewer allocated objects.

// Deep nesting: 5 levels of widgets
Widget build(BuildContext context) {
return Container(
color: Colors.blue,
child: Padding(
padding: const EdgeInsets.all(20),
child: Center(
child: SizedBox(
width: 200,
child: Text('Hello World'),
),
),
),
);
}
// Flattened: 2 levels of widgets
Widget build(BuildContext context) {
return Container(
color: Colors.blue,
alignment: Alignment.center, // replaces Center
padding: const EdgeInsets.all(20), // replaces Padding
child: const SizedBox(
width: 200,
child: Text('Hello World'),
),
);
}

Same output, three fewer widgets. Container already knows how to align and pad its child, so there's no need to wrap it in extra layers to do a job it can do itself.

2. Reach for const Whenever You Can

If flattening reduces how much tree exists, const reduces how much of it ever needs to rebuild.

Marking a widget const tells Flutter this exact instance will never change. The build engine uses that promise to skip rebuilding that branch entirely when the parent rebuilds — it doesn't need to check whether anything changed, because it already knows nothing did.

There’s a second, subtler benefit: canonicalization. const objects are canonicalized in Dart, meaning only a single instance is ever created in memory, no matter how many times const UserBadge(...) appears in your code.

class UserBadge extends StatelessWidget {
final String username;
final Color color; // A const constructor requires every field to be final

const UserBadge({
super.key,
required this.username,
this.color = Colors.blue,
});

@override
Widget build(BuildContext context) {
return Text(username, style: TextStyle(color: color));
}
}
Widget build(BuildContext context) {
return Column(
children: [
// ✅ Flutter knows this widget never needs to rebuild.
const UserBadge(username: 'Admin'),
// ...
],
);
}

Two requirements make this possible: every field has to be final, and the constructor itself has to be marked const.

3. Push Rebuilds Down to the Leaves

This is the single most valuable mental model here, so it’s worth sitting with.

When a widget calls Theme.of(context), it registers itself as a listener on that InheritedWidget. If the theme changes — the user toggles dark mode, say — that widget and everything below it rebuilds, whether or not the children actually care about the theme.

Picture a large StatelessWidget rendering a whole dashboard, where only a small footer at the bottom actually needs the theme color:

class MyDashboard extends StatelessWidget {
const MyDashboard({super.key});

@override
Widget build(BuildContext context) {
// ⚠️ Reading the theme here makes the WHOLE dashboard
// rebuild whenever the theme changes.
final color = Theme.of(context).primaryColor;
return Column(
children: [
Header(), // rebuilt unnecessarily
Sidebar(), // rebuilt unnecessarily
ContentBody(), // rebuilt unnecessarily
Container(
color: color,
child: const Text('Theme-dependent Footer'),
),
],
);
}
}

Rebuilding the header, sidebar, and content body every time someone flips a theme setting is pure waste — none of them read color. The fix is to move the Theme.of(context) call into a small, dedicated widget at the leaf of the tree, so it's the only thing that re-runs:

// A small, focused widget pushed to the leaf of the tree
class _ThemedFooter extends StatelessWidget {
const _ThemedFooter();

@override
Widget build(BuildContext context) {
// This widget is the only one listening to the InheritedWidget
final color = Theme.of(context).primaryColor;
return Container(
color: color,
child: const Text('Theme-dependent Footer'),
);
}
}

Now Header, Sidebar, and ContentBody can stay const and untouched. Only _ThemedFooter re-renders when the theme changes.

  • How to actually push rebuilds down: prefer a widget over a helper method

Once you buy into “push rebuilds to the leaves” the natural next question is how. It’s tempting to reach for a helper method — a private function returning a Widget — instead of a separate widget class. Resist it. A dedicated widget wins on every axis that matters.

A. Performance and rebuild optimization. If a small piece of UI — a favorite icon, say — lives inside a helper method, toggling it forces the entire parent to rebuild, wasting CPU time throughout the Element and RenderObject trees beneath it. Animate something inside a helper method and you may be redrawing an expensive chunk of UI 60 times a second for no reason. A separate widget lets Flutter target only that widget for re-rendering, and a const constructor lets it short-circuit the work entirely.

B. Testability. A standalone widget is simple to test in isolation — mount it, pump it, assert on it. A helper method drags along every dependency of its parent widget (any data, object, or setup the parent needs just to run without crashing), which gets painful fast when that parent is expensive to construct.

C. Accuracy and BuildContext. Helper methods are also a common source of stale BuildContext bugs — using the right value from the wrong location in the tree.

Picture your widget tree as a ladder:

  • Top rung: PageWidget, holding contextA
  • Middle rung: Scaffold, created inside PageWidget
  • Bottom rung: MyButton

To find the Scaffold, you need to be standing on the bottom rung, looking up. But a helper method called from PageWidget.build() is still executing code that belongs to the top rung — it's still holding contextA, no matter how deep the method chain goes. Ask that context to find something below it, and it simply can't see it:

class MyPage extends StatelessWidget {
@override
Widget build(BuildContext context) { // this is contextA — the top rung
return Scaffold(
body: _buildButton(context), // passing contextA into the helper
);
}

Widget _buildButton(BuildContext context) {
// BUG: still using contextA here.
// contextA sits above the Scaffold, so it can't find it.
Scaffold.of(context).openDrawer(); // throws / unexpected behavior
return Container();
}
}

Extract _buildButton into its own widget instead, and its build method receives a new BuildContext — one that sits below the Scaffold, on the correct rung, able to look up and find it without issue.

  • Case 2: Button Written Directly in Same build()
class MyPage extends StatelessWidget {
@override
Widget build(BuildContext context) { // context = MyPage's context
return Scaffold(
body: ElevatedButton(
onPressed: () {
Scaffold.of(context).openDrawer(); // ❌ still MyPage's context
},
),
);
}
}

Why it fails: Even though the button is visually inside Scaffold, the onPressed closure was written inside MyPage.build(). It captures MyPage's context — not a new one.

ٍSolution 1: Separate Widget Class

class MyPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
body: MyButton(), // just instantiate it, no context passed
);
}
}

class MyButton extends StatelessWidget {
@override
Widget build(BuildContext context) { // NEW context, belongs to MyButton
return ElevatedButton(
onPressed: () {
Scaffold.of(context).openDrawer(); // ✅ works!
},
);
}
}

Why it works: MyButton is a real, separate widget class. Its build() method gets a fresh context whose real position in the tree is below Scaffold.

Solution 2: Builder Widget

class MyPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
body: Builder(
builder: (BuildContext newContext) { // NEW context here
return ElevatedButton(
onPressed: () {
Scaffold.of(newContext).openDrawer(); // ✅ works!
},
);
},
),
);
}
}

Why it works: Builder is itself a widget. Its builder function gets a fresh context tied to its actual position in the tree — below Scaffold — without needing to write a whole new class.

4. Cache and Reuse Subtrees

Creating a new widget instance is meaningfully more expensive than reusing one you already have. There are two ways to avoid that redundant creation:

  • Manual caching — assign a widget to a final state variable once, and reuse it every time build runs.
  • const constructors — functionally equivalent to manual caching, letting Flutter short-circuit the rebuild automatically.
class MyOptimizedWidget extends StatefulWidget {
const MyOptimizedWidget({super.key});

@override
State<MyOptimizedWidget> createState() => _MyOptimizedWidgetState();
}

class _MyOptimizedWidgetState extends State<MyOptimizedWidget> {
late final Widget _cachedSubtree; // caching the subtree

@override
void initState() {
super.initState();
// This expensive subtree is created only once
_cachedSubtree = const DeepAndComplexSubtree();
}

@override
Widget build(BuildContext context) {
return Column(
children: [
_cachedSubtree, // reusing the same instance
const Text('Only this updates'),
],
);
}
}

Every time this widget rebuilds, _cachedSubtree is the exact same object it was on the previous build. Flutter doesn't need to re-create or re-diff it.

5. Keep Subtree Structure Consistent

Flutter doesn’t redraw the whole screen every time something changes. It runs a process called reconciliation: it compares the old tree shape to the new tree shape. If it finds the same widget type in the same position, it just updates the properties — no teardown, no rebuild.

The problem happens when the shape of the tree itself changes between builds.

return isBlocked
? IgnorePointer(child: MyWidget()) // when isBlocked = true
: MyWidget(); // when isBlocked = false

Look closely: when isBlocked flips from true to false, the tree's structure changes entirely:

  • Before: IgnorePointer → containing MyWidget (depth = 2 levels)
  • After: MyWidget alone (depth = 1 level)

Flutter sees that the widget type at this position has changed (it was IgnorePointer, now it's MyWidget), so it has no choice but to:

  1. Dispose everything old (IgnorePointer and the MyWidget inside it)
  2. Mount a brand-new MyWidget from scratch

This is expensive — especially if MyWidget is complex or holds internal state.

  • The Solution (Good)
return IgnorePointer(
ignoring: isBlocked, // only this value changes
child: MyWidget(),
);

So when isBlocked changes, Flutter sees:

  • Same type (IgnorePointer) in the same position
  • Same child (MyWidget) inside it

So it says “fine, this is the same element, I’ll just update the ignoring value" — no disposing, no rebuilding from scratch.

Conclusion

None of this is meant to be memorized as a rulebook. APIs change, patterns fall in and out of favor, and any specific tip in this article might look a little different a year from now. What doesn’t change is the value of understanding how things actually work underneath, instead of just copying a fix because it happened to work once.

So if there’s one thing to take away: the next time something behaves strangely, resist the urge to patch it and move on. Take a moment to ask why it happened. That habit will make you a better developer far more than any individual tip ever could.

If this was useful, I’d love to hear your thoughts in the comments — and a clap helps more people find it.

--

--

Mobile Dev [Flutter - Kotlin]

See more from me