React to Flutter with hooks
If you write React, you already think in hooks. State is a value you declare in the render function; effects run after render and clean up after themselves; you depend on a value by listing it, not by wiring a lifecycle method. utopia_hooks is the Flutter library built on that exact model, so the move is mostly a matter of new names for ideas you already hold.
Why utopia_hooks
- The core hooks map almost one to one.
useState,useEffect,useMemo, anduseContextbecomeuseState,useEffect,useMemoized, anduseProvided- same job, same call site, same dependency-list reasoning. - Far less to relearn than the Flutter-native options. BLoC asks you to learn events, states, and
emit; Riverpod asks you to learn provider kinds,ref, and often codegen. Hooks are the abstraction you came in with. - A global-state story React leaves to you. React gives you
useContextand then sends you to Redux, Zustand, or a context tower for app state. Here, shared state is the same hook construct as local state, registered once and read withuseProvided. - Testable logic without a DOM. A React hook needs a renderer (
@testing-library/react-hooks) to exercise. A utopia_hooks state hook runs under a plainSimpleHookContextwith no widget tree, so your logic is testable as a function.
The rest of this page is the translation table and a side-by-side.
The mapping
| React | utopia_hooks | Notes |
|---|---|---|
useState(initial) | useState(initial) | Read .value, write .value =; the write schedules the rebuild |
useEffect(fn, [deps]) | useEffect(fn, [deps]) | Return a function to clean up, exactly like React |
useEffect(fn, []) (run once) | useEffect(fn) (no keys) | Omitting keys means run once - the empty-array case |
useMemo(fn, [deps]) | useMemoized(fn, [deps]) | Memoize a derived value on its dependencies |
useCallback(fn, [deps]) | useMemoized(() => fn, [deps]) | A callback is just a memoized function |
useRef(initial) | useState(initial, listen: false) | A mutable cell that does not trigger a rebuild |
useContext(MyContext) | useProvided<MyState>() | Read shared state by type; useProvided is built on useContext() |
useReducer(reducer, init) | useState + functions in a hook | The reducer's actions become plain functions |
data-fetch in useEffect | useMemoizedFutureData | Re-runs the future when a key changes; no manual loading flag |
<Context.Provider value=> | HookProviderContainerWidget + _providers | Register global state once at the root |
custom hook (useFoo) | custom hook (useFoo) | Same concept - compose hooks out of hooks |
Two rules are identical to React: call hooks at the top level (never inside conditions or loops), and only from a component or another hook. utopia_hooks adds useIf and useMap for the cases where React makes you split a component to vary hook usage, but the baseline discipline is the one you already follow.
Side by side
A small screen: a counter, a memoized derived value, and an effect that fetches when an input changes. First in React.
function ProfileScreen({ userId }) {
const [count, setCount] = useState(0);
const doubled = useMemo(() => `count: ${count * 2}`, [count]);
const [profile, setProfile] = useState(null);
useEffect(() => {
fetchProfile(userId).then(setProfile); // re-runs when userId changes
}, [userId]);
return (
<div>
<p>{doubled}</p>
<p>{profile ?? 'loading...'}</p>
<button onClick={() => setCount(count + 1)}>+</button>
</div>
);
}
The same logic as a utopia_hooks state hook. The shape is the one you know - the differences are .value on state and a purpose-built async hook in place of the manual fetch-into-useState:
class ProfileScreenState {
final int count;
final String doubled;
final String? profile;
final void Function() onIncrement;
const ProfileScreenState({
required this.count,
required this.doubled,
required this.profile,
required this.onIncrement,
});
}
ProfileScreenState useProfileScreenState(String userId) {
final repository = useInjected<Repository>();
final count = useState(0); // const [count, setCount] = useState(0)
// useMemo(() => `count: ${count}`, [count])
final doubled = useMemoized(() => 'count: ${count.value * 2}', [count.value]);
// useEffect(() => { fetchProfile(userId) }, [userId]) - re-runs when userId changes
final profile = useMemoizedFutureData(() => repository.fetchProfile(userId), keys: [userId]);
return ProfileScreenState(
count: count.value,
doubled: doubled,
profile: profile,
onIncrement: () => count.value++, // setCount(count + 1)
);
}
The View that consumes it is a plain widget reading fields off ProfileScreenState - the JSX return block, separated out. That separation is the Screen / State / View pattern: the state hook above holds the logic and is unit-testable on its own, while the widget stays dumb.
Context, and what comes after it
React's useContext reads a value injected higher in the tree. useProvided is the same read by type, and it is the whole story for shared state - no Redux, no Zustand, no second reactivity system. A global state is a hook registered once at the root, then read anywhere:
class ThemeState {
final Brightness brightness;
const ThemeState({required this.brightness});
bool get isDark => brightness == Brightness.dark;
}
ThemeState useThemeState() => const ThemeState(brightness: Brightness.light);
// Registered once at the app root, then read anywhere - the Provider equivalent.
const _providers = <Type, Object? Function()>{
ThemeState: useThemeState,
};
class App extends StatelessWidget {
const App({super.key});
Widget build(BuildContext context) => const HookProviderContainerWidget(
_providers,
child: MaterialApp(home: Scaffold()),
);
}
String useThemeLabel() {
final theme = useProvided<ThemeState>(); // <- useContext(ThemeContext)
return theme.isDark ? 'dark' : 'light';
}
Unlike <Context.Provider>, which re-renders every consumer on any change, a useProvided read rebuilds only the hooks that read the value that changed. And because a global state is itself a hook, one global state can read another by calling useProvided at the top - the way a component composes contexts, but without nesting providers. See Global state.
Where the models differ
A few habits do not carry straight over:
- No virtual DOM diffing. Flutter rebuilds the widget subtree and reconciles against the element tree. The mental model (state change schedules a rebuild) is the same; the machinery underneath is not.
- State is
.value, not a tuple.useStatereturns one object you read and write through.value, rather than a[value, setValue]pair. There is no separate setter to pass around. - Keys are positional. Effect and memo dependencies are a positional list argument, and omitting it means run once - not run every build. In React an absent dependency array runs the effect after every render; here, the empty-array semantics are the default.
- Immutability still matters, for the same reason. Mutating a
useStatevalue in place skips the rebuild, exactly as mutating React state in place skips the re-render. Assign a new value.
See also
- Basics - the three core hooks and the hook rules, framed for first contact
- useState - the
useStateyou know, with Dart specifics - Global state - the
useContext-plus-store story in one model - Screen / State / View - where the logic from your render function lands
- Unit testing - exercising a state hook without a widget tree