Flutter'da Her Şey Widget: StatelessWidget, StatefulWidget, BuildContext ve Üç Ağaç
Flutter'da widget ekrandaki şeyin kendisi değil, immutable bir konfigürasyon: rebuild'de widget'lar yenilenir, element'ler ve render object'ler yerinde kalır, State element'te yaşar. BookCard ve FavoriteButton üzerinden State'in yaşam döngüsünü Angular karşılıklarıyla, const'un rebuild'i kesmesini, await sonrası mounted kontrolünü ve key olmadan state'in yanlış satıra kaymasını gerçek test çıktılarıyla gösteriyoruz.
Sürüm notu: Bu yazıdaki örnekler Flutter 3.47.8 (Dart 3.13.7) ve material_ui 1.11.0 ile denenmiştir. Bütün çıktılar flutter test koşularından; emulator ve cihazda çalıştırma bu yazı için yapılmadı.
Önce Tahmin Et
Sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) mobil uygulamasında kullanıcı hediye listesi hazırlıyor: her satırda bir kitap ve satıra dokununca işaretlenen bir kutu. Satırın işaretli olup olmadığını satırın kendi State'i tutuyor. Liste şu: Dune, Emma, Middlemarch. Kullanıcı Dune'u işaretliyor, sonra Dune listeden çıkarılıyor.
before: [x] Dune, [ ] Emma, [ ] Middlemarch
after : ?
Kalan iki satırda ne görürsün: hiçbiri işaretli değil mi, yoksa Emma işaretli mi? Satırlara bir Key verirsen değişir mi? Bu yazının sonunda iki durumu da testle çalıştırıyoruz. Cevap, Flutter'ın ekranı nasıl yönettiğini anlamanın en kısa yolu.
Widget Bir Konfigürasyondur: StatelessWidget ve BookCard
Flutter'da ekrandaki her şey bir widget: metin, boşluk, hizalama, buton, ekranın kendisi. Ama widget ekrandaki şeyin kendisi değil, onun nasıl görünmesi gerektiğini anlatan immutable bir konfigürasyon. Bütün field'ları final, çoğunun constructor'ı const. Katalogdaki kitap kartı bir StatelessWidget:
import 'package:material_ui/material_ui.dart';
import '../../../domain/models/book.dart';
import 'favorite_button.dart';
class BookCard extends StatelessWidget {
const BookCard({super.key, required this.book, this.onAddToCart});
final Book book;
final VoidCallback? onAddToCart;
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
return Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(book.displayTitle, style: textTheme.titleMedium),
Text(
'${book.author.name} · ${book.publishedYear}',
style: textTheme.bodySmall,
),
Text(book.price.format(), style: textTheme.titleSmall),
],
),
),
FavoriteButton(isbn: book.isbn),
IconButton(
onPressed: onAddToCart,
tooltip: 'Add to cart',
icon: const Icon(Icons.add_shopping_cart),
),
],
),
),
);
}
}
build method'u, verilen konfigürasyondan bir alt ağaç üretiyor: Card içinde Padding, onun içinde Row, satırda kitap bilgileri, favori butonu ve sepete ekleme butonu. Angular'daki bir component'e benziyor: book bir input(), onAddToCart callback'i bir output()'un karşılığı. Fark, template'in ayrı bir dil değil, Dart kodu olması; Angular serisindeki "Angular'da Component İletişimi: @Input, @Output, @ViewChild ve ng-content" yazısındaki veri aşağı, event yukarı akışı burada constructor parametreleri ve callback'lerle kuruluyor.
BookCard'ın kendine ait değişen hiçbir verisi yok; aynı book ile her çağrıldığında aynı ağacı üretiyor. Kitap değişirse parent yeni bir BookCard oluşturur. Widget'lar bu yüzden ucuz: bir widget object'i oluşturmak, birkaç referans tutan küçük bir object ayırmak demek.
StatefulWidget ve State: FavoriteButton
Favori butonu dokunuldukça değişen bir bilgiyi tutmak zorunda. Bunun için iki class var: immutable StatefulWidget ve ona bağlı, değişebilen State:
import 'package:material_ui/material_ui.dart';
class FavoriteButton extends StatefulWidget {
const FavoriteButton({
super.key,
required this.isbn,
this.initiallyFavorite = false,
this.onChanged,
});
final String isbn;
final bool initiallyFavorite;
final ValueChanged<bool>? onChanged;
@override
State<FavoriteButton> createState() => _FavoriteButtonState();
}
class _FavoriteButtonState extends State<FavoriteButton> {
late bool _isFavorite;
@override
void initState() {
super.initState();
_isFavorite = widget.initiallyFavorite;
}
@override
void didUpdateWidget(FavoriteButton oldWidget) {
super.didUpdateWidget(oldWidget);
// Aynı State, farklı kitap: eski değeri tutmak yerine sıfırla
if (oldWidget.isbn != widget.isbn) {
_isFavorite = widget.initiallyFavorite;
}
}
void _toggle() {
setState(() => _isFavorite = !_isFavorite);
widget.onChanged?.call(_isFavorite);
}
@override
Widget build(BuildContext context) {
return IconButton(
onPressed: _toggle,
tooltip: _isFavorite ? 'Remove from favorites' : 'Add to favorites',
icon: Icon(_isFavorite ? Icons.favorite : Icons.favorite_border),
);
}
}
Widget her rebuild'de yeniden oluşturulabilir, ama State object'i yaşamaya devam eder; _isFavorite bu yüzden orada duruyor. State, kendisine bağlı güncel widget'a widget property'siyle ulaşır. setState çağrısı iki şey yapar: verdiğin fonksiyonu çalıştırır ve bu State'i bir sonraki frame'de yeniden build edilmek üzere işaretler. setState dışında field'ı değiştirirsen değer değişir ama ekran güncellenmez.
Bu test, dokunmanın ikonu değiştirdiğini ve yeni değerin callback ile dışarı bildirildiğini doğruluyor; ikinci test ise didUpdateWidget'ın farklı bir ISBN (kitapların uluslararası kimlik numarası) geldiğinde state'i sıfırladığını:
testWidgets('tapping toggles the icon and reports the new value', (
tester,
) async {
final changes = <bool>[];
await tester.pumpWidget(
host(FavoriteButton(isbn: '9780441172719', onChanged: changes.add)),
);
expect(find.byIcon(Icons.favorite_border), findsOneWidget);
await tester.tap(find.byType(FavoriteButton));
await tester.pump();
expect(find.byIcon(Icons.favorite), findsOneWidget);
expect(changes, [true]);
});
tester.pump() bir frame ilerletiyor; setState'in işaretlediği rebuild orada gerçekleşiyor. pump çağırmadan ikonu kontrol etseydin hâlâ eski ikonu görürdün.
State'in Yaşam Döngüsü ve Angular Karşılıkları
State'in hangi method'larının ne zaman çağrıldığını görmek için her method'a log ekleyen bir widget yazdık:
class _LifecycleProbeState extends State<LifecycleProbe> {
@override
void initState() {
super.initState();
log.add('initState');
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
log.add('didChangeDependencies');
}
@override
void didUpdateWidget(LifecycleProbe oldWidget) {
super.didUpdateWidget(oldWidget);
log.add('didUpdateWidget(${oldWidget.title} -> ${widget.title})');
}
@override
void dispose() {
log.add('dispose');
super.dispose();
}
@override
Widget build(BuildContext context) {
log.add('build(${widget.title})');
return Text(widget.title, textDirection: TextDirection.ltr);
}
}
void main() {
testWidgets('State lifecycle', (tester) async {
await tester.pumpWidget(const LifecycleProbe(title: 'Dune'));
log.add('--- parent rebuilds with a new title');
await tester.pumpWidget(const LifecycleProbe(title: 'Emma'));
log.add('--- widget removed from the tree');
await tester.pumpWidget(const SizedBox());
debugPrint(log.join('\n'));
expect(log.first, 'createState');
expect(log.last, 'dispose');
});
}
createState
initState
didChangeDependencies
build(Dune)
--- parent rebuilds with a new title
didUpdateWidget(Dune -> Emma)
build(Emma)
--- widget removed from the tree
dispose
İkinci pumpWidget aynı tipte ama farklı başlıklı bir widget verdi; Flutter yeni bir State oluşturmadı, var olanı yeni widget'la güncelleyip didUpdateWidget'ı çağırdı. Üçüncü pumpWidget'ta widget ağaçtan çıktı ve dispose çalıştı. Angular serisindeki "Angular Lifecycle Hook'ları: ngOnInit'ten ngOnDestroy'a Bir Component'in Hayatı" yazısını okuduysan eşleme şöyle:
Flutter (State) | Angular | Ne için |
|---|---|---|
initState | ngOnInit | Bir kez kurulum: controller oluşturmak, stream dinlemeye başlamak, ilk isteği başlatmak |
didChangeDependencies | Doğrudan karşılığı yok | Theme.of, MediaQuery.of gibi ağaçtan okunan bir değer değiştiğinde |
didUpdateWidget | ngOnChanges | Parent aynı yere yeni konfigürasyon verdiğinde eskisiyle karşılaştırmak |
build | Template'in render edilmesi | Saf olmalı: yan etki yok, istek yok |
dispose | ngOnDestroy | Controller, subscription ve timer'ları kapatmak |
Bir önceki yazıda build içinde başlatılan Future'ın her rebuild'de yeni bir istek attığını görmüştük; tablodaki "Saf olmalı" satırı bunun kuralı. İlk isteğin yeri initState, dinlemeyi bırakmanın yeri dispose.
Üç Ağaç: Widget, Element ve RenderObject
Widget'lar immutable ve sürekli yeniden oluşturuluyorsa, State nerede yaşıyor ve ekrandaki piksel bilgisi nerede tutuluyor? Flutter arka planda iki ağaç daha tutuyor:

