Dart logo
SQL Server logo
Technology logo
Technology logo

Your Flutter Code Works… Until It Doesn’t. These 15 Tips Fix That.

Vishnu Unnikrishnan

Vishnu Unnikrishnan

December 5, 2025
8 min read
Your Flutter Code Works… Until It Doesn’t. These 15 Tips Fix That.

There's a moment every Flutter developer hits. You've built something that works. It runs. It looks good. Then it hits production real users, real data, real scale and suddenly everything falls apart. Screens stutter. Memory spikes. Crashes roll in.

I've been on both sides of this. I've shipped the broken code, and I've been the one cleaning up the mess at 2 AM wondering how nobody caught this in test/review. After hundreds of PRs and more post-mortems than I'd like to admit, I started noticing patterns. The same mistakes. The same oversights. The same "I didn't know that" moments.

This is the guide I wish existed when I started.


1. const Is Your Best Friend (Seriously)#

This is the biggest free performance win in Flutter.

When you mark a widget as const, Flutter says: "Oh, this thing never changes? Cool, I'll just reuse it forever." No rebuilding. No memory churn. Magic.

// 🚫 Every rebuild creates new objects
return Column(
  children: [
    Text('Hello World'),
    SizedBox(height: 16),
  ],
);

// ✅ Flutter reuses these forever
return const Column(
  children: [
    Text('Hello World'),
    SizedBox(height: 16),
  ],
);

Pro move: Add these to your analysis_options.yaml and let the linter yell at you:

linter:
  rules:
    - prefer_const_constructors
    - prefer_const_literals_to_create_immutables

2. Stop Creating Functions Inside build()#

Every single rebuild? Dart creates a brand new function object. Every. Single. Time.

// 🚫 New function on every rebuild
ElevatedButton(
  onPressed: () {
    print('Pressed!');
    _handleSubmit();
  },
  child: Text('Submit'),
)

// ✅ Same function reference, always
ElevatedButton(
  onPressed: _onSubmitPressed,
  child: const Text('Submit'),
)

void _onSubmitPressed() {
  print('Pressed!');
  _handleSubmit();
}

It's cleaner. It's faster. It's testable. Win-win-win.


3. ListView() vs ListView.builder()—Pick Wisely#

Got a list of 10,000 items? ListView() loads them all immediately. Your users' phones will hate you.

ListView.builder()? Only builds what's on screen. Lazy loading FTW.

// 🚫 Loads everything. RIP memory.
ListView(
  children: items.map((item) => ListTile(title: Text(item.name))).toList(),
)

// ✅ Builds on-demand. Your phone says thank you.
ListView.builder(
  itemCount: items.length,
  itemBuilder: (context, index) => ListTile(
    title: Text(items[index].name),
  ),
)

Same rule applies to GridView.builder(), PageView.builder(), and SliverList.builder().


4. ValueNotifier: The State Management Nobody Talks About#

Not everything needs Provider. Not everything needs Riverpod. Not everything needs BLoC.

Sometimes you just need a number to go up.

class CounterScreen extends StatelessWidget {
  final counter = ValueNotifier<int>(0);

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        ValueListenableBuilder<int>(
          valueListenable: counter,
          builder: (context, value, _) => Text('Count: $value'),
        ),
        ElevatedButton(
          onPressed: () => counter.value++,
          child: const Text('+1'),
        ),
      ],
    );
  }
}

Use it for: toggles, form fields, local counters, simple animations.

Don't use it for: complex business logic or cross-screen state.


5. setState() Rebuilds EVERYTHING#

That tiny counter in the corner of your massive product screen? You call setState(), and Flutter rebuilds the whole thing. Images, text, buttons—everything.

// 🚫 One counter changes, everything rebuilds
class ProductScreen extends StatefulWidget { ... }

class _ProductScreenState extends State<ProductScreen> {
  int quantity = 1;

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        ProductImage(),      // Rebuilds. Why?
        ProductDetails(),    // Rebuilds. Why?
        QuantitySelector(),  // This is the only thing that changed!
      ],
    );
  }
}

The fix? Extract the changing part:

// ✅ Only QuantitySelector rebuilds
class ProductScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        const ProductImage(),
        const ProductDetails(),
        QuantitySelector(), // Stateful, isolated
      ],
    );
  }
}

6. Keys: Use Them When Lists Get Weird#

Removing items from a list? Reordering things? Flutter gets confused about which widget is which.

Keys fix that.

ListView.builder(
  itemCount: items.length,
  itemBuilder: (context, index) => ListTile(
    key: ValueKey(items[index].id), // Now Flutter knows who's who
    title: Text(items[index].name),
  ),
)

Don't need keys: Static lists, widgets that never move.


7. Dispose Your Stuff (Or Enjoy Your Memory Leaks)#

Controllers and streams don't clean themselves up. Forget to dispose? Congrats, you've got a memory leak.

class _MyScreenState extends State<MyScreen> {
  final _controller = TextEditingController();
  final _scrollController = ScrollController();
  StreamSubscription? _subscription;

  @override
  void initState() {
    super.initState();
    _subscription = myStream.listen((data) { /* ... */ });
  }

  @override
  void dispose() {
    _controller.dispose();
    _scrollController.dispose();
    _subscription?.cancel();
    super.dispose(); // Always call super.dispose() last
  }
}

