Skip to main content

utopia_hooks vs Riverpod

Riverpod and utopia_hooks solve the same problem - reactive state read from anywhere in the tree - and arrive at similar shapes: a unit of state, a way to register it, and a way to consume it reactively. The migration maps each Riverpod concept onto its hook equivalent.

Why utopia_hooks

Riverpod is a capable, mature library. The difference is how much of a separate world its providers are, and how local state fits in:

  • One concept, not a provider layer on the side. A global state is a hook - the same primitive as local state. There is no parallel vocabulary of provider kinds (Provider, NotifierProvider, StreamProvider, FutureProvider) layered over your widgets.
  • No codegen, no build step. Riverpod's recommended path leans on @riverpod generation and the files it produces. A hook is hand-written Dart - no build_runner, no generated artifacts to keep in sync.
  • Local and global written the same way. Riverpod is global-first, so screen-local state is still its own concern. Here, moving a value between local and global scope is a small change, not a switch onto a different mechanism.
  • useProvided instead of ref. One always-reactive read by type, rather than a ref threaded through build with the ref.watch / ref.read distinction and family modifiers to learn.
Riverpodutopia_hooksNotes
Provider / NotifierProvidera global state hook + State classuseXState() registered in _providers
StreamProvider / FutureProvidera hook over useMemoizedStream / useAutoComputedStateThe async loading is a hook, not a provider kind
ProviderScope (app root)HookProviderContainerWidget + _providersOne flat, ordered map
ConsumerWidget / ConsumerHookWidget Screen + ViewCoordinator plus pure UI
ref.watch(p)useProvided<XState>()Reactive read inside a hook
ref.read(p)useProvided (value read in a callback)One always-reactive read
ref.listen(p, cb)useEffect keyed on the valueSide effects in the hook
derived Provider (built from another)useProvided + a getter or useMemoizedNo separate provider declaration
family modifierhook keys / hook parametersParameterize the hook, not the provider
autoDisposeautomaticScreen-local hooks dispose with the widget
provider override (tests)SimpleHookContext(provided:) / useInjectedInject fakes into the hook directly

Providers become hooks

A NotifierProvider (or AsyncNotifierProvider) holding async state becomes a global state hook returning a plain State class. A stream-backed provider reads its source through useMemoizedStream; isInitialized replaces checking AsyncValue.isLoading.

Before (Riverpod)

final authProvider = StreamProvider<User?>((ref) {
return ref.watch(authRepositoryProvider).userChanges;
});

extension AuthX on AsyncValue<User?> {
bool get isLoggedIn => valueOrNull != null;
}

After (utopia_hooks)

class AuthState extends HasInitialized {
final User? user;

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

bool get isLoggedIn => user != null;
}

AuthState useAuthState() {
final repository = useInjected<AuthRepository>();

// Reads the latest value of a stream, like a StreamProvider would.
final snapshot = useMemoizedStream(() => repository.userChanges);

return AuthState(
isInitialized: snapshot.connectionState != ConnectionState.waiting,
user: snapshot.data,
);
}

The AsyncValue<T> wrapper - with its isLoading/hasError/value triad - flattens into named fields on the State class: a nullable user and an isInitialized flag. The consumer reads fields, not an AsyncValue.

ProviderScope becomes the container

ProviderScope at the root becomes a HookProviderContainerWidget over a flat _providers map keyed by state type. Riverpod resolves the dependency graph for you in any order; the container builds entries top to bottom, so a state that reads another must be registered after it.

// ProviderScope wrapping the app becomes HookProviderContainerWidget with a flat,
// ordered map. Each entry is keyed by its state type; later entries may read
// earlier ones via useProvided.
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

Riverpod providers are lazy and (with autoDispose) self-cleaning, so it costs little to declare one globally. Entries in _providers are eager and live for the whole app. Put only truly global state there; screen-scoped state stays in the screen's state hook, where it is created on navigation and disposed on exit. See Global state.

ref.watch becomes useProvided

A ConsumerWidget reads providers through ref. The equivalent read is useProvided, called at the top of a state hook. There is no ref object threaded through build, and no ref.read/ref.watch split - useProvided is always reactive.

Before (Riverpod)

class ProfileScreen extends ConsumerWidget {

Widget build(BuildContext context, WidgetRef ref) {
final auth = ref.watch(authProvider);
return Text(auth.maybeWhen(
data: (user) => 'Hello, ${user?.name ?? 'guest'}',
orElse: () => '...',
));
}
}

After (utopia_hooks)

// ref.watch(authProvider) becomes useProvided<AuthState>() inside a hook.
class ProfileScreenState {
final String greeting;

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

ProfileScreenState useProfileScreenState() {
final auth = useProvided<AuthState>(); // <- rebuilds the hook when AuthState changes

if (!auth.isInitialized) return const ProfileScreenState(greeting: '...');

return ProfileScreenState(greeting: 'Hello, ${auth.user?.name ?? 'guest'}');
}

A family provider - one parameterized by an argument - has no separate construct here: pass the argument to the hook and list it in keys, the same way useAutoComputedState re-runs when a key changes.

Derived providers

A provider whose value is computed from another provider becomes a useProvided read plus a getter on the State class (or useMemoized when the computation is expensive). There is no second provider to declare and wire up.

// A derived Provider (provider built from another) becomes a useProvided read
// plus a plain getter or useMemoized - no separate provider declaration.
class CartState {
final List<String> itemIds;

const CartState({required this.itemIds});

int get itemCount => itemIds.length; // <- derived value: just a getter
}

CartState useCartState() {
final itemsState = useState<List<String>>(const []);

return CartState(itemIds: itemsState.value);
}

Keeping Riverpod during migration

You do not have to convert the whole app at once. The companion package utopia_hooks_riverpod lets hooks read Riverpod providers, so a screen can move to the hook architecture while still depending on providers you have not migrated yet.

  • HookConsumerWidget / HookConsumer - a ConsumerWidget whose build can also call hooks. Use useRefWatch(provider) inside it to watch a provider from hook code, or useHookConsumerRef() to reach the underlying WidgetRef.
  • HookConsumerProviderContainerWidget - bridges a HookProviderContainer to a Riverpod ProviderScope, so utopia_hooks global states and Riverpod providers run side by side at the root.
class ProfileScreen extends HookConsumerWidget {
const ProfileScreen({super.key});


Widget build(BuildContext context, WidgetRef ref) {
final user = useRefWatch(authProvider); // <- read a Riverpod provider from a hook
final state = useProfileScreenState(); // <- and a utopia_hooks state, together
return ProfileScreenView(state: state, user: user);
}
}

Treat this as a bridge, not a destination: migrate the providers a screen reads, then drop back to a plain HookWidget.

See also