Skip to content

Bab 12 — Di Balik Layar: Tiga Pohon & Keys

Bab ini berbeda dari yang lain. Tidak ada fitur baru yang bisa langsung kamu pakai, tidak ada widget yang perlu dihafal. Yang ada adalah penjelasan tentang bagaimana Flutter bekerja sebenarnya — dan pemahaman itu akan menjelaskan sejumlah perilaku aneh yang mungkin sudah kamu temui.

Kalau kamu pernah bertanya-tanya kenapa menghapus satu item dari daftar membuat item lain ikut berubah, atau kenapa const disebut mempercepat aplikasi, atau apa gunanya parameter key yang selalu muncul di setiap konstruktor widget — jawabannya ada di sini.

Ada tiga pohon, bukan satu

Selama ini kita bicara tentang "widget tree". Kenyataannya, Flutter mengelola tiga pohon yang berjalan berdampingan.

   WIDGET TREE            ELEMENT TREE           RENDER TREE
   (cetak biru)           (penghubung)           (piksel)
   ───────────            ────────────           ───────────
   Ringan                 Sedang                 Berat
   Sering dibuang         Bertahan lama          Bertahan lama
   Immutable              Mutable                Mutable

   MyApp          ──────► Element        ──────► (tidak merender)
     │                      │
   Scaffold       ──────► Element        ──────► RenderBox
     │                      │
   Column         ──────► Element        ──────► RenderFlex
     │                      │
   ├─ Text        ──────► Element        ──────► RenderParagraph
   └─ Button      ──────► Element        ──────► RenderBox

Widget tree adalah yang kamu tulis. Objek konfigurasi yang ringan dan tidak bisa diubah. Flutter membuangnya dan membuat ulang berkali-kali per detik.

Element tree adalah lapisan penghubung. Setiap element menunjuk ke satu widget dan satu render object. Element inilah yang bertahan ketika widget dibuang — dan inilah yang menyimpan State milik StatefulWidget.

Render tree adalah yang mengerjakan pekerjaan berat: menghitung ukuran, menentukan posisi, dan menggambar piksel.

Kenapa ini penting untukmu? Karena element tree adalah alasan setState bisa efisien. Ketika build() dipanggil ulang, Flutter tidak membuang seluruh antarmuka. Ia membandingkan widget baru dengan widget lama pada setiap element, dan hanya memperbarui yang benar-benar berbeda.

Bagaimana pembaruan bekerja

Ketika setState dipanggil, inilah yang terjadi:

1. setState() dipanggil


2. Element ditandai "kotor" (dirty)


3. Pada frame berikutnya, build() dipanggil


4. Widget BARU dihasilkan


5. Flutter membandingkan widget baru vs lama
   pada setiap posisi di pohon:

        ├─ Tipe runtime SAMA?  ──► perbarui widget di element yang ada
        │                           (element & state DIPERTAHANKAN)

        └─ Tipe runtime BEDA?  ──► buang element lama, buat yang baru
                                    (state HILANG)


6. Render object diperbarui, hanya bagian yang berubah digambar ulang

Kunci dari seluruh mekanisme ini ada di langkah 5. Flutter memutuskan apakah akan menggunakan ulang sebuah element berdasarkan dua hal:

  1. Tipe runtime widget — apakah keduanya sama-sama Text? Sama-sama KartuPengeluaran?
  2. Key — kalau ada.

Kalau keduanya cocok, element dan State-nya dipertahankan. Kalau tidak, semuanya dibuang dan dibuat ulang dari nol.

Kenapa const mempercepat aplikasi

Sekarang penjelasan const menjadi masuk akal.

dart
// Setiap build() menghasilkan objek Text BARU
Text('Judul')

// Objek Text yang SAMA persis, dibuat sekali seumur hidup aplikasi
const Text('Judul')

Ketika Flutter membandingkan widget lama dan baru, ada pemeriksaan pintasan: kalau kedua objeknya adalah objek yang sama secara identitas (identical(a, b)), Flutter langsung tahu tidak ada yang berubah dan melewati seluruh proses pembandingan untuk cabang itu.

