NK

Search

Search pages, projects, posts, components, and icons

All articles
Mobile12 min read

Why Flutter Uses Widgets for Almost Everything

Padding, opacity, gestures, and theming are all widgets, and that uniformity is a deliberate trade, not an accident of API design.

flutterdartwidgetsfundamentals

The first thing that surprises people coming to Flutter from Android or the web is that padding is a widget. So is centring. So is opacity, gesture detection, scrolling, and the theme itself. Where UIKit gives you a property and CSS gives you a declaration, Flutter gives you another node in the tree.

The usual reaction is that this is verbose, and it is, a button with padding and a tap handler is three widgets deep before you have drawn anything. What is less obvious is that the verbosity buys something specific, and that most of the alternatives Flutter could have chosen are worse in ways you only discover at scale.

Glossary

Everything this post uses, defined before it is used. Skip it if the terms are already familiar, or come back when one of them trips you up.

The one sentence the rest of this post expands: Flutter puts each capability in its own small wrapper instead of as another setting on a big class, and that one choice explains most of what looks odd about the API.

Terms

TermMeaning
WidgetAn immutable description of a piece of screen. Not the thing on screen.
Widget treeThe nested structure your build methods return. A Column holding a Row holding two Text widgets is a tree three levels deep.
CompositionGetting a capability by wrapping something in another object.
InheritanceGetting a capability by extending a class that already has it.
Base classThe class everything else extends. Every feature added to it is paid for by every subclass.
ElementThe long-lived instance behind a widget, holding its place in the tree and its State.
RenderObjectThe object that actually does layout and painting. Expensive, so it is reused.
ReconciliationMatching a freshly built widget tree against the existing element tree to work out what really changed.
const constructorA constructor whose result is created once and reused. Flutter recognises the same instance and skips the subtree.
buildThe method that returns your description of the screen. Called often, expected to be cheap.
setStateMarking one element as needing a rebuild.
BuildContextA handle to a position in the tree. Lookups start from wherever it points.
InheritedWidgetA widget that exposes data to everything below it, with a targeted subscription.
ConstraintsThe size limits a parent gives a child. Tight means one allowed size, loose means a range.
CascadeCSS's system for deciding which of several competing rules wins. Flutter has no equivalent.
Garbage collectorThe part of the runtime that frees memory no longer in use.
KeyAn identity marker so state follows an item when it moves position.

Abbreviations

ShortFull formIn plain words
UIUser InterfaceEverything the user sees and touches
APIApplication Programming InterfaceThe set of classes and calls a framework offers you
CSSCascading Style SheetsThe web's styling language, the comparison this post keeps returning to
FAQFrequently Asked QuestionsThe question section near the end

Composition instead of a growing configuration surface

Every UI framework has to answer one question: when a component needs to do something new, where does that capability live?

The property-explosion problem

The traditional answer is a property on the component. A button needs padding, so it gets a padding property. Then a border, a shadow, a gradient, a ripple colour, a disabled state, an icon, icon spacing, a loading spinner.

Android's Button inherits from TextView, which inherits from View, which carries hundreds of attributes, most irrelevant to any given use. Every component pays the cost of every feature any component ever needed, and adding a capability means touching the base class.

A useful way to hold the difference: the property approach is a Swiss army knife that grows a new blade every time somebody needs one. Composition is a drawer of single-purpose tools you pick up in any combination. The knife is tidier to carry. The drawer is the one that still works for the job nobody anticipated.

Flutter's answer is to move the capability out into its own widget:

Padding(
  padding: const EdgeInsets.all(16),
  child: DecoratedBox(
    decoration: BoxDecoration(borderRadius: BorderRadius.circular(8)),
    child: GestureDetector(
      onTap: onPressed,
      child: const Text('Save'),
    ),
  ),
)

Four nodes, four single-purpose classes. Padding knows nothing about buttons. It wraps anything. That is the trade: more nesting in exchange for capabilities that combine freely instead of accumulating on a base class.

Uniformity has a compounding payoff

Because everything is a widget, everything obeys the same rules. There is one tree to reason about, one lifecycle, one way to insert behaviour.

That is why a Flutter package can offer you a capability as a wrapper and it just works, anywhere, on anything. There is no equivalent of "this only applies to views" or "this only works on block-level elements". A widget composes with every other widget by construction.

It also means learning the framework converges quickly. Once you understand build, keys, and constraints, you understand Padding, Hero, Theme, and a package you have never seen before, because all four are the same kind of thing.

Going deeper: data travels the same tree

Once every capability is a widget, the natural way to expose data is a widget too. That is what InheritedWidget is: a node children can look up by type and subscribe to.

In practice you have already used this. Theme.of(context) walks up the tree from where you are standing until it finds a Theme, and takes what it holds. The code below is that same mechanism, written out by hand.

class ThemeScope extends InheritedWidget {
  const ThemeScope({super.key, required this.palette, required super.child});
 
  final Palette palette;
 
  static Palette of(BuildContext context) =>
      context.dependOnInheritedWidgetOfExactType<ThemeScope>()!.palette;
 
  @override
  bool updateShouldNotify(ThemeScope old) => old.palette != palette;
}

Two things fall out of this that are easy to miss.

dependOnInheritedWidgetOfExactType does not merely read a value, it registers the calling element as a dependent. When updateShouldNotify returns true, exactly those elements rebuild and nothing else. That is a targeted subscription built out of the tree you already have, with no observer pattern and no reactive library.

It also explains the most common beginner error in Flutter: calling Theme.of(context) with a context from above the widget you meant. The lookup walks up from whichever element you hand it, so a context from the wrong depth finds the wrong ancestor, or none. The framework is not being fussy. You asked a different question than you thought.

