Flutter Performance Series: Optimizing State Management & Asynchronous Operations

Shekhar Shubh
FlutterDude
15 min readSep 20, 2023

--

Introduction

Welcome back to our ongoing Flutter Performance Series! If you’ve been keeping up, you may recall our previous installment, “Flutter Performance Series: Building an Efficient Widget Tree”, where we delved into the intricacies of optimizing your widget tree for peak performance. If you haven’t had a chance to read it yet, we highly recommend checking it out to arm yourself with valuable knowledge on widget optimization. Today, we’re going to explore another essential topic that complements our previous discussions — lazy loading and its role in on-demand state initialization. Should you need personalized guidance or more extensive resources on Flutter, don’t hesitate to visit our website, your go-to destination for all things Flutter-related. Now, without further ado, let’s dive into how lazy loading can make your Flutter apps more efficient and user-friendly!

Using StreamBuilder and FutureBuilder for Asynchronous Operations

What Are StreamBuilder and FutureBuilder?

  • StreamBuilder: A widget that rebuilds its UI at every new value emitted by a Stream.
  • FutureBuilder: Similar to StreamBuilder, but designed for Future objects, which represent a potential value or error that will be available at some time in the future.

Why Use StreamBuilder and FutureBuilder?

  1. Ease of Use: These widgets abstract much of the boilerplate code needed to handle asynchronous operations, making your codebase cleaner and more maintainable.
  2. Performance: Only the widgets that depend on the asynchronous operation are rebuilt, leading to more efficient UI updates.
  3. Reactivity: They fit seamlessly into Flutter’s reactive paradigm, allowing for easy data propagation.

Here is an example of using StreamBuilder to display a list of items from a stream:

Stream<List<Item>> getItemStream() async* {
// ...
}

class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
appBar: AppBar(
title: Text('StreamBuilder Example'),
),
body: StreamBuilder<List<Item>>(
stream: getItemStream(),
builder: (context, snapshot) {
if (snapshot.hasData) {
return ListView.builder(
itemCount: snapshot.data!.length,
itemBuilder: (context, index) {
return ListTile(
title: Text(snapshot.data![index].title),
);
},
);
} else if (snapshot.hasError) {
return Text('Error: ${snapshot.error}');
} else {
return CircularProgressIndicator();
}
},
),
),
);
}
}

This code will subscribe to the getItemStream() stream and rebuild itself whenever the stream emits a new value. If the stream emits an error, the StreamBuilder will display the error. Otherwise, the StreamBuilder will display a list of items.

FutureBuilder

FutureBuilder is a widget that builds itself based on the latest snapshot of interaction with a Future.

Here is an example of using FutureBuilder to fetch a user from an API:

Future<User> fetchUser() async {
// ...
}

class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
appBar: AppBar(
title: Text('FutureBuilder Example'),
),
body: FutureBuilder<User>(
future: fetchUser(),
builder: (context, snapshot) {
if (snapshot.hasData) {
return Text('Name: ${snapshot.data!.name}');
} else if (snapshot.hasError) {
return Text('Error: ${snapshot.error}');
} else {
return CircularProgressIndicator();
}
},
),
),
);
}
}

This code will fetch the user from the API and rebuild itself whenever the Future completes. If the Future completes with an error, the FutureBuilder will display the error. Otherwise, the FutureBuilder will display the user's name.

StreamBuilder and FutureBuilder are two powerful widgets that can be used to handle asynchronous operations in Flutter. StreamBuilder is used to display continuously updating data, while FutureBuilder is used to display the results of a one-time asynchronous operation.

Use StreamBuilder to:

  • Display a real-time chat application.
  • Show a stock ticker.
  • Display a list of tweets that is continuously updating.

Use FutureBuilder to:

  • Display the results of a search query.
  • Fetch and display data from an API.
  • Load and display a file.
Photo by Alex Andrews

setState vs ValueNotifier: Efficiently Triggering UI Updates

One of the foundational aspects of building interactive Flutter apps is state management, which largely determines an application’s performance and user experience. Two commonly used techniques to manage state and update the UI are setState and ValueNotifier. In this section, we'll explore the strengths and weaknesses of each and provide guidance on when to use one over the other for efficient UI updates.

