Skip to content
ExponaBLOG
FLUTTER · DAY 054 / 100

Performance: avoid unnecessary rebuilds, explained simply

Smooth 60 and 120 fps apps.

Expona Admin10 min read

Hitting 60 fps consistently is one thing; maintaining it under real-world load is another. After years building ride-hailing and e-commerce platforms where frame drops meant lost conversions, I learned that performance: avoid unnecessary rebuilds is the single most impactful optimization you can apply to Flutter apps. In this guide, I'll show you exactly how to structure your widgets and manage state so that only the parts that actually change get rebuilt.

Why Rebuilds Matter

Every time a widget rebuilds, Flutter traverses the widget tree, compares the old and new widget configurations, and marks affected render objects for painting. On a 120 Hz device, you have roughly 8.3 milliseconds per frame. Unnecessary rebuilds eat into that budget fast. I've seen apps with 200+ widget rebuilds per frame just because a parent changed state that didn't affect the child. The result: dropped frames, jank, and a poor user experience.

The four strategies in this post directly attack unnecessary rebuilds. Let's walk through each one with concrete examples.

Strategy 1: Split Widgets — Smaller Widgets Rebuild Less

The foundation of rebuild optimization is widget granularity. When you keep widgets small and focused on a single responsibility, you limit the blast radius of state changes.

Consider a user profile screen. A naive implementation might look like this:

dart
class ProfileScreen extends StatefulWidget {
  @override
  State<ProfileScreen> createState() => _ProfileScreenState();
}

class _ProfileScreenState extends State<ProfileScreen> {
  late UserModel user;
  int likeCount = 0;

  @override
  void initState() {
    super.initState();
    _loadUser();
  }

  Future<void> _loadUser() async {
    // Fetch user data
  }

  void _onLike() {
    setState(() {
      likeCount++;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text(user.name)),
      body: Column(
        children: [
          CircleAvatar(backgroundImage: NetworkImage(user.avatar)),
          Text(user.bio),
          ElevatedButton(
            onPressed: _onLike,
            child: Text('Like ($likeCount)'),
          ),
        ],
      ),
    );
  }
}

Every time the user taps "Like," the entire ProfileScreen rebuilds—the avatar, the bio text, the entire layout. That's wasteful.

Now split it into focused widgets:

dart
class ProfileScreen extends StatefulWidget {
  @override
  State<ProfileScreen> createState() => _ProfileScreenState();
}

class _ProfileScreenState extends State<ProfileScreen> {
  late UserModel user;

  @override
  void initState() {
    super.initState();
    _loadUser();
  }

  Future<void> _loadUser() async {
    // Fetch user data
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text(user.name)),
      body: Column(
        children: [
          UserAvatar(imageUrl: user.avatar),
          UserBio(text: user.bio),
          LikeButton(),
        ],
      ),
    );
  }
}

class UserAvatar extends StatelessWidget {
  final String imageUrl;
  const UserAvatar({required this.imageUrl});

  @override
  Widget build(BuildContext context) {
    return CircleAvatar(backgroundImage: NetworkImage(imageUrl));
  }
}

class UserBio extends StatelessWidget {
  final String text;
  const UserBio({required this.text});

  @override
  Widget build(BuildContext context) {
    return Text(text);
  }
}

class LikeButton extends StatefulWidget {
  @override
  State<LikeButton> createState() => _LikeButtonState();
}

class _LikeButtonState extends State<LikeButton> {
  int likeCount = 0;

  void _onLike() {
    setState(() {
      likeCount++;
    });
  }

  @override
  Widget build(BuildContext context) {
    return ElevatedButton(
      onPressed: _onLike,
      child: Text('Like ($likeCount)'),
    );
  }
}

Now when LikeButton triggers a rebuild, UserAvatar and UserBio stay untouched. On busy screens with dozens of interactive elements, this approach can reduce rebuild counts by 70-80%.

Strategy 2: Const Everywhere — Constant Widgets Are Skipped

