How to Convert Callbacks into Futures in Flutter — and Why You Should

Keep your code and calls consistent in Dart and Flutter projects

How to Convert Callbacks into Futures in Flutter — and Why You Should
Source IYKYK

Keep your code and calls consistent in Dart and Flutter projects

Free Link for Readers

In the early days of working with Flutter, callbacks felt like a natural way to deal with asynchronous operations. You pass a function to something, and it does its job. Eventually, it calls you back with a result. Neat, right?

But as your app grows, callbacks become painful, especially when you start nesting them, chaining them, or trying to handle complex async flows. What once felt like a simple solution quickly turns into callback hell — messy, hard to read, and nearly impossible to test or reuse cleanly.

There’s a better way: convert those callbacks into Futures.

Let’s look at how (and when) to do it properly.

Why Convert Callbacks?

Source

Flutter and Dart lean heavily into asynchronous programming, and Dart’s Future type is central to that. The async/await syntax makes asynchronous code easy to read and maintain. But many packages, especially older ones or platform channel APIs, still rely on callbacks.

Converting callbacks into Futures lets you:

  • Write cleaner and more readable code using async/await
  • Chain and combine asynchronous operations more naturally
  • Simplify error handling with try/catch
  • Avoid deeply nested functions and closure hell

The Classic Callback Problem

Let’s say you’re working with a plugin that fetches the current user’s location. But instead of returning a Future, it uses a callback-based API:

void getUserLocation(Function(String location) onSuccess, Function(String error) onError) { 
  // Simulate native async logic 
  Future.delayed(Duration(seconds: 1), () => onSuccess("New York, NY")); 
}

Using this method looks like this:

getUserLocation( 
  (location) => print('User is in $location'), 
  (error) => print('Failed to get location: $error'), 
);

It’s fine, but imagine chaining it with another async call — maybe fetching weather info for that location. You’re immediately stuck nesting more and more logic inside more callbacks.

Using Completer to Convert to Future

Enter Completer<T>. Dart’s Completer class allows you to create a Future manually and resolve it later, which is exactly what we need.

Let’s wrap our callback function:

Future<String> getUserLocationAsync() { 
  final completer = Completer<String>(); 
 
  getUserLocation( 
    (location) => completer.complete(location), 
    (error) => completer.completeError(error), 
  ); 
 
  return completer.future; 
}

Now we can use it like this:

void fetchAndPrintLocation() async { 
  try { 
    final location = await getUserLocationAsync(); 
    print('User is in $location'); 
  } catch (e) { 
    print('Error: $e'); 
  } 
}

That’s it. Your async code is now readable, composable, and easier to test.

Real-World Example: Firebase Auth Listener

Here’s another situation: Firebase Auth exposes an auth state stream:

FirebaseAuth.instance.authStateChanges().listen((User? user) { 
  // do something 
});

What if you only want to know the first auth change and then move on?

Future<User?> waitForFirstAuthChange() { 
  final completer = Completer<User?>(); 
  final sub = FirebaseAuth.instance.authStateChanges().listen((user) { 
    if (!completer.isCompleted) { 
      completer.complete(user); 
      sub.cancel(); // Don’t forget to clean up! 
    } 
  }); 
  return completer.future; 
}

Now your code becomes:

final user = await waitForFirstAuthChange(); 
print('User logged in: ${user?.uid}');

Advanced Case: Handling Multiple Callbacks

Some APIs return results in multiple steps: a success, progress updates, maybe a final confirmation. Here’s how to isolate just what you need and turn it into a Future:

void uploadFile({ 
  required String path, 
  required void Function(double progress) onProgress, 
  required void Function() onSuccess, 
  required void Function(String error) onError, 
}) { 
  // Simulated upload... 
}

Let’s say we don’t care about progress, we just want to await the upload.

Future<void> uploadFileAsync(String path) { 
  final completer = Completer<void>(); 
 
  uploadFile( 
    path: path, 
    onProgress: (_) {}, // Ignore 
    onSuccess: () => completer.complete(), 
    onError: (err) => completer.completeError(err), 
  ); 
 
  return completer.future; 
}

You’ve decoupled your logic from unnecessary events, keeping your business logic clean and focused.

Don’t Forget the Gotchas

1. Guard Against Double Completion

If the API might call both onSuccess and onError — or call one more than once — you need to check:

if (!completer.isCompleted) { 
  completer.complete(...); 
}

2. Handle Timeouts

Some callback-based APIs never resolve if something fails silently. Protect yourself:

return completer.future.timeout(Duration(seconds: 5));

This way, your app won’t hang waiting forever.

Bonus: Convert UI Callbacks Too

This technique isn’t just for plugins. You can use it for UI code too.

Let’s say you want to show a confirmation dialog and await the user’s choice.

Future<bool> showConfirmationDialog(BuildContext context) { 
  final completer = Completer<bool>(); 
 
  showDialog( 
    context: context, 
    builder: (_) { 
      return AlertDialog( 
        title: Text('Confirm'), 
        content: Text('Are you sure?'), 
        actions: [ 
          TextButton( 
            child: Text('Cancel'), 
            onPressed: () { 
              completer.complete(false); 
              Navigator.of(context).pop(); 
            }, 
          ), 
          TextButton( 
            child: Text('Yes'), 
            onPressed: () { 
              completer.complete(true); 
              Navigator.of(context).pop(); 
            }, 
          ), 
        ], 
      ); 
    }, 
  ); 
 
  return completer.future; 
}

Now, in your business logic:

final confirmed = await showConfirmationDialog(context); 
if (confirmed) { 
  // Do the thing 
}

In the end

Once you understand how to turn callbacks into Futures, a huge part of the Flutter ecosystem opens up. You’re no longer stuck working around callback-based APIs — you take control and use them in modern async workflows.

In short: if it’s async, make it a Future.

This small refactor improves your code readability, makes logic easier to manage, and brings your style in line with the rest of the Dart ecosystem.

Got an API that’s still stuck in the callback age? Wrap it in a Future — and enjoy the clarity.