The 5 Design Patterns For Every Flutter Engineer
Level Up Your Engineering Skills.
Flutter, with its hot reload and tons of widgets, is a dream for crafting beautiful and engaging UIs. But as our apps grows, so does the complexity of managing data flow and keeping your code organized.
Remember, it’s always advisable to avoid technical debt which for most of the part, happens when your app codebase starts getting to the thousands of lines. This is where design patterns come in — they’re battle-tested solutions to common software development challenges that engineers have faced since system engineering and design began.
Think of design patterns as architectural blueprints for your code. They provide a structured approach to object creation, communication, and relationships, leading to more:
- Reusability: Don’t reinvent the wheel! Design patterns offer pre-defined solutions that can be adapted across different parts of your app. Easy huh 😊.
- Maintainability: Clean and well-structured code is easier to understand, modify, and debug, saving you time and frustration in the long run 🚀.
- Testability: Design patterns often promote separation of concerns, making it easier to isolate and test individual components of your app ✅.
Let’s check out some of the most common design patterns for Flutter engineers, along with practical examples to illustrate their power:
The patterns and principles I share here are helping teams at Heylixir build a scalable product with great user experience for both our customers and developers alike.
Singleton Pattern
Ever needed a central place to store app-wide data like user preferences or a theme manager? The Singleton pattern ensures only one instance of a class exists throughout the application. Imagine a single “SettingsManager” class that controls the app’s theme. Any part of your UI can access this manager to retrieve the current theme settings and style its widgets accordingly. For apps using GraphQL, imagine a single GraphQL client that you can use to execute queries and mutations. An even more common example is a websocket connection, your app will almost always need only one.
This is where this pattern can be utilized.
class SettingsManager {
static final SettingsManager _instance = SettingsManager._internal();
factory SettingsManager() => _instance;
SettingsManager._internal();
ThemeData _themeData = ThemeData.light();
void switchTheme() {
_themeData = _themeData.brightness == Brightness.light ? ThemeData.dark() : ThemeData.light();
// Notify listeners of theme change (implementation omitted for brevity)
}
ThemeData getTheme() => _themeData;
}
Factory Method Pattern
When you need to dynamically generate UI elements based on data, The Factory Method pattern provides an interface for creating objects without specifying the exact class upfront. This allows for greater flexibility — for instance, a factory class could create different types of buttons (primary, secondary) based on the data it receives.
For sports lovers think of a dashboard showing the bets a user has placed but depending on the game result, the user can see a won or lost widget.
abstract class ButtonFactory {
Widget createButton(String text);
}
class PrimaryButtonFactory implements ButtonFactory {
@override
Widget createButton(String text) => ElevatedButton(onPressed: null, child: Text(text));
}
class SecondaryButtonFactory implements ButtonFactory {
@override
Widget createButton(String text) => TextButton(onPressed: null, child: Text(text));
}
// Usage
ButtonFactory buttonFactory = (isPrimary) => isPrimary ? PrimaryButtonFactory() : SecondaryButtonFactory();
Widget myButton = buttonFactory(true).createButton("Click Me"); // Creates a primary button
Provider Pattern
The provider pattern is a lightweight state management solution ideal for efficient data management. It leverages InheritedWidget to propagate data changes down the widget tree.
Think os a global list of user expenses for an expense app. The Provider pattern allows you to create a single ExpenseProvider that holds the counter state and “listens” for changes. Any widget down the tree that needs to display the expenses can access it through the Provider.
class MyExpensesProvider with ChangeNotifier {
DateTime startDate = DateTime.now().subtract(const Duration(days: 30));
DateTime endDate = DateTime.now();
bool isLoading = false;
String? error;
List<Expense> expsenses = [];
MyExpensesProvider() {
getExpenses();
}
getExpenses() async {
}
}
Composition Pattern
Flutter’s main strength lies in its composable widget system. This aligns perfectly with the Composition Pattern, which promotes building complex UIs by assembling smaller, reusable widgets.
This pattern promotes reusability, flexibility and testability.
class RoundedButton extends StatelessWidget {
final String text;
final VoidCallback onPressed;
const RoundedButton({required this.text, required this.onPressed});
@override
Widget build(BuildContext context) {
return TextButton(
onPressed: onPressed,
child: Container(
padding: EdgeInsets.all(16.0),
decoration: BoxDecoration(
color: Colors.blue,
borderRadius: BorderRadius.circular(10.0),
),
child: Text(text, style: TextStyle(color: Colors.white)),
),
);
}
}
// Usage
Widget myScreen = Scaffold(
body: Center(
child: RoundedButton(text: 'Click Me', onPressed: () => print('Button Pressed')),
),
);
Bloc Pattern
The Bloc pattern offers a structured approach for state management, separating the UI (presentation layer) from the business logic (data fetching, calculations, etc.). This leads to cleaner, more maintainable, and highly testable code.
It includes Blocks that handle events, update and emit state, Events that represent app actions, State which is data contained in an app and the BlocProvider to manage Block lifecycle and provide it to child widgets. For those who are familiar with TypeScript and React, this is very similar to React Redux in a number of ways.
@immutable
abstract class CounterEvent {}
// Events
class IncrementEvent extends CounterEvent {}
class DecrementEvent extends CounterEvent {}
// State
class CounterState {
final int counter;
CounterState(this.counter);
}
// Bloc
class CounterBloc extends Bloc<CounterEvent, CounterState> {
CounterBloc() : super(CounterState(0));
@override
Stream<CounterState> mapEventToState(CounterEvent event) async* {
if (event is IncrementEvent) {
yield CounterState(state.counter + 1);
} else if (event is DecrementEvent) {
yield CounterState(state.counter - 1);
}
}
}
// Usage (in UI)
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => CounterBloc(),
child: Scaffold(
appBar: AppBar(title: Text('Counter')),
body: Column(
mainAxisAlignment: MainCenter,
children: [
BlocBuilder<CounterBloc, CounterState>(
builder: (context, state) => Text('Count: ${state.counter}'),
),
Row(
mainAxisAlignment: MainSpaceEvenly,
children: [
ElevatedButton(
onPressed: () => BlocProvider.of<CounterBloc>(context).add(IncrementEvent()),
child: Icon(Icons.add),
),
ElevatedButton(
onPressed: () => BlocProvider.of<CounterBloc>(context).add(DecrementEvent()),
child: Icon(Icons.remove),
),
],
),
],
),
),
);
}
Conclusion
Most mobile engineers who work on mobile apps are not as familiar with design patterns and the goal of this guide was to offer a starting point for them as well as to give senior engineers a refresher on the most common patterns.
I created it for Flutter because the flutter docs don’t exactly offer these patterns in an explicit manner like Swift does for example. Additionally, these are patterns that I currently use at Heylixir.
Happy Building 🚀 and as always, Long Live The Code.