The const keyword is your most underused performance tool. When you mark a widget constructor as const, Flutter caches the widget instance. If the same const widget appears in two rebuilds with identical arguments, Flutter reuses the cached instance and skips rebuilding it entirely.

I can't overstate this: enabling avoid_unnecessary_containers and prefer_const_constructors lints in your analysis_options.yaml has caught hundreds of missed const optimizations in my team's code.

Here's a realistic example from a product listing screen:

dart
// Bad: Creates new widget instances on every rebuild
class ProductTile extends StatelessWidget {
  final Product product;

  ProductTile({required this.product});

  @override
  Widget build(BuildContext context) {
    return Card(
      child: Padding(
        padding: EdgeInsets.all(16.0),
        child: Column(
          children: [
            Text(product.name),
            SizedBox(height: 8.0),
            Text('\$${product.price}'),
          ],
        ),
      ),
    );
  }
}

// Good: Const where possible
class ProductTile extends StatelessWidget {
  final Product product;

  const ProductTile({required this.product});

  @override
  Widget build(BuildContext context) {
    return Card(
      child: Padding(
        padding: const EdgeInsets.all(16.0),
        child: Column(
          children: [
            Text(product.name),
            const SizedBox(height: 8.0),
            Text('\$${product.price}'),
          ],
        ),
      ),
    );
  }
}

The SizedBox is created once and reused across every rebuild. On a list of 100 products, that's 99 fewer widget allocations per scroll frame.

For stateless widgets with immutable arguments, mark the constructor const too:

dart
class Spacer12 extends StatelessWidget {
  const Spacer12();

  @override
  Widget build(BuildContext context) {
    return const SizedBox(height: 12);
  }
}

Now const Spacer12() is cached globally.

Strategy 3: Select Precisely — Watch Only the State a Widget Needs

If you're using a state management solution like Riverpod or BLoC, the way you consume state directly impacts rebuild frequency. A widget that watches an entire BLoC or provider will rebuild whenever any field in that state changes, even if only one unrelated field updated.

I learned this lesson the hard way on a trading platform where every price tick triggered a full portfolio rebuild. Here's the pattern:

dart
// Bad: Watches the entire state
class PortfolioSummary extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return BlocBuilder<PortfolioCubit, PortfolioState>(
      builder: (context, state) {
        return Text('Total: \$${state.total}');
      },
    );
  }
}

// Good: Select only the field you need
class PortfolioSummary extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return BlocBuilder<PortfolioCubit, PortfolioState>(
      buildWhen: (previous, current) => previous.total != current.total,
      builder: (context, state) {
        return Text('Total: \$${state.total}');
      },
    );
  }
}

With Riverpod, use select to watch a specific property:

dart
class PortfolioSummary extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final total = ref.watch(
      portfolioProvider.select((state) => state.total),
    );
    return Text('Total: \$${total}');
  }
}

Now the widget only rebuilds when total changes, not when other portfolio fields update.

For provider-based state, define separate providers for each piece of state you want to watch independently:

dart
final totalProvider = Provider((ref) {
  final portfolio = ref.watch(portfolioProvider);
  return portfolio.total;
});

final holdingsProvider = Provider((ref) {
  final portfolio = ref.watch(portfolioProvider);
  return portfolio.holdings;
});

This way, widgets watching totalProvider won't rebuild when holdings change.

Strategy 4: Avoid Work in Build — Compute in ViewModels, Not in Build Methods

Every operation in a build method runs on every rebuild. Sorting, filtering, formatting, and calculations belong in view models or providers, not in build().

I've debugged apps where build() was parsing JSON, decrypting strings, or sorting 10,000-item lists. The frame time was brutal.

dart
// Bad: Expensive work in build
class TransactionList extends StatelessWidget {
  final List<Transaction> transactions;

  const TransactionList({required this.transactions});

