Widget Anti-Pattern #
Setelah mempelajari cara yang benar membangun widget di Flutter, penting juga memahami pola-pola yang harus dihindari. Anti-pattern adalah solusi yang terlihat benar di permukaan tapi menyebabkan masalah nyata — performa buruk, memory leak, rebuild berlebihan, atau kode yang sulit dipelihara. Artikel ini mengumpulkan anti-pattern paling umum yang ditemukan di codebase Flutter nyata, lengkap dengan diagnosis dan perbaikannya.
1. Tidak Menggunakan const #
Salah satu perbaikan performa paling mudah dan paling sering diabaikan. Menggunakan const untuk widget yang memenuhi syarat bisa mengurangi widget rebuild hingga 70%. Flutter dapat meng-cache dan mereuse instance yang sama alih-alih membuat objek baru setiap rebuild.
// ANTI-PATTERN: widget tanpa const meskipun bisa di-const
class _MyScreenState extends State<MyScreen> {
int _counter = 0;
@override
Widget build(BuildContext context) {
return Column(
children: [
Text('Counter: $_counter'),
SizedBox(height: 16), // dibuat ulang setiap rebuild!
Icon(Icons.star), // dibuat ulang setiap rebuild!
Padding(
padding: EdgeInsets.all(16), // dibuat ulang setiap rebuild!
child: Text('Label statis'), // dibuat ulang setiap rebuild!
),
],
);
}
}
// BENAR: const pada semua yang tidak bergantung pada state
@override
Widget build(BuildContext context) {
return Column(
children: [
Text('Counter: $_counter'), // tidak bisa const (runtime value)
const SizedBox(height: 16), // const -- tidak pernah rebuild
const Icon(Icons.star), // const -- tidak pernah rebuild
const Padding(
padding: EdgeInsets.all(16),
child: Text('Label statis'), // const -- tidak pernah rebuild
),
],
);
}
Aktifkan lint ruleprefer_const_constructorsdanprefer_const_literals_to_create_immutablesdianalysis_options.yaml. Flutter DevTools Performance tab menunjukkan widget mana yang paling sering rebuild — mulai dari sana.
2. Widget Monolitik — Satu Widget untuk Segalanya #
Membuat widget besar yang menangani banyak tanggung jawab adalah anti-pattern yang mempersulit pemeliharaan, pengujian, dan optimasi rebuild.
// ANTI-PATTERN: satu widget besar yang menangani segalanya
class ProfilScreen extends StatefulWidget { ... }
class _ProfilScreenState extends State<ProfilScreen> {
User? _user;
List<Post> _posts = [];
bool _isFollowing = false;
// ... banyak state
@override
Widget build(BuildContext context) {
// 200+ baris build method yang menangani:
// - header dengan foto profil
// - statistik (follower, following, post count)
// - tombol follow/unfollow
// - grid foto/video
// - highlights/story
// Semuanya dalam satu build() -- setiap setState rebuild semuanya!
return Column(children: [
/* 200 baris... */
]);
}
}
// BENAR: pecah menjadi widget kecil yang fokus
class ProfilScreen extends StatelessWidget {
final User user;
final List<Post> posts;
const ProfilScreen({super.key, required this.user, required this.posts});
@override
Widget build(BuildContext context) {
return CustomScrollView(
slivers: [
SliverToBoxAdapter(child: ProfilHeader(user: user)),
SliverToBoxAdapter(child: ProfilStats(user: user)),
SliverToBoxAdapter(child: ProfilActions(user: user)),
SliverGrid(
delegate: SliverChildBuilderDelegate(
(context, i) => PostThumbnail(post: posts[i]),
childCount: posts.length,
),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
),
),
],
);
}
}
// Setiap widget kecil hanya rebuild ketika datanya berubah
class ProfilHeader extends StatelessWidget { ... } // hanya rebuild saat user berubah
class ProfilStats extends StatelessWidget { ... } // hanya rebuild saat stats berubah
class ProfilActions extends StatefulWidget { ... } // state follow/unfollow terisolasi
3. Helper Function sebagai Pengganti Widget #
Anti-pattern yang sangat umum: menggunakan fungsi biasa untuk membangun bagian UI alih-alih membuat widget terpisah. Hasilnya: konten tidak bisa di-const, tidak bisa di-cache, dan selalu di-rebuild bersama parent.
// ANTI-PATTERN: helper function yang mengembalikan Widget
class _HomeState extends State<HomeScreen> {
int _counter = 0;
// Fungsi ini SELALU dipanggil ulang setiap kali _counter berubah
Widget _buildHeader() {
return Container(
padding: const EdgeInsets.all(16),
color: Colors.blue,
child: const Column(
children: [
CircleAvatar(radius: 40, child: Icon(Icons.person, size: 40)),
SizedBox(height: 8),
Text('Header Statis yang Mahal'),
],
),
);
}
@override
Widget build(BuildContext context) {
return Column(
children: [
_buildHeader(), // dipanggil ulang setiap counter berubah!
Text('$_counter'),
ElevatedButton(
onPressed: () => setState(() => _counter++),
child: const Text('Tambah'),
),
],
);
}
}
// BENAR: widget terpisah yang bisa di-const
class AppHeader extends StatelessWidget {
const AppHeader({super.key});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
color: Colors.blue,
child: const Column(
children: [
CircleAvatar(radius: 40, child: Icon(Icons.person, size: 40)),
SizedBox(height: 8),
Text('Header Statis yang Mahal'),
],
),
);
}
}
class _HomeState extends State<HomeScreen> {
int _counter = 0;
@override
Widget build(BuildContext context) {
return Column(
children: [
const AppHeader(), // TIDAK pernah rebuild meskipun counter berubah!
Text('$_counter'),
ElevatedButton(
onPressed: () => setState(() => _counter++),
child: const Text('Tambah'),
),
],
);
}
}
4. setState() yang Terlalu Luas #
Memanggil setState() di widget besar menyebabkan seluruh subtree di-rebuild, meskipun yang berubah hanya sebagian kecil UI.
// ANTI-PATTERN: setState di widget besar yang punya banyak child
class _ScreenState extends State<BigScreen> {
bool _isLoading = false;
Future<void> _refresh() async {
setState(() => _isLoading = true); // rebuild SELURUH BigScreen!
await fetchData();
setState(() => _isLoading = false); // rebuild SELURUH BigScreen lagi!
}
@override
Widget build(BuildContext context) {
return Column(
children: [
const ExpensiveHeader(), // rebuild tidak perlu!
const ExpensiveContent(), // rebuild tidak perlu!
const ExpensiveFooter(), // rebuild tidak perlu!
if (_isLoading)
const LinearProgressIndicator(), // ini yang seharusnya berubah
],
);
}
}
// BENAR: isolasi state ke widget sekecil mungkin
class _ScreenState extends State<BigScreen> {
Future<void> _refresh() => fetchData(); // tidak ada setState di sini
@override
Widget build(BuildContext context) {
return Column(
children: [
const ExpensiveHeader(), // tidak pernah rebuild
const ExpensiveContent(), // tidak pernah rebuild
const ExpensiveFooter(), // tidak pernah rebuild
LoadingButton( // hanya widget ini yang rebuild
onPressed: _refresh,
child: const Text('Refresh'),
),
],
);
}
}
// Widget kecil dengan state terisolasi
class LoadingButton extends StatefulWidget { ... }
class _LoadingButtonState extends State<LoadingButton> {
bool _isLoading = false;
Future<void> _handle() async {
setState(() => _isLoading = true); // hanya LoadingButton yang rebuild
try { await widget.onPressed(); }
finally {
if (mounted) setState(() => _isLoading = false);
}
}
// ...
}
5. Memory Leak — Tidak Dispose Resource #
Tidak memanggil dispose() pada controller dan subscription adalah penyebab memory leak paling umum di Flutter. Resource tetap hidup di memory meskipun widget sudah dihancurkan.
// ANTI-PATTERN: resource tidak di-dispose
class _VideoPlayerState extends State<VideoPlayer> {
late AnimationController _controller;
late StreamSubscription _subscription;
late TextEditingController _textController;
late ScrollController _scrollController;
@override
void initState() {
super.initState();
_controller = AnimationController(vsync: this, duration: const Duration(seconds: 1));
_subscription = dataStream.listen(_handleData);
_textController = TextEditingController();
_scrollController = ScrollController();
// LUPA dispose -- semua resource bocor ke memory!
}
// dispose() tidak ada atau tidak lengkap
@override
Widget build(BuildContext context) => Container();
}
// BENAR: dispose SEMUA resource
class _VideoPlayerState extends State<VideoPlayer> {
late AnimationController _controller;
late StreamSubscription _subscription;
late TextEditingController _textController;
late ScrollController _scrollController;
@override
void initState() {
super.initState();
_controller = AnimationController(vsync: this, duration: const Duration(seconds: 1));
_subscription = dataStream.listen(_handleData);
_textController = TextEditingController();
_scrollController = ScrollController();
}
@override
void dispose() {
_controller.dispose(); // AnimationController
_subscription.cancel(); // StreamSubscription
_textController.dispose(); // TextEditingController
_scrollController.dispose(); // ScrollController
super.dispose(); // SELALU panggil terakhir
}
@override
Widget build(BuildContext context) => Container();
}
6. setState() Setelah Widget Di-dispose #
Memanggil setState() setelah widget dihancurkan menyebabkan error: setState() called after dispose(). Ini sering terjadi di callback async.
// ANTI-PATTERN: tidak cek mounted sebelum setState
class _DataState extends State<DataWidget> {
List<Item> _items = [];
Future<void> _loadData() async {
final data = await api.fetchItems(); // async -- butuh waktu
// Widget mungkin sudah di-dispose saat ini!
setState(() => _items = data); // ERROR jika sudah dispose!
}
// ...
}
// BENAR: selalu cek mounted sebelum setState di callback async
class _DataState extends State<DataWidget> {
List<Item> _items = [];
Future<void> _loadData() async {
final data = await api.fetchItems();
if (!mounted) return; // cek dulu!
setState(() => _items = data); // aman
}
// ...
}
7. shrinkWrap: true + NeverScrollableScrollPhysics #
Kombinasi ini terlihat seperti solusi mudah untuk menempatkan ListView di dalam ListView, tapi sangat berbahaya untuk performa — semua item dibangun sekaligus tanpa lazy loading.
// ANTI-PATTERN: shrinkWrap yang membunuh lazy loading
Column(
children: [
const HeaderWidget(),
ListView.builder(
shrinkWrap: true, // BAHAYA untuk list panjang
physics: const NeverScrollableScrollPhysics(), // BAHAYA
itemCount: 10000, // semua 10.000 item dibangun sekaligus!
itemBuilder: (context, index) => ListTile(
title: Text('Item $index'),
),
),
const FooterWidget(),
],
)
// BENAR: gunakan CustomScrollView dengan Sliver
CustomScrollView(
slivers: [
const SliverToBoxAdapter(child: HeaderWidget()),
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => ListTile(title: Text('Item $index')),
childCount: 10000, // lazy loading otomatis -- hanya yang terlihat dibangun
),
),
const SliverToBoxAdapter(child: FooterWidget()),
],
)
8. Opacity Widget pada Animasi #
Menggunakan Opacity widget untuk animasi adalah anti-pattern karena ia menyebabkan saveLayer() — operasi GPU yang mahal yang mengalokasikan offscreen buffer.
// ANTI-PATTERN: Opacity di dalam animasi
AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return Opacity(
opacity: _controller.value, // saveLayer() setiap frame!
child: child,
);
},
child: const MyWidget(),
)
// BENAR: gunakan FadeTransition atau AnimatedOpacity
// FadeTransition menggunakan layer compositing -- jauh lebih efisien
FadeTransition(
opacity: _controller, // tidak ada saveLayer()
child: const MyWidget(),
)
// Atau untuk implicit:
AnimatedOpacity(
opacity: _isVisible ? 1.0 : 0.0,
duration: const Duration(milliseconds: 300),
child: const MyWidget(),
)
Hindari widget Opacity, dan terutama hindari di dalam animasi. Gunakan AnimatedOpacity atau FadeInImage sebagai gantinya. Untuk efek opacity sederhana tanpa animasi, pertimbangkan menggunakan warna dengan nilai alpha alih-alih Opacity widget.
9. Logika Bisnis di dalam build() #
Melakukan komputasi berat, pemformatan kompleks, atau operasi yang tidak perlu di dalam build() menyebabkan pekerjaan yang berulang setiap kali widget di-rebuild.
// ANTI-PATTERN: komputasi berat di build()
@override
Widget build(BuildContext context) {
// Dipanggil setiap rebuild -- operasi O(n²)!
final filtered = produk
.where((p) => p.harga < _maxHarga)
.toList()
..sort((a, b) => a.nama.compareTo(b.nama));
// Pemformatan yang tidak perlu diulang
final formattedDate = DateFormat('EEEE, dd MMMM yyyy', 'id').format(DateTime.now());
// Regex yang dikompilasi ulang setiap rebuild
final emailRegex = RegExp(r'^[^@]+@[^@]+\.[^@]+');
return Column(children: [...]);
}
// BENAR: precompute di luar build atau cache hasilnya
class _ProdukState extends State<ProdukScreen> {
List<Produk> _produk = [];
double _maxHarga = 1000000;
List<Produk>? _cachedFiltered; // cache hasil filter
// Recompute hanya saat data atau filter berubah
List<Produk> get _filteredProduk {
return _cachedFiltered ??= _produk
.where((p) => p.harga < _maxHarga)
.toList()
..sort((a, b) => a.nama.compareTo(b.nama));
}
void _setMaxHarga(double harga) {
setState(() {
_maxHarga = harga;
_cachedFiltered = null; // invalidate cache
});
}
// Buat satu kali, gunakan berulang -- letakkan di luar build atau sebagai const
static final _emailRegex = RegExp(r'^[^@]+@[^@]+\.[^@]+');
static final _dateFormatter = DateFormat('EEEE, dd MMMM yyyy', 'id');
@override
Widget build(BuildContext context) {
// build() sekarang ringan -- hanya menyusun widget
return ProdukList(produk: _filteredProduk);
}
}
10. GlobalKey yang Berlebihan #
GlobalKey sangat powerful tapi mahal — ia menonaktifkan optimasi widget tree dan menyebabkan widget selalu di-rebuild dari awal saat dipindahkan. Penggunaan berlebihan bisa menjadi bottleneck signifikan.
// ANTI-PATTERN: GlobalKey untuk tujuan yang tidak memerlukannya
class _FormState extends State<MyForm> {
// GlobalKey untuk setiap field -- sangat mahal!
final _namaKey = GlobalKey<FormFieldState>();
final _emailKey = GlobalKey<FormFieldState>();
final _phoneKey = GlobalKey<FormFieldState>();
final _addressKey = GlobalKey<FormFieldState>();
@override
Widget build(BuildContext context) {
return Column(
children: [
TextFormField(key: _namaKey), // GlobalKey di setiap field
TextFormField(key: _emailKey),
TextFormField(key: _phoneKey),
TextFormField(key: _addressKey),
],
);
}
}
// BENAR: satu GlobalKey untuk Form, controller untuk kontrol field
class _FormState extends State<MyForm> {
final _formKey = GlobalKey<FormState>(); // satu GlobalKey untuk Form
final _namaController = TextEditingController();
final _emailController = TextEditingController();
void _submit() {
if (_formKey.currentState!.validate()) {
_formKey.currentState!.save();
// proses data
}
}
@override
void dispose() {
_namaController.dispose();
_emailController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Form(
key: _formKey, // hanya satu GlobalKey
child: Column(
children: [
TextFormField(controller: _namaController),
TextFormField(controller: _emailController),
],
),
);
}
}
11. Border.all() di dalam build() #
Border.all() membuat objek Border baru setiap kali widget di-rebuild karena Border bersifat immutable. Untuk widget yang sering di-rebuild, ini menciptakan banyak objek tak berguna.
// ANTI-PATTERN: Border.all() di dalam build()
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
border: Border.all(color: Colors.grey, width: 1), // objek baru setiap rebuild!
),
child: const Text('Konten'),
);
}
// BENAR: definisikan border sebagai konstanta
static const _border = Border.fromBorderSide(
BorderSide(color: Colors.grey, width: 1),
);
@override
Widget build(BuildContext context) {
return Container(
decoration: const BoxDecoration(border: _border), // reuse objek yang sama
child: const Text('Konten'),
);
}
12. StatefulWidget Tanpa Alasan #
Menggunakan StatefulWidget saat StatelessWidget sudah cukup menambahkan kompleksitas tanpa manfaat. State object tetap hidup di memory selama widget ada di tree.
// ANTI-PATTERN: StatefulWidget yang tidak punya state internal
class UserAvatar extends StatefulWidget {
final String imageUrl;
final double radius;
const UserAvatar({super.key, required this.imageUrl, this.radius = 24});
@override
State<UserAvatar> createState() => _UserAvatarState();
}
class _UserAvatarState extends State<UserAvatar> {
// Tidak ada state internal sama sekali!
@override
Widget build(BuildContext context) {
return CircleAvatar(
radius: widget.radius,
backgroundImage: NetworkImage(widget.imageUrl),
);
}
}
// BENAR: StatelessWidget sudah cukup
class UserAvatar extends StatelessWidget {
final String imageUrl;
final double radius;
const UserAvatar({super.key, required this.imageUrl, this.radius = 24});
@override
Widget build(BuildContext context) {
return CircleAvatar(
radius: radius,
backgroundImage: NetworkImage(imageUrl),
);
}
}
Ringkasan Cepat #
| Anti-Pattern | Dampak | Solusi |
|---|---|---|
Tidak pakai const | Rebuild tidak perlu | Tambahkan const di semua tempat yang memungkinkan |
| Widget monolitik | Rebuild masif, susah di-maintain | Pecah menjadi widget kecil yang fokus |
| Helper function | Tidak bisa di-const, selalu rebuild | Buat widget terpisah |
| setState di widget besar | Rebuild subtree besar | Isolasi state ke widget terkecil |
| Tidak dispose resource | Memory leak | Dispose di dispose() tanpa kecuali |
| Tidak cek mounted | Runtime error | if (!mounted) return; sebelum setState async |
shrinkWrap: true | Semua item dibuat sekaligus | Ganti dengan CustomScrollView + Sliver |
Opacity di animasi | saveLayer() setiap frame | Gunakan FadeTransition atau AnimatedOpacity |
Logika berat di build() | Komputasi berulang | Precompute atau cache di luar build |
| GlobalKey berlebihan | Nonaktifkan optimasi | Gunakan seperlunya, controller untuk kontrol field |
Border.all() di build | Objek baru setiap rebuild | Definisikan sebagai static const |
| StatefulWidget tanpa state | Kompleksitas tidak perlu | Gunakan StatelessWidget |
Gunakan Flutter DevTools Performance tab dengan fitur “Track Widget Rebuilds” untuk mengidentifikasi widget yang paling sering rebuild. Mulai perbaikan dari widget dengan rebuild count tertinggi — dampaknya paling besar.