Element ağacı, widget ağacındaki her widget için bir element tutar. Element, widget'ın ağaçtaki yerini temsil eder ve uzun ömürlüdür; State object'i StatefulElement'e bağlıdır. RenderObject ağacı ise layout (boyut ve konum hesabı), çizim ve dokunma testini yapan object'lerden oluşur. Her widget'ın render object'i yoktur: BookCard ve Text gibi widget'lar yalnız başka widget'lar üretir; Padding, Row ve Text'in içinde oluşan RichText ise ekranda yer kaplayan bir render object yaratır. Bunu bir testle doğruladık: BookCard'ı Dune ile build edip sonra aynı yere başlığı farklı bir kitap verdik.
Widget host(Book book) => MaterialApp(
home: Scaffold(body: BookCard(book: book)),
);
String describeTitle(WidgetTester tester, String title) {
final textElement = tester.element(find.text(title));
final richTextElement = tester.element(
find.descendant(of: find.text(title), matching: find.byType(RichText)),
);
return '${textElement.widget.runtimeType} -> ${textElement.runtimeType}\n'
'${richTextElement.widget.runtimeType} -> ${richTextElement.runtimeType} -> '
'${richTextElement.renderObject.runtimeType}';
}
Text -> StatelessElement
RichText -> MultiChildRenderObjectElement -> RenderParagraph
same widget: false
same element: true
same RenderParagraph: true
Rebuild sonrasında başlıktaki Text widget'ı yeni bir object, ama element ve RenderParagraph aynı instance. Flutter yeni widget'ı eski element'in widget'ıyla karşılaştırdı; tip ve key aynı olduğu için element'i yeniden kullandı ve render object'in yalnız metin property'sini güncelledi. Pahalı olan iş, yani render object oluşturmak ve layout'u baştan kurmak, yapılmadı. Build'in ucuz olmasının nedeni bu: build yalnız konfigürasyon üretir, gerçek değişikliği element ağacı küçük adımlarla uygular. Angular'daki change detection'ın değişen binding'i bulup DOM'da (tarayıcının sayfayı tuttuğu ağaç yapısı) yalnız onu güncellemesine benziyor; Flutter'da DOM'un yerinde render object'ler var.
const Constructor Rebuild'i Keser
Element, yeni widget'ın eskisiyle aynı object olduğunu görürse o alt ağacı hiç build etmez. const widget'lar canonical olduğu için (üçüncü yazıda identical ile görmüştük) bu tam olarak const'ta olur. Bunu ölçmek için sepet sayacının yanındaki başlığın kaç kez build edildiğini saydık:
class _CartBadgeState extends State<CartBadge> {
int _count = 0;
@override
Widget build(BuildContext context) {
return Column(
children: [
widget.constHeader ? const CatalogHeader() : CatalogHeader(),
TextButton(
onPressed: () => setState(() => _count++),
child: Text('Cart: $_count'),
),
],
);
}
}
const: false -> CatalogHeader.build ran 4 times for 1 + 3 frames
const: true -> CatalogHeader.build ran 1 times for 1 + 3 frames
Butona üç kez basıldı. const olmadan başlık her setState'te yeniden build edildi; const ile yalnız ilk seferde. Bu küçük örnekte fark önemsiz, ama bir listede yüzlerce satır ve her satırda onlarca widget olduğunda const bir alt ağacın tamamını atlatır. flutter_lints'in prefer_const_constructors_in_immutables kuralı bu yüzden widget class'larında const constructor tanımlamanı ister; değişmeyen widget'ları çağırırken de const yazmayı alışkanlık hâline getir.
BuildContext ve mounted
build(BuildContext context)'teki context, aslında o widget'ın element'idir; widget'ın ağaçtaki yerine açılan bir kapı. Theme.of(context) bu konumdan yukarı doğru en yakın Theme'i arar. Angular'daki hiyerarşik injector'ın bir bağımlılığı component ağacında yukarı doğru araması gibi; state management yazısında provider'ın da aynı mekanizmayı kullandığını göreceğiz.
Element ağaçtan çıktığında context geçersiz olur. Asenkron bir işten sonra setState çağırırken tehlike burada başlıyor: kullanıcı sen API'yi beklerken ekrandan çıkmış olabilir. Bunu bir testle canlandırdık; kayıt sürerken ekranı başka bir ekranla değiştirip sonra kaydı tamamladık:
Future<void> saveNow() async {
setState(() => _status = 'saving');
await widget.save();
// Biz beklerken kullanıcı ekrandan çıkmış olabilir
if (widget.checkMounted && !mounted) return;
setState(() => _status = 'saved');
}
checkMounted: false -> setState() called after dispose(): SaveFavoriteState#88115(lifecycle state: defunct, not mounted)
checkMounted: true -> no error
mounted kontrolü olmadan Flutter, dispose edilmiş bir State'te setState çağrıldığını söyleyen bir hata fırlattı. Kontrol varken iş sessizce bitti. Kural: her await'ten sonra setState ya da context kullanmadan önce mounted'ı kontrol et. flutter_lints'teki use_build_context_synchronously kuralı context'in await'ten sonra kontrolsüz kullanımını zaten yakalıyor. Mimari yazısından sonra bu iş büyük ölçüde ViewModel'e geçecek, ama widget'ta kalan her asenkron callback için kural geçerli.
Key'ler: State Yanlış Satıra Kaydığında
"Önce Tahmin Et"teki hediye listesini bir testle kurduk. Her satır kendi _picked değerini tutan bir PickRow:
// Kullanıcının işaretleyip işaretlemediğini hatırlayan bir satır (ör. hediye listesi için)
class PickRow extends StatefulWidget {
const PickRow({super.key, required this.title});
final String title;
@override
State<PickRow> createState() => _PickRowState();
}
class _PickRowState extends State<PickRow> {
bool _picked = false;
@override
Widget build(BuildContext context) => TextButton(
onPressed: () => setState(() => _picked = !_picked),
child: Text('${_picked ? '[x]' : '[ ]'} ${widget.title}'),
);
}
Widget list(List<String> titles, {required bool useKeys}) => MaterialApp(
home: Scaffold(
body: Column(
children: [
for (final title in titles)
PickRow(key: useKeys ? ValueKey(title) : null, title: title),
],
),
),
);
keys: false, before: [x] Dune, [ ] Emma, [ ] Middlemarch
keys: false, after : [x] Emma, [ ] Middlemarch
keys: true, before: [x] Dune, [ ] Emma, [ ] Middlemarch
keys: true, after : [ ] Emma, [ ] Middlemarch
Key olmadan Dune'un işareti Emma'ya geçti. Neden: Flutter yeni çocuk listesini eskisiyle sırayla, tip ve key'e bakarak eşliyor. Key yokken ilk sıradaki PickRow(title: 'Emma'), ilk sıradaki eski element'le aynı tipte olduğu için o element'i ve onun State'ini aldı; Dune'un _picked = true değeri Emma'nın satırında kaldı. Son element ise artık eşi olmadığı için dispose edildi. ValueKey(title) ile Flutter eşlemeyi key'e göre yaptı: Emma'nın widget'ı Emma'nın element'ini buldu, Dune'un element'i ve state'i atıldı.
Ne zaman key gerekir: aynı tipte, state tutan widget'lardan oluşan bir koleksiyon yeniden sıralanıyor, araya ekleme yapılıyor ya da ortadan silme yapılıyorsa. Key için her zaman kalıcı bir kimlik kullan; Bookstore'da bu ISBN. HomeScreen'deki kart listesinde bu yüzden BookCard(key: ValueKey(book.isbn), ...) yazdık. Liste index'ini key yapmak sorunu çözmez, çünkü silmeden sonra index'ler de kayar. GlobalKey ise farklı bir araç: bir widget'ın state'ine ağacın başka bir yerinden ulaşmak (form yazısındaki GlobalKey<FormState>) ya da bir widget'ı ağaçta taşırken state'ini korumak için. Pahalıdır, liste satırları için kullanılmaz.
Üretimde Dikkat
Rebuild'leri ölç, tahmin etme. Flutter DevTools'un performans görünümünde widget build'lerini izleme seçeneğini açtığında hangi widget'ın kaç kez build edildiğini görürsün. Bir setState'in ekranın tamamını yeniden build ettiğini görürsen state'i daha aşağıya, değişen küçük widget'a taşı; büyük bir ekranın kökünde setState çağırmak en yaygın performans sorunu.
dispose'u eksiksiz yaz. TextEditingController, AnimationController, StreamSubscription ve Timer gibi object'ler kapatılmazsa ekran kapandıktan sonra bellekte kalır ve callback'leri çalışmaya devam eder. Code review'da initState'te oluşturulan her şeyin dispose'ta karşılığı olduğunu kontrol et.
Liste key'lerini veri kimliğinden üret. API'den gelen bir listenin sırası değişebilir, sayfalama ile araya kayıt girebilir. Key olmadan bu durumlar, yukarıdaki gibi yanlış satırda görünen bir favori ya da yanlış ürüne ait bir sepet adedi olarak kullanıcıya yansır.
Sık Düşülen Tuzaklar
Birinci tuzak, build'i bir kez çalışan kurulum kodu sanmak. build her rebuild'de çalışır; içinde istek başlatmak, controller oluşturmak ya da bir değişkene sayaç yazmak hatalıdır. Kurulum initState'te yapılır.
İkinci tuzak, widget'ın field'ına state koymak. StatefulWidget'ın kendisi immutable'dır ve her rebuild'de yeniden oluşturulabilir; değişen değerler State'te durur. Parent'tan gelen değeri initState'te State'e kopyalarsan, parent değeri değiştirdiğinde didUpdateWidget'ta güncellemeyi unutma.
Üçüncü tuzak, await'ten sonra mounted kontrol etmeden setState ya da Navigator.of(context) çağırmak. Test ortamında yakalanan bu hata, gerçek cihazda kullanıcının hızlıca geri tuşuna basmasıyla ortaya çıkar.
Dördüncü tuzak, key'i her soruna çözüm sanmak ya da hiç kullanmamak. Stateless satırlardan oluşan sabit bir listede key'e gerek yok; state tutan ve sırası değişen satırlarda key zorunlu.
Küçük Bir Deneme
★☆☆ BookCard'daki const EdgeInsets.all(12) ve const Icon(...) ifadelerinden const'u kaldır. flutter analyze ne diyor? Testler hâlâ geçiyor mu?
★★☆ keys_test.dart'ta key olarak ValueKey(index) kullanan üçüncü bir durum ekle. Dune'u sildikten sonra çıktı key'siz duruma mı, ValueKey(title) durumuna mı benziyor? Nedenini element eşleme kuralıyla açıkla.
★★★ FavoriteButton'daki didUpdateWidget'ı sil ve favorite_button_test.dart'taki ikinci testi çalıştır. Hangi senaryoda kullanıcı yanlış kitabı favori olarak görür? Bunu BookCard'a key vermeden bir listeyle yeniden üret.
Bir sonraki yazıda ekranı nasıl yerleştirdiğimize geçiyoruz: Flutter'ın layout kuralı (constraint'ler aşağı, size'lar yukarı, parent konumlar), Row, Column, Expanded, Stack, "RenderFlex overflowed" hatası ve kitap detay ekranı.