Widget const selalu memenuhi syarat itu, karena Dart menyimpan satu instance saja untuk setiap nilai const yang identik — ini disebut canonicalization.

dart
class _HalamanState extends State<Halaman> {
  int _hitungan = 0;

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        // Seluruh cabang ini dilewati saat rebuild
        const HeaderBerat(),

        // Hanya ini yang benar-benar dibandingkan dan diperbarui
        Text('$_hitungan'),

        ElevatedButton(
          onPressed: () => setState(() => _hitungan++),
          child: const Text('Tambah'),
        ),
      ],
    );
  }
}

Inilah kenapa linter Flutter terus-menerus menyarankanmu menambahkan const. Itu bukan sekadar kerapian gaya.

Memecah widget membatasi rebuild

Prinsip kedua yang mengikuti dari model tiga pohon: setState hanya memengaruhi element tempat ia dipanggil dan keturunannya.

dart
// ❌ Satu setState membangun ulang SEMUANYA
class _BerandaState extends State<Beranda> {
  int _hitungan = 0;

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        PetaBerat(),          // dibangun ulang percuma
        GrafikBerat(),        // dibangun ulang percuma
        Text('$_hitungan'),
        ElevatedButton(
          onPressed: () => setState(() => _hitungan++),
          child: const Text('Tambah'),
        ),
      ],
    );
  }
}
dart
// ✅ setState hanya menyentuh cabang yang relevan
class Beranda extends StatelessWidget {
  const Beranda({super.key});

  @override
  Widget build(BuildContext context) {
    return const Column(
      children: [
        PetaBerat(),          // tidak pernah tersentuh
        GrafikBerat(),        // tidak pernah tersentuh
        BagianPenghitung(),   // punya state sendiri
      ],
    );
  }
}

class BagianPenghitung extends StatefulWidget {
  const BagianPenghitung({super.key});

  @override
  State<BagianPenghitung> createState() => _BagianPenghitungState();
}

class _BagianPenghitungState extends State<BagianPenghitung> {
  int _hitungan = 0;

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Text('$_hitungan'),
        ElevatedButton(
          onPressed: () => setState(() => _hitungan++),
          child: const Text('Tambah'),
        ),
      ],
    );
  }
}

Aturannya: turunkan state serendah mungkin di pohon. Semakin dekat state dengan widget yang benar-benar memakainya, semakin kecil cakupan pembangunan ulangnya.

Mengekstrak widget vs mengekstrak method

Ada godaan untuk memecah build() menjadi method-method kecil:

dart
Widget build(BuildContext context) {
  return Column(
    children: [
      _bangunHeader(),      // ❌ tidak membantu performa
      _bangunKonten(),
    ],
  );
}

Ini memang meningkatkan keterbacaan, tetapi tidak membantu performa — method dipanggil dari build() yang sama, jadi tetap dieksekusi ulang setiap kali. Hanya widget terpisah yang membuat element tree bercabang, dan hanya itu yang membatasi rebuild.

Masalah yang dipecahkan oleh Keys

Sekarang bagian yang paling praktis. Mari lihat bug nyata.

Bayangkan daftar kotak berwarna, masing-masing sebuah StatefulWidget yang menyimpan berapa kali ia diketuk:

dart
class KotakHitung extends StatefulWidget {
  const KotakHitung({super.key, required this.warna});

  final Color warna;

  @override
  State<KotakHitung> createState() => _KotakHitungState();
}

class _KotakHitungState extends State<KotakHitung> {
  int _ketukan = 0;   // ← state yang tersimpan di element

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: () => setState(() => _ketukan++),
      child: Container(
        width: 100,
        height: 100,
        color: widget.warna,
        alignment: Alignment.center,
        child: Text(
          '$_ketukan',
          style: const TextStyle(fontSize: 32, color: Colors.white),
        ),
      ),
    );
  }
}

Dan daftarnya, tanpa key:

