BLoC in Flutter: The Real-World Approach to Avoiding setState Chaos
Many Flutter developers have faced this scenario: you start a small app, sprinkle in some setState()
calls to handle minor interactions, and everything is smooth sailing—until the day you try to expand your project. Suddenly, your once-tiny app has half a dozen screens, each with its own logic. Your setState()
calls begin to trip over each other, making debugging feel like detective work. If you’ve been there, you’ll recognize the urge to find a more scalable architecture. That’s exactly where BLoC (Business Logic Component) can help, especially now that it’s at version 9.x.
Below, we’ll build a currency converter that not only demonstrates BLoC’s unidirectional data flow but also remains simple enough to follow step by step. We’ll keep everything contained in a single file so you can run it directly in DartPad or in your own IDE. And we’ll make sure you can actually switch currencies — no mock demos where the dropdowns do nothing!
If you’ve seen my earlier pieces — like State Management with Riverpod: Building a Food Energy Tracker in Flutter or Flutter State Management Made Simple: From Local to Global with Provider — you know that I love showing why certain architectural patterns make life easier, rather than just telling you what to copy and paste. By the end of this article, you’ll understand how BLoC’s events and states prevent stateful spaghetti code and give you confidence as your project grows.
The Pain Point: Why Not Just Use setState()
Everywhere?
setState()
is fine for a trivial app. But as soon as you add multiple features—like a currency converter with validation, dropdown menus, error handling, and loading spinners—your code quickly becomes a patchwork of state updates. It’s easy to accidentally rebuild everything, trigger infinite loops of setState, or lose track of which widget triggered which rebuild.
BLoC solves this by separating your UI from your logic:
- The UI sends events (e.g., “User changed dropdown to USD”).
- The BLoC processes these events and decides what to do (e.g., “Update base currency and validate the input”).
- The BLoC then emits a new state, which the UI listens to and renders accordingly.
This unidirectional data flow might sound fancy, but it essentially means you don’t have to guess where your data is going — it always goes in one direction, from UI into the BLoC and then back out in the form of a state.
Generics and Abstract Classes: Why BLoC Looks the Way It Does
class ConverterBloc extends Bloc<ConverterEvent, ConverterState> {
// ...
}
If you glance at the BLoC signature — it’s natural to wonder why it’s defined with <ConverterEvent, ConverterState>
. The short answer is: type safetyand explicit contracts.
When you use Bloc<Event, State>
, you’re saying: “This particular BLoC will only accept Event
objects as input, and it will only produce State
objects as output.” That means a ConverterBloc
can’t accidentally receive a LoginEvent
from another part of your app, and it won’t emit a ProfileLoaded
state meant for a user profile screen. Generics force your code to stay within the boundaries you’ve defined, dramatically reducing the risk of mixing up unrelated logic.
You might also notice we have abstract classes named ConverterEvent
and ConverterState
. Each real event (like BaseCurrencyChanged
) and each real state (like ConverterSuccess
) extends those abstracts. By doing so, you give your code a clear shape:
- One abstract class for events means all events share a common parent, making it easy to organize them and handle them in your BLoC.
- One abstract class for states means you’re forced to define distinct states for every situation your UI can be in (editing, loading, success, error, etc.).
This structure has two big benefits:
- Readability: When you look at your codebase, you can see at a glance the set of events your BLoC can handle. You also see exactly which states it can emit. No guesswork, no rummaging around to see what magic strings or property toggles might exist.
- Scalability: If you want to add a new feature — say, “OfflineState” or “PartialConversionState” — you create a new class that extends
ConverterState
, define how your BLoC emits it, and then handle it in your UI withBlocBuilder
. You don’t need to modify some big monolithic chunk of logic or introduce complicatedswitch
statements on ephemeral variables.
When you read or write code in a BLoC that uses generics and abstract classes, you can feel the architecture guiding you. It nudges you toward a consistent approach: each new feature or edge case becomes a new event or state class, rather than a random boolean flag or an unstructured map of data. That consistency pays off when your team grows, or when you revisit your code a few months later and still recognize exactly how the data flows.
A Hands-On Example: Currency Converter in DartPad
Below is a complete example you can run in DartPad’s Flutter mode or in your own IDE. If you’re in modern DartPad, click “Add Dependency” in the upper-left panel and type flutter_bloc: ^9.0.0
. Then replace the main.dart
content with the code below. If your DartPad environment doesn’t support external packages, you can always try this in a local Flutter project.
We’ll include six currencies: USD, EUR, GBP, JPY, CNY, and AUD. The app will:
- Let you pick two different currencies.
- Handle invalid input.
- Show a loading indicator (simulating an API call).
- Display the result in the format
5.00 USD = 4.15 GBP
plus a helpful reference of1 USD = 0.83 GBP
. - Show error messages if you pick the same currency or type something non-numeric.
Go ahead and copy-paste:
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
// ----------------------------------------------------
// 1) Define the available currencies
// ----------------------------------------------------
enum Currency { usd, eur, gbp, jpy, cny, aud }
// Pretend exchange rates, relative to 1 USD
final Map<Currency, double> baseUsdRates = {
Currency.usd: 1.0,
Currency.eur: 0.92,
Currency.gbp: 0.83,
Currency.jpy: 135.0,
Currency.cny: 6.9,
Currency.aud: 1.45,
};
// ----------------------------------------------------
// 2) Define Events
// ----------------------------------------------------
abstract class ConverterEvent {}
class BaseCurrencyChanged extends ConverterEvent {
final Currency newBase;
BaseCurrencyChanged(this.newBase);
}
class TargetCurrencyChanged extends ConverterEvent {
final Currency newTarget;
TargetCurrencyChanged(this.newTarget);
}
class AmountEntered extends ConverterEvent {
final String newAmount;
AmountEntered(this.newAmount);
}
class ConvertPressed extends ConverterEvent {}
// ----------------------------------------------------
// 3) Define State
// ----------------------------------------------------
/// In this design, we store both the "current" UI selections
/// and the "last successful conversion" separately.
class ConverterState {
// Current UI selections
final Currency baseCurrency;
final Currency targetCurrency;
final String typedAmount;
// Loading and errors
final bool isLoading;
final String? errorMessage;
// Last successful conversion data
final Currency? lastBaseCurrency;
final Currency? lastTargetCurrency;
final double? lastOriginal;
final double? lastConverted;
const ConverterState({
required this.baseCurrency,
required this.targetCurrency,
required this.typedAmount,
required this.isLoading,
required this.errorMessage,
required this.lastBaseCurrency,
required this.lastTargetCurrency,
required this.lastOriginal,
required this.lastConverted,
});
ConverterState copyWith({
Currency? baseCurrency,
Currency? targetCurrency,
String? typedAmount,
bool? isLoading,
String? errorMessage, // passing null means 'no error'; passing a non-null means 'new error'
Currency? lastBaseCurrency,
Currency? lastTargetCurrency,
double? lastOriginal,
double? lastConverted,
}) {
return ConverterState(
baseCurrency: baseCurrency ?? this.baseCurrency,
targetCurrency: targetCurrency ?? this.targetCurrency,
typedAmount: typedAmount ?? this.typedAmount,
isLoading: isLoading ?? this.isLoading,
// For errorMessage, let's allow an explicit 'null' to clear it
errorMessage: errorMessage,
lastBaseCurrency: lastBaseCurrency ?? this.lastBaseCurrency,
lastTargetCurrency: lastTargetCurrency ?? this.lastTargetCurrency,
lastOriginal: lastOriginal ?? this.lastOriginal,
lastConverted: lastConverted ?? this.lastConverted,
);
}
}
// ----------------------------------------------------
// 4) BLoC Implementation
// ----------------------------------------------------
class ConverterBloc extends Bloc<ConverterEvent, ConverterState> {
ConverterBloc()
: super(const ConverterState(
baseCurrency: Currency.usd,
targetCurrency: Currency.eur,
typedAmount: '',
isLoading: false,
errorMessage: null,
lastBaseCurrency: null,
lastTargetCurrency: null,
lastOriginal: null,
lastConverted: null,
)) {
on<BaseCurrencyChanged>(_onBaseCurrencyChanged);
on<TargetCurrencyChanged>(_onTargetCurrencyChanged);
on<AmountEntered>(_onAmountEntered);
on<ConvertPressed>(_onConvertPressed);
}
void _onBaseCurrencyChanged(
BaseCurrencyChanged event,
Emitter<ConverterState> emit,
) {
emit(
state.copyWith(
baseCurrency: event.newBase,
errorMessage: null,
),
);
}
void _onTargetCurrencyChanged(
TargetCurrencyChanged event,
Emitter<ConverterState> emit,
) {
emit(
state.copyWith(
targetCurrency: event.newTarget,
errorMessage: null,
),
);
}
void _onAmountEntered(
AmountEntered event,
Emitter<ConverterState> emit,
) {
emit(
state.copyWith(
typedAmount: event.newAmount,
errorMessage: null,
),
);
}
Future<void> _onConvertPressed(
ConvertPressed event,
Emitter<ConverterState> emit,
) async {
// If base == target, show an error
if (state.baseCurrency == state.targetCurrency) {
emit(
state.copyWith(
errorMessage: "Please pick two different currencies.",
),
);
return;
}
// Try parsing the typed amount
final originalAmount = double.tryParse(state.typedAmount);
if (originalAmount == null) {
emit(
state.copyWith(
errorMessage: "Enter a valid numeric amount.",
),
);
return;
}
// Show loading indicator
emit(
state.copyWith(isLoading: true, errorMessage: null),
);
// Simulate an API call
await Future.delayed(const Duration(seconds: 1));
// Calculate the conversion
final baseRate = baseUsdRates[state.baseCurrency] ?? 1.0;
final targetRate = baseUsdRates[state.targetCurrency] ?? 1.0;
final toUsd = originalAmount / baseRate;
final converted = toUsd * targetRate;
// Update the "last successful" portion of state
emit(
state.copyWith(
isLoading: false,
lastBaseCurrency: state.baseCurrency,
lastTargetCurrency: state.targetCurrency,
lastOriginal: originalAmount,
lastConverted: converted,
),
);
}
}
// ----------------------------------------------------
// 5) Build the Flutter UI
// ----------------------------------------------------
void main() => runApp(const MyConverterApp());
class MyConverterApp extends StatelessWidget {
const MyConverterApp({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'BLoC 9.x Currency Converter',
theme: ThemeData(primarySwatch: Colors.blue),
home: BlocProvider(
create: (_) => ConverterBloc(),
child: const ConverterScreen(),
),
);
}
}
class ConverterScreen extends StatelessWidget {
const ConverterScreen({Key? key}) : super(key: key);
String currencyString(Currency c) {
switch (c) {
case Currency.usd: return 'USD';
case Currency.eur: return 'EUR';
case Currency.gbp: return 'GBP';
case Currency.jpy: return 'JPY';
case Currency.cny: return 'CNY';
case Currency.aud: return 'AUD';
}
}
@override
Widget build(BuildContext context) {
final bloc = context.read<ConverterBloc>();
return BlocConsumer<ConverterBloc, ConverterState>(
listenWhen: (previous, current) =>
previous.errorMessage != current.errorMessage &&
current.errorMessage != null,
listener: (context, state) {
// Show a SnackBar if there's an error
if (state.errorMessage != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(state.errorMessage!)),
);
}
},
builder: (context, state) {
// If loading, just show a spinner
if (state.isLoading) {
return Scaffold(
appBar: AppBar(title: const Text('Currency Converter (BLoC)')),
body: const Center(child: CircularProgressIndicator()),
);
}
// Main UI
return Scaffold(
appBar: AppBar(title: const Text('Currency Converter (BLoC)')),
body: Padding(
padding: const EdgeInsets.all(16),
child: Center(
child: SingleChildScrollView(
child: Column(
children: [
// Base currency
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('From: '),
DropdownButton<Currency>(
value: state.baseCurrency,
items: Currency.values.map((c) {
return DropdownMenuItem<Currency>(
value: c,
child: Text(currencyString(c)),
);
}).toList(),
onChanged: (value) {
if (value != null) {
bloc.add(BaseCurrencyChanged(value));
}
},
),
],
),
const SizedBox(height: 16),
// Target currency
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('To: '),
DropdownButton<Currency>(
value: state.targetCurrency,
items: Currency.values.map((c) {
return DropdownMenuItem<Currency>(
value: c,
child: Text(currencyString(c)),
);
}).toList(),
onChanged: (value) {
if (value != null) {
bloc.add(TargetCurrencyChanged(value));
}
},
),
],
),
const SizedBox(height: 16),
// Amount text field
SizedBox(
width: 200,
child: TextField(
decoration: const InputDecoration(
labelText: 'Amount',
border: OutlineInputBorder(),
),
keyboardType:
const TextInputType.numberWithOptions(decimal: true),
onChanged: (value) {
bloc.add(AmountEntered(value));
},
controller: TextEditingController(text: state.typedAmount)
..selection = TextSelection.fromPosition(
TextPosition(offset: state.typedAmount.length),
),
),
),
const SizedBox(height: 16),
ElevatedButton(
child: const Text('Convert'),
onPressed: () => bloc.add(ConvertPressed()),
),
const SizedBox(height: 24),
// Show old result if it exists
if (state.lastConverted != null)
buildResultDisplay(state),
],
),
),
),
),
);
},
);
}
Widget buildResultDisplay(ConverterState state) {
// We specifically use the "last*" fields,
// so they don't change when the user selects new currencies.
final baseStr = currencyString(state.lastBaseCurrency!);
final targetStr = currencyString(state.lastTargetCurrency!);
final typed = state.lastOriginal!.toStringAsFixed(2);
final converted = state.lastConverted!.toStringAsFixed(2);
final ratePerOne =
(state.lastConverted! / state.lastOriginal!).toStringAsFixed(2);
return Column(
children: [
Text(
'$typed $baseStr = $converted $targetStr',
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Text(
'1 $baseStr = $ratePerOne $targetStr',
style: const TextStyle(fontSize: 16),
),
],
);
}
}
Testing It Out
- Choose different currencies (like USD → GBP) and type an amount (e.g., “5”).
- Click Convert. After a quick “loading” spinner, you should see something like “5.00 USD = 4.15 GBP” and the reference “1 USD = 0.83 GBP.”
- If you pick the same currency for both, or type something invalid, you’ll see a snack bar error message.
Everything you see — dropdown changes, typed amounts, the result displayed — is orchestrated by BLoC events and states. The UI simply reacts to what the BLoC emits.
A Gentle Peek Inside the BLoC
You can think of a BLoC as the traffic controller for your logic. All user actions — like choosing a new currency or typing into a text field — arrive as events. The BLoC processes these events in carefully separated handler methods, and then emits states that your UI can react to. Here’s a closer look at what happens, step by step:
Event Creation
Every time a user interacts with the UI, we trigger an event. For instance, when they pick “USD” from a dropdown, we dispatch BaseCurrencyChanged(Currency.usd)
. If they type “5” into an amount field, we dispatch AmountEntered("5")
. By packaging each user action into an event, we keep the BLoC’s logic independent from the widget code. The UI doesn’t have to figure out how to convert 5 USD to GBP; it just sends the request.
Event Handling
Inside ConverterBloc
, you’ll see lines like:
on<BaseCurrencyChanged>(_onBaseCurrencyChanged);
on<TargetCurrencyChanged>(_onTargetCurrencyChanged);
// ...
Each on<SomeEvent>()
line associates an event class with a method that knows what to do in response. For example, _onBaseCurrencyChanged
might update the baseCurrency
in your “editing” state. This is where you implement your business logic: validations, transformations, or calculations.
Emitting States
Whenever an event handler finishes, it calls emit(...)
with a new state object. Notice how states are immutable. Rather than tweaking an existing state in place, we create a fresh state object every time. This is crucial:
- Predictability: If someone reports a bug, you can inspect each discrete state your BLoC emitted. You won’t be stuck wondering if some variable mutated halfway through a frame.
- UI Consistency: The UI knows exactly which state it’s in at all times and can rebuild accordingly.
Listening and Building
In the UI, widgets like BlocBuilder<ConverterBloc, ConverterState>
rebuild only when a new state is emitted. That means you can keep your UI code relatively simple: “If the state is ConverterEditing
, show the dropdowns and text fields; if it’s ConverterLoading
, show a spinner; if it’s ConverterSuccess
, show the result.”
Error Handling
If the user picks the same currency for both “from” and “to,” or enters non-numeric text, the BLoC emits a ConverterError
state. In our code, a BlocListener
sees that state and displays a SnackBar. This ensures our UI remains fully reactive: the BLoC drives the logic, and the UI just responds.
Because of this flow — event → logic → new state → UI rebuild — you never worry about partial updates or weird timing bugs. You won’t accidentally do half the conversion before the user changes their mind. The moment you dispatch an event, you’re handing the process off to the BLoC. If you need to do something time-consuming (like an API call), the BLoC can emit a loading state, wait for the network, and then emit the success or error state. All in a controlled, traceable manner.
In short, the BLoC is the brains of the operation, but it’s not a giant tangle of logic. It’s a careful pipeline of discrete events and well-defined states, which guides your app through each transition. By peeking inside this pipeline, you see exactly how your user’s actions turn into results, with no guesswork or hidden side effects. This clarity of flow is why many teams choose BLoC for production apps — when the stakes are high, a consistent, easy-to-debug architecture is priceless.
Conclusion: Your Turn to Expand
We now have a fairly complete — and actually functional — currency converter in about 150 lines of code. It’s easy to see how you can expand this:
- Pull real exchange rates from an online API.
- Store a conversion history in a local database.
- Hook in user authentication if needed.
BLoC keeps your logic separate from your UI, making everything more testable and less prone to random side effects. If you’re curious about other approaches, definitely check out my articles on Riverpod or Provider. Each tool has its own strengths, but all share the same principle: keep your logic decoupled from the widgets that render it.
And if you want to experiment with other wild sides of Flutter, you might enjoy my previous post, Building a 2D Game in Flutter with Flame: Air Hockey from Scratch. Flutter isn’t just about forms and lists — it’s a versatile framework if you manage state correctly.
Hopefully, this converter example clarifies the why behind BLoC. Give it a try, tweak it, break it, and extend it. Once you see how well it scales for even the simplest projects, you’ll appreciate BLoC’s unidirectional design the next time you’re facing a “spaghetti state” crisis in your Flutter app. Happy coding! 🚀