With BLoC
BLoC is effective for complex UI state machines such as multi-step checkout, authentication flows, and form orchestration. It is less efficient for repetitive server-state operations such as fetch, cache, retry, and background revalidation.
In a combined architecture, BLoC remains responsible for UI transitions and Qora manages server-state lifecycles.
Division of Labour
| Responsibility | BLoC | Qora |
|---|---|---|
| Fetch user profile | QoraBuilder | |
| Fetch product list / infinite feed | QoraBuilder / InfiniteQueryBuilder | |
| Create, update, delete (mutations) | QoraMutationBuilder | |
| Multi-step checkout flow | CheckoutBloc | |
| Form validation state | BLoC or ValueNotifier | |
| Authentication flow | AuthBloc | |
| Optimistic UI + cache invalidation after write | setQueryData + invalidate |
The two coexist cleanly: QoraScope nests inside the BlocProvider tree (or vice versa).
The Anti-Pattern: Data-Fetching BLoC
A BLoC that only wraps an API call provides no structural benefit over a function call, yet carries the full overhead of events, states, and wiring:
abstract class UserEvent {}
class LoadUser extends UserEvent { final int id; LoadUser(this.id); }
abstract class UserState {}
class UserInitial extends UserState {}
class UserLoading extends UserState {}
class UserLoaded extends UserState { final User user; UserLoaded(this.user); }
class UserError extends UserState { final String message; UserError(this.message); }
class UserBloc extends Bloc<UserEvent, UserState> {
final UserApi _api;
UserBloc(this._api) : super(UserInitial()) {
on<LoadUser>((event, emit) async {
emit(UserLoading());
try {
emit(UserLoaded(await _api.getUser(event.id)));
} catch (e) {
emit(UserError('$e')); // no retry, no deduplication, no SWR
}
});
}
}
BlocBuilder<UserBloc, UserState>(
builder: (context, state) => switch (state) {
UserLoading() => const CircularProgressIndicator(),
UserLoaded(:final user) => UserCard(user),
UserError(:final message) => ErrorWidget(message),
_ => const SizedBox.shrink(),
},
)
After: QoraBuilder for Server Data
QoraBuilder<User>(
queryKey: ['users', userId],
fetcher: () => UserApi.getUser(userId),
options: const QoraOptions(staleTime: Duration(minutes: 5)),
builder: (context, state, fetchStatus) => switch (state) {
Success(:final data) => UserCard(data),
Loading() => const CircularProgressIndicator(),
Failure(:final error) => ErrorWidget('$error'),
Initial() => const SizedBox.shrink(),
},
)
For data-only reads, Qora removes event/state boilerplate and manual transition code. Retry with backoff, request deduplication, and background revalidation on focus or reconnect are built in.
The third builder argument fetchStatus distinguishes active requests (FetchStatus.fetching) from paused offline requests (FetchStatus.paused).
BLoC + Qora on the Same Screen
Complex screens often need both: a UI state machine (BLoC) driving a view that also displays server data (Qora). They compose without friction:
BlocBuilder<CheckoutBloc, CheckoutState>(
builder: (context, checkoutState) {
// CheckoutBloc drives the step machine
if (checkoutState is SelectingAddress) {
return QoraBuilder<List<Address>>(
queryKey: ['users', userId, 'addresses'],
fetcher: () => api.getAddresses(userId),
builder: (context, state, fetchStatus) => switch (state) {
Success(:final data) => AddressSelector(
addresses: data,
onSelect: (address) =>
context.read<CheckoutBloc>().add(AddressSelected(address)),
),
Loading() => const CircularProgressIndicator(),
Failure(:final error)=> ErrorWidget('$error'),
Initial() => const SizedBox.shrink(),
},
);
}
if (checkoutState is ReviewingOrder) {
return OrderReviewWidget(order: checkoutState.order);
}
return const SizedBox.shrink();
},
)
BLoC controls step selection. Qora provides server data inside each step.
Invalidating Qora from a BLoC
When a BLoC performs a write (a form submission, a completed checkout step), it can invalidate Qora's cache so the next read gets fresh data:
class UpdateUserBloc extends Bloc<UpdateUserEvent, UpdateUserState> {
final QoraClient _client;
final UserApi _api;
UpdateUserBloc(this._client, this._api) : super(UpdateUserInitial()) {
on<SubmitUpdate>((event, emit) async {
emit(UpdateUserLoading());
try {
await _api.updateUser(event.id, event.name);
_client.invalidate(['users', event.id]); // Qora refetches on next read
emit(UpdateUserSuccess());
} catch (e) {
emit(UpdateUserError('$e'));
}
});
}
}
Pass QoraClient into BLoCs that need to coordinate with the server-state cache. Do not pass BuildContext, the client is a shared cache object, not a widget concern.
Setup
final qoraClient = QoraClient(
config: const QoraClientConfig(debugMode: kDebugMode),
);
runApp(
MultiBlocProvider(
providers: [
BlocProvider(create: (_) => AuthBloc()),
BlocProvider(create: (ctx) => CheckoutBloc(ctx.read())),
],
child: QoraScope(
client: qoraClient,
lifecycleManager: FlutterLifecycleManager(qoraClient: qoraClient),
connectivityManager: FlutterConnectivityManager(),
child: const MyApp(),
),
),
);
QoraScope can be nested inside or outside MultiBlocProvider. Select the layout that yields the clearest composition in the app root.
Checklist
- Add
qora_fluttertopubspec.yaml - Add
QoraScopeto the widget tree alongsideMultiBlocProvider - Identify BLoCs that only do
emit(Loading) -> fetch -> emit(Loaded/Error)and replace them withQoraBuilder - Keep BLoCs that model multi-event workflows, complex transitions, or non-fetch domain logic
- Remove
BlocProviderfor data-only blocs - Replace
BlocBuilderfor data-only blocs withQoraBuilder - Inject
QoraClientinto BLoCs that perform writes and need to invalidate the cache