Understanding setState

  • What is it?: setState is the most straightforward way to change the state of a widget in Flutter.
  • How it Works: The setState function triggers a rebuild of the widget tree, leading to the execution of the build method and updating the UI.
void _incrementCounter() {
setState(() {
_counter++;
});
}

Understanding ValueNotifier

  • What is it?: ValueNotifier is a simple ChangeNotifier that holds a single value and notifies its listeners when that value changes.
  • How it Works: Instead of rebuilding the entire widget tree, ValueNotifier triggers rebuilds only for the widgets that are listening to it.
final counter = ValueNotifier<int>(0);

ValueListenableBuilder<int>(
valueListenable: counter,
builder: (context, value, child) {
return Text('Counter: $value');
},
);

Comparing setState and ValueNotifier

When to Use setState

  1. Simple Widgets: For less complex widgets where performance isn’t a concern, setState is straightforward to implement.
  2. Local State: It is perfect for managing local state within a single widget.
  3. Quick Prototyping: Ideal for rapid development and proof-of-concept projects.

When to Use ValueNotifier

  1. Scoped Rebuilds: When you want to rebuild only a specific part of the widget tree.
  2. Reusable State: When you want to share state between multiple widgets.
  3. Complex UI: For apps with intricate UI that require multiple widgets to respond to state changes.

Performance Implications

  • Efficiency: ValueNotifier can be more efficient for targeted updates, reducing unnecessary rebuilds.
  • CPU Utilization: Overuse of setState can lead to higher CPU utilization and reduced frame rates.
Photo by le vy (yes it has a space if you noticed)

Leveraging ChangeNotifier for Reactive State Management

In any robust Flutter application, state management is a core component that can’t be ignored. While setState and ValueNotifier offer straightforward solutions, they might not suffice for more complex applications. This is where Flutter's ChangeNotifier comes into play. Serving as the backbone of reactive state management, it allows for scalable and maintainable code. In this section, we'll explore how to leverage ChangeNotifier for reactive state management and why it's an excellent choice for enhancing performance.

What is ChangeNotifier?

  • Definition: ChangeNotifier is a simple class included in the Flutter framework. It can notify its listeners whenever a change occurs, making it extremely useful for managing complex state.
  • How it Works: When a variable or property within a ChangeNotifier class changes, the notifyListeners() method is called, triggering a rebuild of all the widgets that are listening to the change.

Here is an example of using ChangeNotifier for reactive state management:

class MyModel extends ChangeNotifier {
int counter = 0;

void incrementCounter() {
counter++;
notifyListeners();
}
}

class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
appBar: AppBar(
title: Text('My App'),
),
body: Consumer<MyModel>(
builder: (context, model, child) {
return Text('Counter: ${model.counter}');
},
),
),
);
}
}

In this example, we have a MyModel class that extends ChangeNotifier and contains a counter property. We also have a MyApp widget that wraps a Consumer widget.

The Consumer widget will rebuild itself whenever the state of the MyModel object changes. In this case, the Consumer widget will rebuild itself whenever the counter property changes.

This means that whenever the user clicks the button in the MyModel class, the counter property will be incremented and the Consumer widget will rebuild itself, which will update the UI to display the new counter value.

ChangeNotifier is a powerful tool for reactive state management in Flutter. It is easy to use and can be used to create complex state management systems.

Here are some tips for using ChangeNotifier for reactive state management:

  • Avoid using setState() in your widgets. Instead, use the notifyListeners() method in your ChangeNotifier class to notify listeners of state changes.
  • Use conditional statements to decide when to call notifyListeners(), thus avoiding unnecessary rebuilds.
  • Use the Consumer widget to wrap your widgets so that they will rebuild whenever the state of the model changes.
  • Use multiple ChangeNotifier objects to manage different parts of your state. This can help to keep your code organized and maintainable.
  • Use the Provider package to simplify the process of managing ChangeNotifier objects.

When to Use ChangeNotifier

  • Complex Apps: Best suited for more complex applications with multiple interactive features.
  • Shared State: When the state needs to be shared or accessed by multiple widgets.
  • Dynamic UI: If you aim to create a dynamic, responsive user interface that reacts seamlessly to state changes.

