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
@riverpodgeneration and the files it produces. A hook is hand-written Dart - nobuild_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.
useProvidedinstead ofref. One always-reactive read by type, rather than arefthreaded through build with theref.watch/ref.readdistinction andfamilymodifiers to learn.
| Riverpod | utopia_hooks | Notes |
|---|---|---|
Provider / NotifierProvider | a global state hook + State class | useXState() registered in _providers |
StreamProvider / FutureProvider | a hook over useMemoizedStream / useAutoComputedState | The async loading is a hook, not a provider kind |
ProviderScope (app root) | HookProviderContainerWidget + _providers | One flat, ordered map |
ConsumerWidget / Consumer | HookWidget Screen + View | Coordinator 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 value | Side effects in the hook |
derived Provider (built from another) | useProvided + a getter or useMemoized | No separate provider declaration |
family modifier | hook keys / hook parameters | Parameterize the hook, not the provider |
autoDispose | automatic | Screen-local hooks dispose with the widget |
| provider override (tests) | SimpleHookContext(provided:) / useInjected | Inject 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()),
);
}
}
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- aConsumerWidgetwhosebuildcan also call hooks. UseuseRefWatch(provider)inside it to watch a provider from hook code, oruseHookConsumerRef()to reach the underlyingWidgetRef.HookConsumerProviderContainerWidget- bridges aHookProviderContainerto a RiverpodProviderScope, 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
- Global state - the
_providersmodel that replacesProviderScope - useProvided - the
ref.watchequivalent - Dependency injection & services -
useInjected, the bridge over your DI - useMemoizedStream - reading a stream's latest value, like a
StreamProvider - From StatefulWidget - the other half of moving a screen to hooks