One of the most known good practice when building widgets in a Flutter project is the usage of widgets instead of methods. It is highly advised to split large widgets into smaller widgets. While this approach has many advantages, it can quickly turn your code base into a gas factory if it isn't implemented correctly. On a large-scale application, it can sometimes happen that you end up with huge screens containing dozens of smaller Widgets. Add to that logic and state management, and it's easy to get confused and end up with code that even the you of the future will have trouble understanding.
Your data flow when building a widget tree should be unidirectional. What does this mean? It means that data flows in a single direction, from the parent widget down to the child widgets, ensuring a predictable and easy-to-debug structure.
Unidirectional data flow in a widget tree means that data flows in a single direction, typically from the parent widget down to its child widgets, without any feedback loop.
Here's a simplified explanation:
Parent Widget: At the top of the widget tree, there's usually a parent widget that holds the main data or state of the application (it also has a direct way to interact with the global app state). This parent widget serves as the source of truth for the data.
Data Flow: The data flows down the widget tree hierarchy, from the parent widget to its child widgets. Each widget in the tree receives the data it needs from its parent widget and passes it down to its own child widgets.
Child Widgets: Each child widget in the widget tree receives data from its parent widget and uses that data to build its own UI representation. These child widgets don't modify the data themselves; they only display it or react to changes.
Propagation of Changes: If there are changes to the data in the parent widget, those changes propagate down the widget tree, triggering rebuilds of the affected child widgets. This ensures that the UI stays in sync with the latest data.
No Feedback Loop: There's no direct feedback loop where changes in the child widgets affect the parent widget's data. Any changes initiated by the child widgets are typically communicated back to the parent widget through callbacks or events, maintaining the one-way flow of data. Another way of looking at it is that a child widget never alters the state of the application on its own. Instead, it "delegates" this task to its parent via a callback supplied to it when it is created.
In essence, unidirectional data flow in a widget tree ensures that data is passed down the hierarchy from parent to child widgets, with changes flowing in a single direction. This approach helps maintain a clear and predictable flow of data, making it easier to manage and debug Flutter applications.
Let's consider a simple Flutter app where we have a parent widget called CounterApp
that holds a counter value. This counter value is then displayed by a child widget called CounterDisplay
, and there's another child widget called CounterButton
that allows the user to increment the counter value.
Applying the concepts explained above would give something looking like this:
import 'package:flutter/material.dart';
void main() {
runApp(CounterApp());
}
class CounterApp extends StatefulWidget {
@override
_CounterAppState createState() => _CounterAppState();
}
class _CounterAppState extends State<CounterApp> {
int _counter = 0;
void _incrementCounter() {
setState(() {
_counter++;
});
}
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
appBar: AppBar(
title: Text('Counter App'),
),
body: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CounterDisplay(counter: _counter), // Child widget to display counter value
CounterButton(onPressed: _incrementCounter), // Child widget for increment button
],
),
),
);
}
}
class CounterDisplay extends StatelessWidget {
final int counter;
CounterDisplay({required this.counter});
@override
Widget build(BuildContext context) {
return Text(
'Counter: $counter',
style: TextStyle(fontSize: 24),
);
}
}
class CounterButton extends StatelessWidget {
final VoidCallback onPressed;
CounterButton({required this.onPressed});
@override
Widget build(BuildContext context) {
return ElevatedButton(
onPressed: onPressed,
child: Text('Increment'),
);
}
}
Takeaways
A child widget should never read the state on its own. As far as possible, the screen (i.e. the widget uppermost in the tree) should provide the information it needs.
A child widget must never trigger a change of state. Even if there are no major consequences (to be qualified), this poses serious readability problems. In the event of an anomaly, it will be quite difficult to untangle all the nodes in order to determine the source of the problem and apply an appropriate patch. What's more, it breaks the principle of single responsibility and makes unit testing a bit more tedious.