First Month in Flutter: Getting Used to the Widget Tree
After six years of writing native apps in Swift, Java and Kotlin, I moved to Flutter. What made it hard was not the syntax of the language but the way you build a UI: immutable widgets, a build method that runs constantly, the scope setState actually covers, and where BuildContext sits in the tree. Here are the things that confused me in the first month and the mistakes I made, in order.
At the start of 2026, after six years of writing native apps in Swift, Java and Kotlin, I moved to Flutter. Vakti Geçmeden and Hadi Yapalım were shipped this way, and Sinepia is currently in app store review. What made the first month hard was not the language itself — Dart feels more than familiar to anyone who knows Swift and Kotlin. What made it hard was that the way you build a UI looked nothing like the model I had in my head.
The first wall coming from native: not a screen, a tree
In UIKit or on Android you hold a reference to a view and manipulate it: label.text = ..., button.isEnabled = false. The screen is an object, and you change that object's properties. There is no such thing in Flutter. You are not holding a view object you can mutate; you write a description that says "given the current state, the screen should look like this", and the framework runs that description over and over.
I wrote my first screen as if I were translating a ViewController line by line. The result worked, but it was written wrong: I collected everything in a single State class and rebuilt the entire screen on the slightest change. It took me a week to settle what corresponds to what.
| In native | The Flutter equivalent |
|---|---|
| UIViewController / Activity | A widget, plus a route on the Navigator |
| UIView / View | Widget — but the description of the view, not the view itself |
| Auto Layout / ConstraintLayout | Constraints go down, sizes go up, the parent sets the position |
| viewDidLoad / onCreate | initState |
| deinit / onDestroy | dispose |
| UITableView / RecyclerView + adapter | ListView.builder |
What is actually behind "everything is a widget"
You hear this sentence everywhere, but on its own it tells you nothing. The useful version is this: the widgets you write are immutable configuration objects. They are not the thing sitting on the screen. Flutter keeps a second tree behind the scenes — the element tree. Elements are the persistent ones; they carry the state, while the constraint and painting work is done by a third layer, the render objects.
When a rebuild happens, the framework compares the old widget with the new widget at the same position. If their types and keys match, the element stays in place and only updates its configuration. That is why creating dozens of widget objects inside build is not an expensive thing to do; the expensive part is the layout and painting that genuinely get recalculated afterwards.
Once I understood this, I also stopped using Container. Container does not do one thing; it is a convenience that wraps widgets like padding, alignment and decoration. If I know what I want, writing it directly is both more readable and lets me make it const.
class FiyatEtiketi extends StatelessWidget {
const FiyatEtiketi({super.key, required this.tutar});
final int tutar;
@override
Widget build(BuildContext context) {
return DecoratedBox(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Text('$tutar TL'),
),
);
}
}
Stateless or Stateful: the question I was asking wrong
In the early days I asked "should this be stateful?" for every screen. It was the wrong question. The right one is: where does this data live, and how much of the tree has to be rebuilt when it changes?
A stateless widget knows what to draw from the fields you give it; if the fields change, a new widget object arrives, and that is all. With stateful there are two separate objects: the widget object is still immutable and a new one is produced on every rebuild, but the State object stays where it is. Until I grasped this distinction, I copied a value coming from the parent into a field inside initState and then spent hours staring at it wondering "why is it not updating". You have to reach the parent's current value through widget.alanAdi; and when I genuinely need to compare, there is didUpdateWidget.
Lists brought up the matter of keys as well. When I sorted a list made of stateful rows, the rows' states got mixed up with each other, because the framework matches by position. Giving the rows a stable key fixed it.
The build method runs far more often than I assumed
On the native side viewDidLoad runs once and you write your code accordingly. build is not like that. It can be called by your own setState, by any parent widget rebuilding, on a theme change, when the screen dimensions change, and on every frame during a page transition animation.
I saw this most clearly when the keyboard opened. Opening the keyboard changes the screen's safe area dimensions, the widgets that depend on it get rebuilt, and the sorting operation I had put inside build ran from scratch every time. I made the same mistake by setting up a Firestore query inside build.
Starting a network request, setting up a listener, reading a file, running a heavy computation — none of that is build's job. build should only describe the current state, and it should give the same result no matter how many times it is called.
The cost of setState and the quiet contribution of const
setState does not perform a magical update. What it does is mark the element belonging to that State as dirty; on the next frame, that element's build method runs from the top. So its cost is proportional to the size of the tree underneath that method. If you put the whole screen inside a single State and call setState for a counter, you have also forced everything unrelated to the counter to be rebuilt.
The fix is to pull the changing part out into a separate widget class. Pulling it into a helper method does not work; because a method does not create a separate element, it does not draw a boundary. A separate class, on the other hand, both confines the rebuild within itself and allows a const constructor. When the same const instance shows up again in the same place, the framework does not update that subtree at all.
class SayacSatiri extends StatefulWidget {
const SayacSatiri({super.key});
@override
State<SayacSatiri> createState() => _SayacSatiriState();
}
class _SayacSatiriState extends State<SayacSatiri> {
int _adet = 0;
@override
Widget build(BuildContext context) {
return Row(
children: [
const UrunBasligi(), // const: not rebuilt
Text('$_adet'),
IconButton(
icon: const Icon(Icons.add),
onPressed: () => setState(() => _adet++),
),
],
);
}
}
BuildContext: your address in the tree
For a long time I thought BuildContext was "something like Context on Android". It is not. It is really your widget's position in the element tree. When you write Theme.of(context), the framework walks upwards from that point and finds the widget it is looking for. So what it finds depends on which context you hand it.
I made the classic mistake too: I tried to open a bottom sheet using the context of the build method that returns the Scaffold itself. Because that context sits above the Scaffold, the lookup failed. Using a Builder in the subtree and passing the context from there solved it.
Two more notes: doing this kind of upward lookup inside initState is not safe, the right place is didChangeDependencies. And after an await, a mounted check is mandatory before using the context or calling setState — if the user leaves the screen while waiting, the app throws.
Future, Stream and FutureBuilder
Dart's async/await feels familiar if you are coming from Swift and Kotlin, but the model underneath is different. Dart runs on a single event loop; await does not spin up a background thread, it just yields its turn. If there really is a heavy computation — parsing a large JSON, for example — you have to hand it to a separate isolate, and isolates do not share memory, they pass messages between each other. After the habit of shared threads on the Java side, this was something I had to learn separately.
A Future is a one-off result, while a Stream is a flowing sequence. Since I use Firebase, on most screens I work with Streams directly: I hand Firestore's snapshot stream to a StreamBuilder, and it takes care of setting up and tearing down the listener. The listener cleanup I used to write by hand on the native side takes care of itself here.
With FutureBuilder I made the sneakiest mistake of the first week. If you create the Future directly inside build, every rebuild starts a new request and the screen ends up permanently in a loading state. The Future has to be created once and stored.
// Wrong: every build starts a new request
Widget build(BuildContext context) {
return FutureBuilder<Kullanici>(
future: kullaniciGetir(widget.uid),
builder: (context, anlik) => ...,
);
}
// Right: create once, store it
late final Future<Kullanici> _kullanici;
@override
void initState() {
super.initState();
_kullanici = kullaniciGetir(widget.uid);
}
Checking snapshot.hasData is also not enough on its own. If you do not handle the error case separately, the user is left with a spinner that turns forever whenever a request fails.
Where the need for state management comes from
Before starting with Flutter I had read the state management debates and thought "why is this talked about so much". By the end of the first month I understood. When both the button at the bottom and the badge in the top bar need to know the number of items in the cart, you have no option other than moving the data up to a common ancestor. And the moment you move it, two problems arrive: the value gets passed hand to hand through the intermediate layers, and the setState at the top rebuilds the entire screen.
Before picking a package, I preferred to live with this problem using the framework's own tools. Holding a listenable object that carries the value and subscribing only the small widget that uses it is enough to narrow the rebuild boundary. Firestore streams already handle most of the rest: the data comes from a single source and the screen looks at it.
The real thing to learn in Flutter is not the list of widgets but these three questions: where does this state live, how much of the tree gets rebuilt when it changes, and where in the tree is this context looking.
The years I spent on the native side were not wasted; the habits around lifecycle, memory and background work carry over as they are. The only thing that changes is dropping the view of the UI as "an object you mutate" and seeing it as "a function of state". Once I got past that threshold, the rest picked up speed.
- Flutter
- Dart
- Mobile Development
- Widget
- State Management
Demir Taşdemir
Mobile App & Web Developer
I have been building software since 2018. I have released 11 apps on the App Store and Google Play; right now I am working on 6 mobile apps, 1 e-commerce platform and 1 desktop game.
Let's talk about your project
If you have an app idea on the Flutter, Swift or Kotlin side, or a codebase that needs to be taken over, get in touch; let's work out the path together.