  @override
  Widget build(BuildContext context) {
    final sorted = transactions..sort((a, b) => b.date.compareTo(a.date));
    final filtered = sorted.where((t) => t.amount > 0).toList();
    final formatted = filtered
        .map((t) => '${t.name}: \$${t.amount.toStringAsFixed(2)}')
        .toList();

    return ListView.builder(
      itemCount: formatted.length,
      itemBuilder: (context, index) => Text(formatted[index]),
    );
  }
}

Every frame, we sort, filter, and format the entire list. On a 120 Hz device with 2,000 transactions, this is a bottleneck.

Move it to a ViewModel or provider:

dart
class TransactionViewModel {
  final List<Transaction> _transactions;

  TransactionViewModel(this._transactions);

  List<String> get formattedTransactions {
    final sorted = _transactions..sort((a, b) => b.date.compareTo(a.date));
    return sorted
        .where((t) => t.amount > 0)
        .map((t) => '${t.name}: \$${t.amount.toStringAsFixed(2)}')
        .toList();
  }
}

class TransactionList extends StatelessWidget {
  final TransactionViewModel viewModel;

  const TransactionList({required this.viewModel});

  @override
  Widget build(BuildContext context) {
    return ListView.builder(
      itemCount: viewModel.formattedTransactions.length,
      itemBuilder: (context, index) =>
          Text(viewModel.formattedTransactions[index]),
    );
  }
}

Better yet, cache the result if transactions don't change often:

dart
class TransactionViewModel {
  final List<Transaction> _transactions;
  List<String>? _cachedFormatted;

  TransactionViewModel(this._transactions);

  List<String> get formattedTransactions {
    if (_cachedFormatted != null) return _cachedFormatted!;

    final sorted = _transactions..sort((a, b) => b.date.compareTo(a.date));
    _cachedFormatted = sorted
        .where((t) => t.amount > 0)
        .map((t) => '${t.name}: \$${t.amount.toStringAsFixed(2)}')
        .toList();

    return _cachedFormatted!;
  }

  void invalidateCache() {
    _cachedFormatted = null;
  }
}

With Riverpod, use a provider with explicit dependencies:

dart
final formattedTransactionsProvider = Provider((ref) {
  final transactions = ref.watch(transactionsProvider);
  final sorted = [...transactions]..sort((a, b) => b.date.compareTo(a.date));
  return sorted
      .where((t) => t.amount > 0)
      .map((t) => '${t.name}: \$${t.amount.toStringAsFixed(2)}')
      .toList();
});

class TransactionList extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final formatted = ref.watch(formattedTransactionsProvider);
    return ListView.builder(
      itemCount: formatted.length,
      itemBuilder: (context, index) => Text(formatted[index]),
    );
  }
}

Riverpod automatically memoizes the result and only recomputes when transactionsProvider changes.

Common Mistakes

  • Forgetting to const child widgets: A parent that rebuilds will always rebuild its children unless they're const. This is the most common mistake I see.
  • Watching entire objects when you need one field: In BLoC/Riverpod, always use buildWhen or select to narrow your scope. Watching a 20-field state object when you only need one field is wasteful.
  • Creating new instances in build: If you're calling MyWidget() inside a build method without const, you're creating a new instance on every rebuild, defeating memoization.
  • Running async operations in build: Never call .then(), await, or API requests in build(). Move them to initState, a provider, or a state management layer.
  • Over-splitting widgets: While granularity helps, creating 50 single-child widgets adds overhead. Split when it makes semantic sense and reduces state coupling.

Key Takeaways

  • Split widgets into smaller, focused units so that state changes in one don't trigger cascading rebuilds.
  • Mark widget constructors and internal widgets const wherever possible; Flutter caches const instances and skips rebuilding them.
  • When using state management, use buildWhen, select, or separate providers to watch only the fields a widget needs.
  • Move all computation—sorting, filtering, formatting, parsing—out of build() methods and into view models or providers that you can cache.
  • Profile your app with DevTools' Performance tab; use the "Rebuild" highlight to identify widgets rebuilding unexpectedly.
  • Start with these four strategies and measure before and after. On production e-commerce apps, I've seen 40-60 ms frame times drop to 8-12 ms just by applying these patterns consistently.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles