Skip to main content

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, and useContext become useState, useEffect, useMemoized, and useProvided - 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 useContext and 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 with useProvided.
  • 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 plain SimpleHookContext with 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

Reactutopia_hooksNotes
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 hookThe reducer's actions become plain functions
data-fetch in useEffectuseMemoizedFutureDataRe-runs the future when a key changes; no manual loading flag
<Context.Provider value=>HookProviderContainerWidget + _providersRegister 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. useState returns 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 useState value 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 useState you 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