⚙️ Mastering Enums in Flutter: Clean, Scalable & Pro-Level Usage
Most Flutter developers reach for enums as simple labels and stop there, but modern Dart enums can carry fields, constructors, and methods — which means they can absorb logic that would otherwise sprawl across if/switch blocks scattered through a codebase. A ButtonType enum with an icon, a color, and a build() method turns "look up the right icon and color for this variant" into a single call site. An OrderStatus enum with a priority field and an isFinal getter turns state-ordering logic into something the type itself understands, instead of a helper function that has to be kept in sync with it.
The pattern extends naturally to serialization (.name and .values.byName() remove the need for hand-written string mapping to and from an API), UI state (an extension on a Riverpod/Bloc state enum can own its own display label or loading widget), and theming (an AppTheme enum can expose its own ThemeData getter). The common thread is keeping behavior next to the states it describes, rather than duplicating a switch over the same enum in every file that needs to react to it.
How Do You Add Fields and Methods to a Dart Enum?
The upgrade from "just a label" starts with giving an enum its own constructor and fields:
enum ButtonType {
primary(icon: Icons.check, color: Colors.blue),
destructive(icon: Icons.delete, color: Colors.red),
ghost(icon: null, color: Colors.grey);
const ButtonType({required this.icon, required this.color});
final IconData? icon;
final Color color;
Widget build(String label, VoidCallback onPressed) {
return ElevatedButton.icon(
onPressed: onPressed,
icon: icon != null ? Icon(icon) : const SizedBox.shrink(),
label: Text(label),
style: ElevatedButton.styleFrom(backgroundColor: color),
);
}
}
// call site — no separate lookup table, no switch statement
ButtonType.destructive.build('Delete Account', onDelete);
Every call site that used to need a switch on ButtonType to decide the icon and color now just asks the enum value directly — adding a new variant means adding one case to the enum declaration, not hunting down every switch statement across the codebase that needs a new branch.
Computed Properties Instead of Helper Functions
A getter on the enum keeps derived logic next to the states it describes, rather than in a separate function that has to be kept in sync manually:
enum OrderStatus {
pending(priority: 1),
processing(priority: 2),
shipped(priority: 3),
delivered(priority: 4),
cancelled(priority: 0);
const OrderStatus({required this.priority});
final int priority;
bool get isFinal => this == delivered || this == cancelled;
bool get isActive => !isFinal && this != pending;
}
Sorting a list of orders by .priority, or filtering to just the ones still in progress with .isActive, reads directly at the call site — there's no separate isOrderFinal(OrderStatus status) free function to remember exists, find, and keep updated as new statuses are added.
Serialization Without Hand-Written Maps
Dart's built-in .name and .values.byName() remove the most tedious part of enum serialization — the hand-written Map<String, MyEnum> lookup table that has to be kept in sync with the enum's own definition:
enum PaymentMethod { creditCard, applePay, googlePay, bankTransfer }
// serialize
final json = {'method': PaymentMethod.applePay.name}; // "applePay"
// deserialize
final method = PaymentMethod.values.byName(json['method'] as String);
For APIs using a different casing convention (snake_case from a backend versus Dart's camelCase), a small fromJson/toJson pair on the enum itself keeps that translation contained in one place instead of scattered wherever the enum gets serialized.
Enum-Driven UI State
An extension on a state enum lets each state own its own display logic, rather than a widget's build() method containing a switch that mixes state definition with UI decisions:
enum LoadingState { idle, loading, success, error }
extension LoadingStateUI on LoadingState {
Widget get indicator => switch (this) {
LoadingState.idle => const SizedBox.shrink(),
LoadingState.loading => const CircularProgressIndicator(),
LoadingState.success => const Icon(Icons.check_circle, color: Colors.green),
LoadingState.error => const Icon(Icons.error, color: Colors.red),
};
}
// call site
loadingState.indicator
This keeps the mapping from state to widget in one well-defined place (the extension), while the actual switch that has to exist somewhere is exhaustive and compiler-checked — adding a new LoadingState value without updating the extension is a compile error, not a silent UI gap discovered later.
Theming as an Enum
An AppTheme enum exposing its own ThemeData getter turns theme selection into the same pattern as everything above — the enum owns the data that describes it, rather than a separate if (theme == AppTheme.dark) { ... } else { ... } scattered wherever theming decisions get made:
enum AppTheme {
light,
dark,
highContrast;
ThemeData get data => switch (this) {
AppTheme.light => ThemeData.light(),
AppTheme.dark => ThemeData.dark(),
AppTheme.highContrast => ThemeData.dark().copyWith(
colorScheme: const ColorScheme.dark(primary: Colors.yellow),
),
};
}
MaterialApp(theme: currentTheme.data, ...)
The Common Thread
Every one of these patterns — buttons, order statuses, serialization, loading states, theming — follows the same principle: keep behavior next to the states it describes, instead of duplicating a switch over the same enum in every file that needs to react to it. The payoff compounds as an app grows — adding a new enum variant becomes a single, localized change instead of a search across the codebase for every place that needs updating to handle it.
No spam, no schedule — just an email when a new post goes up.