Riverpod, Provider, MediaQuery, and Directionality are all this mechanism. Learning it once explains a large fraction of the ecosystem.

What is actually cheap about this

The objection is performance: surely wrapping everything in objects is expensive?

Widgets are not what is on screen

A widget is an immutable configuration object, a few fields, allocated and discarded constantly. It is not the thing that gets painted. Behind it sits an element (the persistent instance) and, for some widgets, a render object (the thing with geometry).

Crucially, most widgets have no render object of their own. Padding resolves to a RenderPadding, but Container is a composition helper that expands into a handful of others, and a StatelessWidget contributes nothing to the render tree at all. The render tree, the expensive one, is far shallower than the widget tree you write.

Going deeper: allocation is the cheap part

Dart's generational garbage collector is tuned for exactly this pattern: many short-lived objects that die young. Allocating a Padding on every frame is closer to free than it feels, and the framework leans on that assumption throughout.

What is not free is rebuilding a large subtree unnecessarily, or doing real work inside build. The cost of Flutter's uniformity shows up in reconciliation, not allocation, hence const matters and why where you call setState matters more than how many widgets you nested.

Going deeper: composition is why the framework stays small

Look at what Flutter does not have to ship. There is no layout property system, because layout is widgets. There is no styling cascade, because styling is widgets. There is no separate animation attribute syntax, because AnimatedContainer and TweenAnimationBuilder are widgets. Each of those, in a traditional toolkit, is a subsystem with its own rules, its own precedence order, and its own edge cases.

That is the trade being made. CSS gives you enormous expressive power in a compact syntax, at the cost of a cascade whose specificity rules are actually hard to reason about at scale. Flutter gives you no cascade at all: a widget either wraps a subtree or it does not, and there is no action at a distance. The markup is longer and the mental model is smaller.

It also means capabilities compose in orders the framework's authors never anticipated. Wrapping a Hero in an Opacity inside a RepaintBoundary inside a GestureDetector is not a supported combination anybody wrote down, it just works, because none of those four knows anything about the others.

Where the model shows its seams

Being honest about the trade means naming what it costs.

Nesting is harder to read. Six levels of indentation to express "padded card with a tap handler" is real, and no amount of explaining the architecture makes that pleasant. Extracting widgets aggressively is the fix, not because it is faster, but because it is legible.

Some things fit awkwardly. Theme as a widget means theming is position-dependent: a widget outside the Theme subtree does not get it, which surprises people who expect theming to be global. Same for MediaQuery and Directionality.

Errors point at the wrong place. Because behaviour lives in wrappers, a constraint violation is often reported at a node several levels from the one you need to change. The exception text names the render object that failed, but the widget you must edit is usually an ancestor that handed down the wrong constraints, so reading Flutter layout errors is a skill in itself.

Wrapping changes meaning silently. Adding a Center around a child does not just move it. It also changes the constraints that child receives from tight to loose. Nothing warns you, and the child may resize as a result. Every wrapper is a layout decision, even the ones that look purely cosmetic.

None of these is fatal, and each is a symptom of the same design that gives you free composition. It pays to be knowing they are the price rather than assuming you are holding it wrong.

Key takeaways

  • Capabilities live in widgets, not properties. That is what stops component classes accumulating hundreds of attributes.
  • Uniformity compounds. One tree, one lifecycle, one composition rule, so every package's widget works with every other.
  • Widgets are cheap descriptions, not the rendered output. The render tree is much shallower than the widget tree.
  • The real cost is reconciliation, not allocation, so const and where you hold state matter more than nesting depth.
  • The seams are legibility and position-dependence. Extract widgets early. Expect inherited widgets like Theme to be scoped, not global.

FAQ

Isn't this just composition over inheritance?

Yes, applied more thoroughly than most frameworks dare. The unusual part is not the principle but the consistency, Flutter applies it to layout, theming, and input handling too, where other frameworks stop at components.

Why is Container so common if it is just a composition helper?

Convenience. It bundles Padding, DecoratedBox, ConstrainedBox, Align, and Transform behind one constructor, and only creates the ones you actually configure. Using it is fine. Knowing it is sugar helps when you are reading a widget inspector tree.

Does deep nesting hurt performance?

Almost never at realistic depths. What hurts is a rebuild high in the tree dragging a large subtree with it, or expensive work inside build. Depth alone is not the problem.

Why do I sometimes need a Builder?

Because of(context) looks up from the context you pass, and inside a build method that context belongs to the widget being built, not to anything it returns. Builder inserts a new element, giving you a context below the widget you just created. It is a one-line fix for a lookup starting too high in the tree.

Is there a limit to how deep the tree should get?

No fixed one. Flutter apps routinely run trees hundreds of nodes deep without trouble, because most of those nodes never reach the render tree. Depth becomes a problem when it stops you reading the code, which happens well before it troubles the framework.

Should I extract every subtree into its own widget?

Extract for readability, and for rebuild isolation when a subtree is animating. A StatelessWidget with a const constructor also gives you a reconciliation short-circuit that an inline subtree cannot.

Conclusion

"Everything is a widget" reads like a slogan, but it is a real architectural decision with a real trade: verbosity at the call site in exchange for composability, uniformity, and a framework that stays small because capabilities live in leaf classes rather than base classes.

Once you stop reading nesting as clutter and start reading it as a list of capabilities being applied, most of Flutter's API surface stops feeling arbitrary.

References

Official documentation for the topics covered here.

Read more

For what happens underneath those widgets, the element tree, reconciliation, and where frames are actually spent, see How Flutter Rendering Actually Works.