Hi there, fellow Flutter enthusiast!

Today I will share more about FutureBuilder in Flutter - an essential widget for handling asynchronous operations in your UI. Whether you’re fetching data from an API, reading files or performing complex calculations, FutureBuilder can come in handy.

Understanding Future

Before we dive into FutureBuilder, let’s quickly understand what Future<T> is. In Flutter/Dart, Future<T> represents a value that will be available at some point in the future. It’s the foundation for asynchronous programming, allowing you to perform async operations.

Future<UserData> fetchUserData() async {
    // Simulating API call
    await Future.delayed(const Duration(seconds: 2));
    return UserData(name: 'John Doe', age: 30);
}

Introduction to FutureBuilder

FutureBuilder is a widget that makes it easy to work with Future<T> in your UI. It helps to handle different states of the future:

  • Loading state (when the future is running)
  • Success state (when the future completes successfully)
  • Error state (when the future throws an error)

Here’s a basic example:

FutureBuilder<UserData>(
    future: fetchUserData(),
    builder: (context, snapshot) {
        if (snapshot.connectionState == ConnectionState.waiting) {
            return const Center(child: CircularProgressIndicator());
        }
        
        if (snapshot.hasError) {
            return Center(
                child: Text('Error: ${snapshot.error}'),
            );
        }
        
        if (snapshot.hasData) {
            final user = snapshot.data!;
            return UserProfileWidget(user: user);
        }
        
        return const SizedBox(); // Fallback for initial state
    },
)

Best Practices

1. Avoid Creating Futures in build()

One common mistake is creating the future directly in the build method. This can cause unnecessary future executions and potential infinite loops.

Don’t do this:

FutureBuilder<UserData>(
    future: fetchUserData(), // Don't create future here!
    builder: (context, snapshot) {
        // ...
    },
)

Do this instead:

class _MyWidgetState extends State<MyWidget> {
    late final Future<UserData> _userDataFuture;

    @override
    void initState() {
        super.initState();
        _userDataFuture = fetchUserData();
    }

    @override
    Widget build(BuildContext context) {
        return FutureBuilder<UserData>(
            future: _userDataFuture,
            builder: (context, snapshot) {
                // ...
            },
        );
    }
}

2. Proper Error Handling

Always provide meaningful error feedback to your users:

FutureBuilder<UserData>(
    future: _userDataFuture,
    builder: (context, snapshot) {
        if (snapshot.connectionState == ConnectionState.waiting) {
            return const LoadingWidget();
        }
        
        if (snapshot.hasError) {
            return ErrorWidget(
                error: snapshot.error,
                onRetry: () {
                    setState(() {
                        _userDataFuture = fetchUserData();
                    });
                },
            );
        }
        
        final user = snapshot.data!;
        return UserProfileWidget(user: user);
    },
)

3. Handle All Connection States

FutureBuilder provides different connection states that you should handle appropriately:

FutureBuilder<UserData>(
    future: _userDataFuture,
    builder: (context, snapshot) {
        switch (snapshot.connectionState) {
            case ConnectionState.none:
                return const Text('No future to execute');
                
            case ConnectionState.waiting:
                return const LoadingWidget();
                
            case ConnectionState.active:
                // This state occurs when the Future is still active but has yielded a value
                // It's more common with StreamBuilder, but can occur with certain Future implementations
                return Column(
                  children: [
                    if (snapshot.hasData)
                      UserProfileWidget(user: snapshot.data!),
                    const LinearProgressIndicator(),
                  ],
                );
                
            case ConnectionState.done:
                if (snapshot.hasError) {
                    return ErrorWidget(
                        error: snapshot.error,
                        onRetry: _retryFetch,
                    );
                }
                
                return UserProfileWidget(user: snapshot.data!);
        }
    },
)

Advanced Usage

1. Refreshable Future

Here’s how to implement a pull-to-refresh functionality with FutureBuilder:

RefreshIndicator(
    onRefresh: () async {
        setState(() {
            _userDataFuture = fetchUserData();
        });
    },
    child: FutureBuilder<UserData>(
        future: _userDataFuture,
        builder: (context, snapshot) {
            // ... your builder logic
        },
    ),
)

2. Caching Results

You can cache the future’s result to avoid unnecessary API calls:

class _MyWidgetState extends State<MyWidget> {
    late Future<UserData> _userDataFuture;
    UserData? _cachedData;

    @override
    void initState() {
        super.initState();
        _userDataFuture = fetchUserData().then((data) {
            _cachedData = data;
            return data;
        });
    }

    Future<void> _refreshData() async {
        setState(() {
            _userDataFuture = fetchUserData().then((data) {
                _cachedData = data;
                return data;
            });
        });
    }

    @override
    Widget build(BuildContext context) {
        return FutureBuilder<UserData>(
            future: _userDataFuture,
            builder: (context, snapshot) {
                if (snapshot.connectionState == ConnectionState.waiting && _cachedData != null) {
                    // Show cached data while refreshing
                    return UserProfileWidget(user: _cachedData!);
                }
                
                // ... rest of your builder logic
            },
        );
    }
}

Remember

  • Avoid creating new futures in the build method
  • Initialize futures in initState
  • Cancel any ongoing operations in dispose()
  • Handle all possible states
  • Provide meaningful error feedback
  • Consider caching for better UX
  • Use proper type parameters

Example Code

Visit my Flutter Playground App repository for a complete example

Conclusion

Choose FutureBuilder for simpler scenarios, and explore options like Provider, Bloc or Riverpod for more complex state management needs.

P.S. If you found this helpful, don’t forget to check out my other Flutter articles!

Share: