Skip to main content

utopia_hooks vs BLoC

A Cubit bundles three things into one framework class: the state it holds, the methods that mutate it, and the emit calls that publish each change. utopia_hooks pulls those apart. The state becomes a plain data class, the methods become functions inside a hook, and emit disappears - a useState write publishes itself. There are no Bloc/Cubit base classes to extend, no event classes, and no close() to remember: a hook is disposed when its widget unmounts.

Why utopia_hooks

BLoC is a disciplined, well-understood pattern. The trade is ceremony, and a model that stops short of covering the whole presentation layer:

  • Less boilerplate. An event class, an on<Event> handler, an emit, and often a Freezed union per state - all of it collapses into a function and a useState. The indirection that serialized events is available when you actually need it (debounce, drop, sequence), not imposed by default.
  • One model for the whole screen. BLoC handles shared logic, but local UI state still sends you back to StatefulWidget, so a screen runs two systems. Hooks hold local and shared state the same way.
  • A gentler entry from other platforms. BLoC is a Flutter-specific pattern to learn from scratch. If you arrive from React or native, the hook model is closer to what you already know.
  • Composability over inheritance. Behavior is shared by calling one hook from another, not by extending a base class or layering mixins.

The result is the Screen / State / View pattern. Read that first - this page maps BLoC concepts onto it.

Concept mapping

BLoC / Cubitutopia_hooksNotes
Cubit<State> classuseXState() hook + State classThe hook holds the logic; the class holds the data
Bloc<Event, State>useXState() hook + action functionsEvents become plain function calls; no event classes
emit(newState)state.value = ...A useState write publishes the change
on<Event>(handler)a function defined in the hookNo registration; just call it
Freezed union state (when)flat State class with nullable fieldsstate.when(loading:, loaded:)if (state.data == null)
Status enum (idle/loading/...)useAutoComputedState / useSubmitStateBuilt-in state machines; don't recreate the enum
BlocProvider (local)the hook called inside useXScreenStateState lives in the hook; no provider widget
BlocProvider / MultiBlocProvider (app)HookProviderContainerWidget + _providersOne flat map at the root
RepositoryProviderexisting DI + a useInjected<T>() bridgeKeep get_it etc.; add a one-line hook
BlocBuilderStatelessWidget View taking stateThe builder: body becomes the View
BlocListeneruseEffect in the hookSide effects live in logic, not the tree
BlocConsumerHookWidget Screen + ViewCoordinator plus pure UI
context.read<C>() / context.watch<C>()useProvided<XState>()One hook, always reactive
context.select<C, T>()useMemoized with keysDerived value via memoization
buildWhen / listenWhenhook / effect keysThe predicate becomes a keys array
Cubit.close()automaticDisposed when the widget unmounts

A Cubit becomes a hook + State class

A Cubit's loading-flow - emit loading, await, emit loaded or error - is exactly what useAutoComputedState does. A nullable field replaces the Freezed union; valueOrNull is null while loading. Mutations that the user triggers (here, a delete) run through useSubmitState, which tracks the in-progress flag you used to emit by hand.

Before (Cubit)

class TaskListCubit extends Cubit<TaskListState> {
TaskListCubit(this._repository) : super(const TaskListState.loading());

final TaskRepository _repository;

Future<void> loadTasks() async {
emit(const TaskListState.loading());
try {
emit(TaskListState.loaded(await _repository.getAll()));
} catch (e) {
emit(TaskListState.error(e.toString()));
}
}

Future<void> deleteTask(String id) async {
await _repository.delete(id);
await loadTasks(); // reload
}
}


class TaskListState with _$TaskListState {
const factory TaskListState.loading() = _Loading;
const factory TaskListState.loaded(List<Task> tasks) = _Loaded;
const factory TaskListState.error(String message) = _Error;
}

After (utopia_hooks)

class TaskListScreenState {
final List<Task>? tasks; // <- null = still loading; no union type needed
final bool isDeleting;
final void Function(String id) onDeletePressed;

const TaskListScreenState({
required this.tasks,
required this.isDeleting,
required this.onDeletePressed,
});
}

TaskListScreenState useTaskListScreenState() {
final repository = useInjected<TaskRepository>();

final tasksState = useAutoComputedState(repository.getAll); // <- runs once, refreshable

final deleteState = useSubmitState();

void onDeletePressed(String id) => deleteState.runSimple<void, Never>(
submit: () async {
await repository.delete(id);
await tasksState.refresh(); // <- reload after delete
},
);

return TaskListScreenState(
tasks: tasksState.valueOrNull, // <- maps the BLoC Status union to one nullable field
isDeleting: deleteState.inProgress, // <- replaces the loading flag emitted by hand
onDeletePressed: onDeletePressed,
);
}

The Screen is a thin coordinator, and BlocBuilder becomes a View that reads the state through its constructor:

class TaskListScreen extends HookWidget {
const TaskListScreen({super.key});


Widget build(BuildContext context) {
final state = useTaskListScreenState();
return TaskListScreenView(state: state);
}
}
class TaskListScreenView extends StatelessWidget {
final TaskListScreenState state;

const TaskListScreenView({required this.state});


Widget build(BuildContext context) {
final tasks = state.tasks;
if (tasks == null) return const Center(child: CircularProgressIndicator());

return ListView(
children: [
for (final task in tasks)
ListTile(
title: Text(task.title),
trailing: IconButton(
icon: const Icon(Icons.delete),
onPressed: () => state.onDeletePressed(task.id),
),
),
],
);
}
}

The three-way state.when(loading:, loaded:, error:) collapses into one null check, because loading and error are no longer separate state shapes - they are an absent value and a thrown error the hook surfaces.

A Bloc with events

A Bloc adds event objects and an on<Event> handler table on top of a Cubit. None of that survives the migration: an event is just a method call, so the handler becomes a function and the event class is deleted.

Before (Bloc)

sealed class CounterEvent {}
class Incremented extends CounterEvent {}
class Decremented extends CounterEvent {}

class CounterBloc extends Bloc<CounterEvent, int> {
CounterBloc() : super(0) {
on<Incremented>((event, emit) => emit(state + 1));
on<Decremented>((event, emit) => emit(state - 1));
}
}

// dispatch
context.read<CounterBloc>().add(Incremented());

After (utopia_hooks)

class CounterScreenState {
final int count;
final void Function() onIncrement;
final void Function() onDecrement;

const CounterScreenState({
required this.count,
required this.onIncrement,
required this.onDecrement,
});
}

CounterScreenState useCounterScreenState() {
final count = useState(0);

return CounterScreenState(
count: count.value,
onIncrement: () => count.value++, // <- the `Incremented` event, inlined
onDecrement: () => count.value--,
);
}

The event/handler indirection existed to serialize and transform a stream of events. When you need that - debouncing, dropping, sequencing - reach for the same tools the async hooks use (useDebounced, a StreamSubscriptionStrategy on useStreamSubscription) rather than reintroducing event objects.

App-level providers

MultiBlocProvider at the root becomes a single HookProviderContainerWidget wrapping a flat _providers map, keyed by state type. Where BLoC creates a Cubit lazily on first read, the container builds every entry eagerly at startup, in map order - so a state registered earlier is available to one registered later.

class AuthState extends HasInitialized {
final String? userId;

const AuthState({required super.isInitialized, required this.userId});

bool get isLoggedIn => userId != null;
}

AuthState useAuthState() {
// A real hook derives this from a service stream; kept inert here.
final userIdState = useState<String?>(null);

return AuthState(isInitialized: true, userId: userIdState.value);
}
// Replaces MultiBlocProvider: one flat map, keyed by state type, built eagerly
// in order. Screen-local states do NOT go here - they live in their screen hook.
const _providers = <Type, Object? Function()>{
AuthState: useAuthState,
};

class App extends StatelessWidget {
const App({super.key});


Widget build(BuildContext context) {
return const HookProviderContainerWidget(
_providers,
alwaysNotifyDependents: false,
child: MaterialApp(home: Scaffold()),
);
}
}
caution

Only truly global state belongs in _providers - everything there runs for the whole life of the app. A Cubit that was provided locally for one screen stays local: call its hook inside that screen's state hook, where it lives only while the screen is mounted. See Global state.

RepositoryProvider has no equivalent because utopia_hooks does not replace your DI. Keep get_it (or whatever you use) and write a one-line bridge so hooks can reach it:

// For get_it:
T useInjected<T extends Object>() => GetIt.I<T>();

See Dependency injection & services.

context.read / context.watch

BLoC distinguishes context.read (one-shot) from context.watch (reactive). useProvided makes no such distinction - it is always reactive, and rebuilds the hook when the value changes. Read it once at the top of the hook and use the fields directly.

// context.read<AuthCubit>() / context.watch<AuthCubit>() both become this.
class ProfileScreenState {
final bool canEdit;

const ProfileScreenState({required this.canEdit});
}

ProfileScreenState useProfileScreenState() {
final auth = useProvided<AuthState>(); // <- always reactive: rebuilds on change

if (!auth.isInitialized) return const ProfileScreenState(canEdit: false);

return ProfileScreenState(canEdit: auth.isLoggedIn);
}

For a context.select-style derived value, wrap the derivation in useMemoized keyed on the source.

BlocListener

A BlocListener is a side effect bolted onto the widget tree. It moves into the hook as a useEffect, and its listenWhen predicate becomes the effect's keys - the effect runs only when a listed value changes. Navigation arrives as a callback the Screen builds, never a BuildContext reached from inside the hook.

// BlocListener side effects move into the hook as an effect keyed on the value.
AuthGateState useAuthGateState({required void Function() navigateToLogin}) {
final auth = useProvided<AuthState>();

useEffect(() {
if (auth.isInitialized && !auth.isLoggedIn) {
navigateToLogin(); // <- listenWhen predicate becomes the effect's keys
}
return null;
}, [auth.isLoggedIn]);

return const AuthGateState();
}

Things that go away

  • Equatable / props - the State class is plain. Hooks compare a useState value with == themselves; there is nothing to override.
  • copyWith - one useState per mutable field, written directly. state.value = state.value.copyWith(...) is BLoC thinking.
  • close() / @override dispose - hooks dispose with their widget.
  • BlocObserver - no central observer. Log inside a hook if needed, route errors through a global handler, and track analytics in useSubmitState's afterSubmit / afterError.
tip

A whole codebase can be migrated incrementally. BLoC and hooks coexist across different screens - keep MultiBlocProvider and HookProviderContainerWidget mounted together - but never mix them within one screen: migrate a screen completely or leave it on BLoC.

See also