Best Practices

  • Dispose Properly: Always remember to dispose of your ChangeNotifier objects to free up resources.
  • Immutable State: Make state variables private and expose them through public getters to maintain immutability.
Photo by Rodolfo Clix

Debouncing and Throttling: Optimal State Changes with Reduced Overhead

Even with efficient state management tools like setState, ValueNotifier, and ChangeNotifier, managing your app's performance can be a challenge, especially when dealing with rapid, successive state changes. Overreacting to every minor state alteration can cripple performance and lead to a poor user experience. That's where debouncing and throttling techniques come into play. This section dives into how these methods can optimize state changes while reducing overhead.

What Are Debouncing and Throttling?

  • Debouncing: This technique delays the execution of a function until after a defined period has elapsed since the last time the debounced function was invoked. It’s often used in search functionalities and form validations.
  • Throttling: Unlike debouncing, throttling executes the function at a fixed, regular interval irrespective of the number of times it is triggered. It’s commonly used in scrolling events and animations.

Here is an example of how to debounce a function in Flutter:

import 'dart:async';

void debounce(Function func, Duration duration) {
Timer? timer;

void debouncedFunc() {
if (timer != null) {
timer.cancel();
}

timer = Timer(duration, func);
}

return debouncedFunc;
}

class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
final debouncedFunc = debounce(() {
// Update state here
}, Duration(milliseconds: 250));

return Scaffold(
appBar: AppBar(
title: Text('My App'),
),
body: Center(
child: TextField(
onChanged: (value) {
debouncedFunc();
},
),
),
);
}
}

In this example, the debouncedFunc() function will only be called after 250 milliseconds have passed since the last time it was called. This means that the state will only be updated after the user has stopped typing for at least 250 milliseconds.

Here is an example of how to throttle a function in Flutter:

import 'dart:async';

void throttle(Function func, Duration duration) {
Timer? timer;
bool canCall = true;

void throttledFunc() async {
if (!canCall) {
return;
}

canCall = false;
timer?.cancel();

await func();

canCall = true;
timer = Timer(duration, throttledFunc);
}

return throttledFunc;
}

class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
final throttledFunc = throttle(() {
// Update state here
}, Duration(milliseconds: 250));

return Scaffold(
appBar: AppBar(
title: Text('My App'),
),
body: ListView.builder(
itemCount: 100,
itemBuilder: (context, index) {
return ListTile(
title: Text('Item $index'),
onTap: throttledFunc,
);
},
),
);
}
}

Debouncing and throttling are two techniques that can be used to reduce the overhead of state changes in Flutter.

Debouncing

Debouncing is a technique that delays the execution of a function until a certain amount of time has passed since the last time it was called. This can be useful for preventing unnecessary state changes, such as when the user is typing in a search bar.

Here is an example of how to debounce a function in Flutter:

import 'dart:async';

void debounce(Function func, Duration duration) {
Timer? timer;
void debouncedFunc() {
if (timer != null) {
timer.cancel();
}
timer = Timer(duration, func);
}
return debouncedFunc;
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
final debouncedFunc = debounce(() {
// Update state here
}, Duration(milliseconds: 250));
return Scaffold(
appBar: AppBar(
title: Text('My App'),
),
body: Center(
child: TextField(
onChanged: (value) {
debouncedFunc();
},
),
),
);
}
}

In this example, the debouncedFunc() function will only be called after 250 milliseconds have passed since the last time it was called. This means that the state will only be updated after the user has stopped typing for at least 250 milliseconds.

Throttling

Throttling is a technique that limits the number of times a function can be called within a certain period of time. This can be useful for preventing unnecessary state changes, such as when the user is scrolling through a list of items.

Here is an example of how to throttle a function in Flutter:

import 'dart:async';