dart
class _DaftarKotakState extends State<DaftarKotak> {
  List<Color> _warna = [Colors.red, Colors.green, Colors.blue];

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Row(
          children: [
            for (final warna in _warna)
              KotakHitung(warna: warna),   // ← tanpa key
          ],
        ),
        ElevatedButton(
          onPressed: () => setState(() => _warna.removeAt(0)),
          child: const Text('Hapus yang pertama'),
        ),
      ],
    );
  }
}

Sekarang lakukan ini: ketuk kotak merah tiga kali, ketuk hijau lima kali, lalu tekan "Hapus yang pertama".

Yang kamu harapkan: merah hilang, hijau tetap menunjukkan 5, biru tetap 0.

Yang sebenarnya terjadi: hijau menunjukkan 3.

Kenapa bisa begitu

Karena Flutter membandingkan berdasarkan posisi dan tipe:

SEBELUM penghapusan                SESUDAH penghapusan
─────────────────────              ─────────────────────
Posisi 0: KotakHitung(merah)       Posisi 0: KotakHitung(hijau)
          State: ketukan = 3                 ← element LAMA dipakai ulang!
                                               State ketukan = 3 tertinggal

Posisi 1: KotakHitung(hijau)       Posisi 1: KotakHitung(biru)
          State: ketukan = 5                 ← element lama dipakai ulang
                                               State ketukan = 5 tertinggal

Posisi 2: KotakHitung(biru)        (posisi 2 dibuang)
          State: ketukan = 0

Flutter melihat posisi 0 dan berpikir: "widget baru di sini bertipe KotakHitung, widget lama juga KotakHitung — tipenya sama, jadi aku pakai ulang element-nya, cukup perbarui propertinya."

Ia tidak tahu bahwa merah dan hijau adalah entitas yang berbeda. Dari sudut pandangnya, keduanya hanyalah "KotakHitung di posisi 0".

State-nya menempel pada posisi, bukan pada data.

Solusinya: berikan identitas

Key adalah cara memberi tahu Flutter identitas sebuah widget, terlepas dari posisinya.

dart
for (final warna in _warna)
  KotakHitung(
    key: ValueKey(warna),     // ← identitas berbasis data
    warna: warna,
  ),

Sekarang perbandingannya menjadi:

SEBELUM                            SESUDAH
─────────────────────              ─────────────────────
Posisi 0: key = merah              Posisi 0: key = hijau
          ketukan = 3                        ← key tidak cocok dengan
                                               element lama di posisi 0

Posisi 1: key = hijau     ─────────────────► Flutter mencari element
          ketukan = 5                         dengan key "hijau",
                                              menemukannya di posisi 1,
                                              lalu MEMINDAHKANNYA ke posisi 0
                                              beserta State ketukan = 5 ✅

Flutter kini bisa mencocokkan element berdasarkan key, bukan posisi. Ia memindahkan element yang benar dan membuang yang benar.

Jenis-jenis Key

ValueKey

Yang paling sering dipakai. Identitas berdasarkan sebuah nilai:

dart
ValueKey(pengeluaran.id)
ValueKey('item-$indeks')     // ⚠️ hati-hati, lihat catatan di bawah
ValueKey(warna)

ObjectKey

Identitas berdasarkan identitas objek itu sendiri. Berguna kalau objekmu tidak punya ID unik tetapi instansnya memang berbeda:

dart
ObjectKey(pengeluaran)

UniqueKey

Membuat identitas yang selalu berbeda setiap kali dibuat:

dart
UniqueKey()

UniqueKey hampir selalu salah

Karena dibuat baru pada setiap build(), key ini tidak akan pernah cocok dengan element sebelumnya. Akibatnya seluruh element dan state-nya dibuang serta dibuat ulang setiap frame — kebalikan dari yang kamu inginkan.

Gunakan hanya kalau kamu memang sengaja ingin memaksa Flutter membuang state, misalnya untuk memicu ulang sebuah animasi.

GlobalKey

Berbeda dari yang lain: GlobalKey memberi akses ke state widget dari mana pun di aplikasi.

dart
final _kunciForm = GlobalKey<FormState>();