Dispose checklist: TextEditingController, ScrollController, AnimationController, StreamSubscription, FocusNode, ChangeNotifier.


8. Don't Do Math in build()#

build() can fire dozens of times per second. That sorting algorithm you wrote? It's running constantly.

// 🚫 Sorting on every frame
@override
Widget build(BuildContext context) {
  final sorted = items..sort((a, b) => a.name.compareTo(b.name));
  final filtered = sorted.where((i) => i.isActive).toList();
  return ListView(children: filtered.map(/* ... */).toList());
}

// ✅ Compute once, use forever
late final List<Item> filteredItems;

@override
void initState() {
  super.initState();
  filteredItems = _processItems();
}

List<Item> _processItems() {
  final sorted = [...items]..sort((a, b) => a.name.compareTo(b.name));
  return sorted.where((i) => i.isActive).toList();
}

If it's expensive and the result won't change? Do it in initState().


9. MediaQuery.of(context) Is Sneaky Expensive#

Every call subscribes to all MediaQuery changes. Keyboard pops up? Rebuild. Screen rotates? Rebuild.

// 🚫 Subscribes to everything
final width = MediaQuery.of(context).size.width;
final padding = MediaQuery.of(context).padding;

// ✅ Only subscribes to what you need (Flutter 3.10+)
final width = MediaQuery.sizeOf(context).width;
final padding = MediaQuery.paddingOf(context);

Also available: viewInsetsOf(), orientationOf().


10. Consumer Saves Rebuilds#

Using Provider? Don't watch() at the top of your tree.

// 🚫 Everything rebuilds when cart changes
Widget build(BuildContext context) {
  final cart = context.watch<CartProvider>();
  return Column(
    children: [
      const ProductImage(),
      const ProductDetails(),
      Text('Items: ${cart.itemCount}'), // Only this needs the data
    ],
  );
}

// ✅ Only the text rebuilds
Widget build(BuildContext context) {
  return Column(
    children: [
      const ProductImage(),
      const ProductDetails(),
      Consumer<CartProvider>(
        builder: (context, cart, _) => Text('Items: ${cart.itemCount}'),
      ),
    ],
  );
}

11. Always Check mounted After Async Calls#

User taps button. API call starts. User navigates away. API returns. You call setState().

Crash.

Future<void> loadData() async {
  try {
    final data = await api.fetchData();
    if (mounted) { // Is this widget still alive?
      setState(() => this.data = data);
    }
  } catch (e, stackTrace) {
    if (mounted) {
      setState(() => error = e.toString());
    }
    // Log it somewhere useful
    crashlytics.recordError(e, stackTrace);
  }
}

mounted is your friend. Use it.


12. Freezed: Stop Writing Boilerplate#

Manual data classes are like 40 lines of pain. copyWith(), ==, hashCode, toString()...

Or you could write 4 lines:

@freezed
class User with _$User {
  const factory User({
    required String id,
    required String name,
    required String email,
  }) = _User;

  factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
}

Boom. Immutability, copy methods, equality, JSON serialization. Done.


13. Preload Critical Images#

Ever notice that flicker when your app loads? The logo pops in a half-second late. The hero image stutters into view. Users notice. It feels cheap.

Fix it with precacheImage():

@override
void didChangeDependencies() {
  super.didChangeDependencies();
  precacheImage(const AssetImage('assets/logo.png'), context);
  precacheImage(const AssetImage('assets/hero_banner.png'), context);
}

For network images, same idea:

precacheImage(NetworkImage(user.avatarUrl), context);

Bonus tip: Loading massive images you'll display small? Resize on decode:

Image.network(
  url,
  cacheWidth: 400,  // Decode at this size, not 4000px
  cacheHeight: 400,
)

Your users' RAM will thank you.


14. Use GoRouter (Ditch the Boilerplate Navigation)#

Declarative routing. Type-safe. Deep linking for free.

final router = GoRouter(
  routes: [
    GoRoute(path: '/', builder: (_, __) => const HomeScreen()),
    GoRoute(
      path: '/product/:id',
      builder: (_, state) => ProductScreen(
        productId: state.pathParameters['id']!,
      ),
    ),
  ],
);

// Navigate like a civilized person
context.go('/product/123');

15. Profile First, Optimize Second#

That RepaintBoundary you added? Might be hurting performance. That caching layer? Might be unnecessary.

Don't guess. Measure.

flutter run --profile

Open DevTools. Look at:

  • Frame rendering time (stay under 16ms)
  • Widget rebuild counts
  • Memory usage
  • Image cache size

The profiler will show you what's actually slow.


The PR Checklist I Use Every Time#

Before I hit "Create Pull Request":

  • const everywhere possible?
  • Disposed all controllers/subscriptions?
  • Lists using .builder()?
  • Loading/error states handled?
  • No expensive operations in build()?
  • Would a junior understand this code?

Final Thought#

Senior Flutter engineers don't know more widgets. They know when to use which patterns, why things are slow, and how to write code that doesn't wake them up at 2 AM.

Pick 2-3 tips from this list. Master them. Then come back for more.

Your future self (and your code reviewers) will thank you.


Got a Flutter tip I missed? I'd love to hear it.

Comments

Loading comments...