void throttle(Function func, Duration duration) {
Timer? timer;
bool canCall = true;
void throttledFunc() async {
if (!canCall) {
return;
}
canCall = false;
timer?.cancel();
await func();
canCall = true;
timer = Timer(duration, throttledFunc);
}
return throttledFunc;
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
final throttledFunc = throttle(() {
// Update state here
}, Duration(milliseconds: 250));
return Scaffold(
appBar: AppBar(
title: Text('My App'),
),
body: ListView.builder(
itemCount: 100,
itemBuilder: (context, index) {
return ListTile(
title: Text('Item $index'),
onTap: throttledFunc,
);
},
),
);
}
}

In this example, the throttledFunc() function can only be called once every 250 milliseconds. This means that the state will only be updated once every 250 milliseconds, even if the user taps on a list item multiple times in a row.

Use Cases and Applications

  • Form Validation: Debouncing is useful in live form validation, where you don’t want to validate the input immediately after each keystroke.
  • Search Auto-suggest: Both debouncing and throttling can optimize performance in search functionalities by controlling the rate of API calls.
  • Scroll Events: Throttling is ideal for handling events triggered during scrolling, such as lazy loading or infinite scrolling.

Performance Benefits

  • Reduced CPU Load: By minimizing the number of function calls, you can reduce the load on the CPU, thereby improving performance.
  • Network Efficiency: These techniques can help in reducing the number of network requests, leading to faster load times and less data usage.
  • Smoother User Experience: Limiting redundant state updates ensures that your app remains responsive and fluid.

Tips for Optimal Performance

  • Tune Delays Appropriately: The delay time should be tuned according to the use case. Too long a delay might make the app seem sluggish, while too short a delay might negate the performance benefits.
  • Combine with State Management Tools: Debouncing and throttling can be combined with existing state management tools like ValueNotifier or ChangeNotifier for more robust performance optimization.
Photo by Torsten Dettlaff

Batched Updates: Minimizing Widget Rebuilds for Multiple State Changes

While it’s common to update the state of an application piece by piece, doing so can inadvertently lead to excessive widget rebuilds, harming performance. Here is where the concept of batched updates comes into play. This section will shed light on how batched updates can help you minimize widget rebuilds and why it’s a recommended strategy for optimal performance.

What Are Batched Updates?

  • Definition: Batched updates involve grouping multiple state changes into a single operation to trigger a single widget rebuild, as opposed to separate rebuilds for each change.
  • How It Works: All the state changes in the batch are processed together, followed by a single call to update the UI, thereby reducing the number of widget rebuilds.
import 'package:flutter/scheduler.dart';

class MyModel extends ChangeNotifier {
int counter1 = 0;
int counter2 = 0;

void incrementCounter1() {
counter1++;
notifyListeners();
}

void incrementCounter2() {
counter2++;
notifyListeners();
}
}

class MyApp extends StatelessWidget {
final MyModel model;

MyApp(this.model);

@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
appBar: AppBar(
title: Text('My App'),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Counter 1: ${model.counter1}'),
Text('Counter 2: ${model.counter2}'),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: () {
Scheduler.postFrameCallback((_) {
model.incrementCounter1();
model.incrementCounter2();
});
},
child: Icon(Icons.add),
),
),
);
}
}

In this example, the Scheduler.postFrameCallback() method is used to schedule the model.incrementCounter1() and model.incrementCounter2() functions to be called after the current frame has been rendered. This means that the two widgets will only be rebuilt once, even though the state of both widgets has been changed.

Advantages of using batched updates:

  • Improved performance: Batched updates can improve the performance of applications that have complex state management systems by reducing the number of widget rebuilds.
  • Reduced code complexity: Batched updates can reduce the complexity of code by simplifying the process of updating the state of multiple widgets.

Disadvantages of using batched updates:

  • Delayed state updates: Batched updates can delay the update of widget state, which can lead to a less responsive user interface.
  • Difficult to debug: Batched updates can make it more difficult to debug code, as it can be difficult to track down the source of a state update.

Tips for Optimal Performance

  • Defer State Changes: If possible, defer non-critical state changes to be processed as part of a batch update.
  • Time-Sensitive Updates: For time-sensitive features, ensure that the batch doesn’t delay important updates.
  • Avoid Nested Widgets: When using batched updates, try to avoid nesting stateful widgets to ensure that the update doesn’t trigger unintended rebuilds in the widget tree.