// Di tempat lain, akses state-nya
_kunciForm.currentState!.validate();
_kunciForm.currentState!.save();

Ini yang kamu pakai di Bab 6 untuk Form.

GlobalKey mahal dan harus dipakai hemat. Ia juga harus dibuat sekali dan disimpan sebagai properti final, tidak pernah di dalam build():

dart
// ✅ Benar
class _FormSayaState extends State<FormSaya> {
  final _kunciForm = GlobalKey<FormState>();

// ❌ Salah — dibuat ulang setiap build, state formulir hilang terus
  Widget build(BuildContext context) {
    final kunciForm = GlobalKey<FormState>();

Kapan kamu butuh Key

Kamu butuh key ketika ketiga syarat ini terpenuhi bersamaan:

  1. Kamu punya daftar widget sejenis.
  2. Widget-widget itu stateful — punya StatefulWidget, animasi, posisi gulir, atau input teks di dalamnya.
  3. Daftarnya bisa berubah urutan — item ditambah, dihapus, diurutkan, atau disaring.

Kalau salah satu tidak terpenuhi, key biasanya tidak diperlukan.

dart
// ✅ Tidak butuh key — widget stateless
Column(
  children: [
    for (final teks in daftarTeks) Text(teks),
  ],
)

// ✅ Tidak butuh key — daftar tidak pernah berubah urutan
ListView(
  children: const [
    PengaturanNotifikasi(),
    PengaturanPrivasi(),
    PengaturanAkun(),
  ],
)

// ⚠️ BUTUH key — stateful + bisa berubah urutan
ListView.builder(
  itemCount: _tugas.length,
  itemBuilder: (ctx, i) => KartuTugas(
    key: ValueKey(_tugas[i].id),
    tugas: _tugas[i],
  ),
)

// ⚠️ WAJIB key — Dismissible menuntut key
Dismissible(
  key: ValueKey(item.id),
  onDismissed: (_) => _hapus(item),
  child: /* ... */,
)

Pakai ID, bukan indeks

Ini kesalahan paling umum ketika orang baru mulai memakai key.

dart
// ❌ Indeks berubah setelah penghapusan — sama saja dengan tanpa key
ValueKey(indeks)

// ✅ ID menempel pada data, tidak berubah
ValueKey(item.id)

Kalau kamu memakai indeks, kamu kembali ke masalah semula: setelah item pertama dihapus, item kedua berpindah ke indeks 0 dan mewarisi state yang salah.

Kalau modelmu belum punya ID, tambahkan. Paket uuid membuatnya sepele:

dart
import 'package:uuid/uuid.dart';

const uuid = Uuid();

class Pengeluaran {
  Pengeluaran({
    required this.judul,
    required this.jumlah,
    required this.tanggal,
  }) : id = uuid.v4();     // ID unik dibuat otomatis

  final String id;
  final String judul;
  final double jumlah;
  final DateTime tanggal;
}

var, final, dan const dilihat dari sisi memori

Sekarang kita bisa melihat kembali tiga kata kunci dari Bab 2 dengan pemahaman yang lebih dalam.

Ketika kamu menulis:

dart
final daftar = [1, 2, 3];

Variabel daftar tidak menyimpan list itu sendiri. Ia menyimpan alamat tempat list itu berada di memori.

   Variabel            Memori
   ────────            ──────
   daftar  ──────────► [1, 2, 3]
           (alamat)     (objek sesungguhnya)

final mengunci alamatnya, bukan isi di alamat itu:

dart
final daftar = [1, 2, 3];
daftar.add(4);        // ✅ isi di alamat itu berubah
daftar = [5, 6];      // ❌ alamatnya yang dikunci

const mengunci keduanya — objeknya dibekukan seluruhnya:

dart
const daftar = [1, 2, 3];
daftar.add(4);        // ❌ error saat runtime: Unsupported operation

Pemahaman ini penting ketika bekerja dengan state, karena inilah sumber bug klasik berikut:

dart
// ❌ Tampilan tidak berubah meskipun setState dipanggil
void tambahItem(Item baru) {
  setState(() {
    _daftar.add(baru);   // objek yang sama, hanya isinya berubah
  });
}

Ini sebenarnya berfungsi untuk setState biasa, karena setState menandai element sebagai kotor tanpa peduli objeknya berubah alamat atau tidak.

Tetapi ia gagal pada solusi state management yang membandingkan objek — seperti Riverpod dan BLoC. Keduanya memeriksa apakah state baru berbeda dari yang lama, dan kalau alamatnya sama, mereka menyimpulkan tidak ada perubahan.

Karena itu di Bagian III kamu akan selalu melihat pola ini:

dart
// ✅ Buat list BARU, bukan mengubah yang lama
state = [...state, itemBaru];

// ✅ Buat objek BARU dengan copyWith
state = state.copyWith(hitungan: state.hitungan + 1);

Masalah ini dibahas tuntas di Bab 23 dan bab tentang Equatable.

Memakai Flutter DevTools untuk melihat pohonnya

Semua ini bukan teori abstrak — kamu bisa melihatnya langsung.

Jalankan aplikasi dalam mode debug, lalu buka DevTools:

bash
flutter run
# Tekan 'v' di terminal, atau buka URL yang ditampilkan

Di tab Flutter Inspector, kamu bisa:

  • Melihat widget tree yang sesungguhnya, lengkap dengan properti tiap widget.
  • Mengaktifkan Highlight Repaints — setiap widget yang digambar ulang akan dikelilingi garis berwarna. Kalau seluruh layar berkedip setiap kali kamu menekan tombol, itu pertanda rebuild yang terlalu luas.
  • Mengaktifkan Show Guidelines untuk melihat batas-batas layout.
  • Memilih widget di layar dan langsung melompat ke barisnya di kode.

Tab Performance menunjukkan berapa lama setiap frame dibangun. Frame yang melebihi 16 milidetik akan terasa tersendat pada layar 60 Hz.

DevTools dibahas lebih lengkap di Bab 13.

Ikhtisar

  • Flutter mengelola tiga pohon: widget (cetak biru, sering dibuang), element (penghubung, bertahan, menyimpan State), dan render (menggambar piksel).
  • Saat rebuild, Flutter memakai ulang element kalau tipe runtime widget lama dan baru cocok — dan kalau ada key, key-nya juga harus cocok.
  • const mempercepat karena Dart hanya membuat satu instance untuk nilai const yang identik, sehingga Flutter bisa melewati pembandingan seluruh cabang itu.
  • Mengekstrak widget membatasi cakupan rebuild; mengekstrak method tidak — method tetap dieksekusi dalam build() yang sama.
  • Turunkan state serendah mungkin di pohon.
  • Tanpa key, state menempel pada posisi, bukan pada data. Inilah sebabnya menghapus item dari daftar bisa membuat state item lain tertukar.
  • Butuh key ketika daftar + stateful + urutan bisa berubah terjadi bersamaan.
  • Pakai ValueKey(item.id), jangan ValueKey(indeks) — indeks berubah setelah penghapusan.
  • GlobalKey untuk akses state dari luar (seperti Form); mahal, harus dibuat sekali sebagai properti final.
  • UniqueKey membuang state setiap frame — hampir selalu bukan yang kamu mau.
  • final mengunci alamat, const mengunci alamat dan isinya. Perbedaan ini menjadi krusial di state management, tempat objek baru harus dibuat agar perubahan terdeteksi.
  • Flutter Inspector di DevTools memungkinkanmu melihat semua ini secara langsung, termasuk widget mana yang digambar ulang.

Berikutnya: Bab 13 — Debugging & DevTools, tempat kita belajar membaca pesan error alih-alih menakutinya.

Transkrip asli

Disintesis dari 1_flutter_core/6_todo_internals.md (9 video, termasuk "Three Trees", "Understanding Keys", dan "Mutating Values in Memory"). Lihat PDF Flutter Core.

Rangkuman pembelajaran pribadi, disusun ulang dari beberapa kursus Flutter.