The never-ending debate on returning data from a screen in Flutter
Passing data across screens in Flutter is a nightmare when it comes to type-checking. Even though you can use a Data Transfer Object to send data to a screen and take advantage of such a form of a contract, especially if you’re using the routing navigation, it’s not that practical when you want to return data from it.
Passing data to a screen
Let’s analyze the following example (a custom snippet from the official Flutter docs) to understand what we need to do to pass data from one screen to another:
class FirstScreen extends StatelessWidget {
const FirstScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('First Screen'),
),
body: Center(
child: ElevatedButton(
child: const Text('Open screen'),
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(builder: (context) => SecondScreen(title: 'title')),
);
},
),
),
);
}
}
class SecondScreen extends StatelessWidget {
final String title;
const SecondScreen({required this.title, super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(title),
),
body: Center(
child: ElevatedButton(
onPressed: () {
// Navigate back to first route when tapped.
},
child: const Text('Go back!'),
),
),
);
}
}
When we tap on the ElevatedButton in the FirstScreen class, we’re navigating to another route named SecondScreen and we’re passing custom data, in this case, the title of the screen. SecondScreen has a strong contract definition because we exactly know what kind of data we want to send. If someone else in the future changes the definition of the SecondScreen constructor, a syntactic error will occur.
Return data from a screen
Let’s keep adding features to our snippet so we can return data back:
class FirstScreen extends StatelessWidget {
const FirstScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('First Screen'),
),
body: Center(
child: ElevatedButton(
child: const Text('Open screen'),
onPressed: () async {
final result = await Navigator.push(
context,
MaterialPageRoute(builder: (context) => SecondScreen(title: 'title')),
);
ScaffoldMessenger.of(context)
..removeCurrentSnackBar()
..showSnackBar(SnackBar(content: Text('$result')));
},
),
),
);
}
}
class SecondScreen extends StatelessWidget {
final String title;
const SecondScreen({required this.title, super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(title),
),
body: Center(
child: ElevatedButton(
onPressed: () {
Navigator.pop(context, 'Let\'s go back!');
},
child: const Text('Go back!'),
),
),
);
}
}
In the aforementioned snipped code I changed the behavior of both screens a little bit: in the SecondScreen, I added a Navigator.pop which navigates back to FirstScreen and returns data, in this case, a String, which will be shown as a Snackbar in the FirstScreen.
If we inspect the definition of Navigator.push, we can see there’s a custom generic type we can take advantage of to declare the type that will be returned if we decide to wait for the future. If we choose to not declare a type, then our result variable will be dynamic.
final result = await Navigator.push(
context,
MaterialPageRoute(builder: (context) => SecondScreen(title: 'title')),
);
ScaffoldMessenger.of(context)
..removeCurrentSnackBar()
..showSnackBar(SnackBar(content: Text('$result')));
In this particular case, we’re able to show the SnackBar because the type of result implicitly inherits toString(), but if we want to explicitly use result as a String, we need to declare the generic type when using Navigator.push:
final result = await Navigator.push<String>(
context,
MaterialPageRoute(builder: (context) => SecondScreen(title: 'title')),
);
ScaffoldMessenger.of(context)
..removeCurrentSnackBar()
..showSnackBar(SnackBar(content: Text(result)));
But what happens if someone in the future changes the type of the returned data? We have no control over the returned data in terms of a contract or type. In the first scenario, the type will always be dynamic, in the second one it will be String, but if someone returns data that is not a String and we declare the generic type as String, we won’t face any kind of syntactic error, the code will compile the same way as before, but as soon as our user will tap on the ElevatedButton in SecondScreen, our app will miserably crash.
Take advantage of contracts
If we don’t want to make mistakes when it comes to returning data from a screen, we need to define contracts across screens to exchange data. Let’s customize the snippet code to see how to introduce it:
typedef SecondScreenCallback = void Function(String result);
class FirstScreen extends StatelessWidget {
const FirstScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('First Screen'),
),
body: Center(
child: ElevatedButton(
child: const Text('Open screen'),
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(builder: (context) => SecondScreen(
title: 'title',
onResult: (result) {
ScaffoldMessenger.of(context)
..removeCurrentSnackBar()
..showSnackBar(SnackBar(content: Text('$result')));
}
)),
);
},
),
),
);
}
}
class SecondScreen extends StatelessWidget {
final String title;
final SecondScreenCallback onResult;
const SecondScreen({required this.title, required this.onResult, super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(title),
),
body: Center(
child: ElevatedButton(
onPressed: () {
Navigator.pop(context);
onResult.call('Let\'s go back!');
},
child: const Text('Go back!'),
),
),
);
}
}
SecondScreenCallback is our contract: we defined a custom callback, a simple function that returns void with a single parameter, the message that will be shown in our SnackBar as soon as SecondScreen is popped.
Instead of waiting for the future when we push SecondScreen, we declare an anonymous function that will be triggered on the screen where we want to send back data. This function contains our Data Transfer Object, in this case, a simple String that will be used later.
Pros and Cons
Of course, the two solutions aren’t 100% perfect, they both share pros and cons, but I prefer the second one as it gives me a bit more control over the data I’m returning.
If you decide to go with the contractless implementation, you don’t need to create a contract and declare custom callbacks that can be easily forgotten when you call Navigator.pop. On the other hand, you have no control over the types of data you’re returning.
With the contract implementation, of course, you have more control over the data but bear in mind you should invoke the callback every time you want to navigate back using Navigator.pop.