When to Use Batched Updates

  • Bulk Operations: Particularly useful when you have a series of state changes happening in quick succession.
  • Animations: When multiple elements in your UI are part of an orchestrated animation.
  • Data-heavy Applications: In scenarios where the UI needs to reflect multiple pieces of new or updated data simultaneously.
Photo by Inge Wallumrød

Lazy Loading: On-Demand State Initialization

While we’ve previously discussed techniques such as debouncing, throttling, and batched updates, another crucial approach to boost your Flutter application’s performance is lazy loading. This section will explore how lazy loading helps with on-demand state initialization and why it’s an essential tool for enhancing your app’s efficiency.

What is Lazy Loading?

  • Definition: Lazy loading is a design pattern that delays the initialization of an object, or the fetching of resources, until the point at which they’re needed.
  • How It Works: Instead of pre-loading all assets or initializing all states at the application’s launch, lazy loading waits until these elements are actually required, reducing initial load time and memory usage.
class MyApp extends StatelessWidget {
final List<String> items = ['Item 1', 'Item 2', 'Item 3'];

@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
appBar: AppBar(
title: Text('My App'),
),
body: LazyBuilder(
builder: (context) {
return ListView.builder(
itemCount: items.length,
itemBuilder: (context, index) {
return ListTile(
title: Text(items[index]),
);
},
);
},
),
),
);
}
}

In this example, the LazyBuilder widget will only build the ListView widget when the user scrolls to the bottom of the list. This means that the ListView widget will not be built until it is needed, which can improve the performance of the application.

You can also use lazy loading to initialize other types of objects, such as state management objects and network connections. For example, you could use lazy loading to initialize a database connection only when the user tries to access the database.

Advantages of lazy loading:

  • Improved performance: Lazy loading can improve the performance of applications by reducing the amount of memory that is used and the time it takes to start up the application.
  • Reduced memory usage: Lazy loading can reduce the memory usage of applications by deferring the initialization of objects until they are needed.
  • Increased responsiveness: Lazy loading can increase the responsiveness of applications by preventing the application from blocking while initializing objects.

Disadvantages of lazy loading:

  • Increased complexity: Lazy loading can increase the complexity of code, as it can be difficult to track down the source of a state update.
  • Delayed state updates: Lazy loading can delay the update of widget state, which can lead to a less responsive user interface.

Use Cases

  • Image Galleries: In an app that displays an extensive gallery of images, you can use lazy loading to load images only when they’re about to be displayed.
  • Infinite Scrolling: In applications with a long list of data, like a news feed, lazy loading can fetch additional content as the user scrolls down.
  • Tabs and Pages: For tabbed interfaces or multi-page apps, you can load content for each tab only when it is accessed.

Performance Tips

  • Placeholder Content: Use skeleton screens or loading spinners to indicate that content is being lazily loaded.
  • Error Handling: Implement error fallbacks in case the resource fails to load.
  • Prioritize Crucial Content: Always ensure that mission-critical content is pre-loaded, so it’s immediately available to the user.
Photo by olia danilevich

Conclusion

Optimizing your Flutter app’s performance is not just a one-time task; it’s an ongoing process that can significantly impact user satisfaction and retention. From employing lazy loading to utilizing advanced state management techniques like batched updates and debouncing, there are numerous ways to enhance your app’s efficiency.

If this all seems a bit overwhelming, fear not! Our series on Flutter performance aims to provide you with actionable, easy-to-implement guidance that can make a world of difference in your projects. And remember, you don’t have to go it alone. Our team of Flutter experts is always here to offer personalized consultations and solutions tailored to your needs.

For more resources, tutorials, and expert advice, make sure to visit our website. If you find yourself in need of specialized help or have specific questions, don’t hesitate to reach out to us via email hello@flutterdude.com. We’re committed to helping you achieve optimal performance in your Flutter applications and ensuring that your users have the best experience possible.

Thank you for following along in our Flutter Performance Series. We’re looking forward to assisting you on your journey to creating incredibly efficient and high-performing Flutter apps.

--

--

Shekhar Shubh
FlutterDude

Tech Enthusiast, Word Whisperer, Future Gazer. I thrive at the intersection of technology, storytelling, and insight.