Conversation
Adds a new hook inspired by React's useEffectEvent for extracting non-reactive logic into functions that read the latest state.
📝 WalkthroughWalkthroughA new useEffectEvent hook and EffectEvent type were added in primitives, accompanied by a comprehensive test suite and README updates (English and translations). The primitives implementation appears duplicated in the diff. Changes
Sequence Diagram(s)(Skipped) Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related issues
Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 3✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Fix all issues with AI agents
In `@packages/flutter_hooks/lib/src/primitives.dart`:
- Around line 239-303: Update the documentation for
useEffectEvent/_EffectEventHook/_EffectEventHookState to clarify that while the
hook updates hook.event on rebuilds, the returned function only "invokes the
latest callback" when it is called synchronously inside the useEffect callback;
if the returned function is captured and invoked later (e.g., from timers,
streams, subscriptions), it will execute the callback and closures from the
build where it was captured, not necessarily the latest values—make the caveat
explicitly state "Effect events must be invoked synchronously within useEffect
and must not be stored or invoked asynchronously (timers, streams,
subscriptions) or passed to children, otherwise they will capture stale
closures."
🧹 Nitpick comments (1)
packages/flutter_hooks/test/use_effect_event_test.dart (1)
9-36: Add coverage for calling a previously captured event after a rebuild (if that’s the intended contract)This test rebinds
eventafter each rebuild, so it won’t catch stale-reference behavior. If the contract is “latest callback even when an older reference is invoked,” add a check that an old reference also reflects the updated state.✅ Suggested test extension
expectedNumber = 42; await tester.pumpWidget(buildWidget()); + final firstEvent = event; event(); expect(capturedNumber, 42); expectedNumber = 21; await tester.pumpWidget(buildWidget()); event(); expect(capturedNumber, 21); + // If latest-callback semantics are intended: + firstEvent(); + expect(capturedNumber, 21);
This commit rewrites the useEffectEvent hook implementation to use ref indirection, matching React's approach. The key changes: Implementation: - Add EffectEvent<T> wrapper class that reads callback from shared ref - useEffectEvent now returns EffectEvent<T> instead of T directly - Callback is accessed via event.call() which reads from ref at call time - Ref is updated in didUpdateHook to always hold the latest callback This enables the core use case: registering a callback with an external system (WebSocket, Timer, etc.) that always sees the latest props/state values, without adding those values to the effect's dependencies. Tests: - Add demonstration tests showing WHY useEffectEvent is needed - Add tests adapted from React's useEffectEvent test suite
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Fix all issues with AI agents
In `@packages/flutter_hooks/test/use_effect_event_test.dart`:
- Around line 313-385: The test duplicates an earlier one and should either be
removed or rewritten to actually exercise a custom hook; implement the custom
hook approach: add a hook like EffectEvent<void Function([int?])>
useLoggedIncrement(ValueNotifier<int> count, int incrementBy) that wraps
useEffectEvent and updates count (and optionally logs), then update the test's
HookBuilder to call useLoggedIncrement instead of creating the event inline
(keep the rest of the assertions the same to verify stability when tapping and
when incrementBy changes); ensure you reference useLoggedIncrement and
useEffectEvent in the revised test and remove the old duplicate behavior if you
choose deletion instead.
🧹 Nitpick comments (1)
packages/flutter_hooks/lib/src/primitives.dart (1)
316-324: Missingsupercalls in lifecycle methods.Other hooks in this file call
super.initHook()andsuper.didUpdateHook(oldHook)for consistency and future-proofing. This hook should follow the same pattern.Suggested fix
`@override` void initHook() { + super.initHook(); _ref = ObjectRef<T>(hook.callback); } `@override` void didUpdateHook(_EffectEventHook<T> oldHook) { + super.didUpdateHook(oldHook); _ref.value = hook.callback; }
| testWidgets('is stable in a custom hook', (tester) async { | ||
| final logs = <String>[]; | ||
|
|
||
| Widget counter(int incrementBy) { | ||
| return HookBuilder(builder: (context) { | ||
| final count = useState(0); | ||
|
|
||
| final increment = useEffectEvent(([int? amount]) { | ||
| count.value += amount ?? incrementBy; | ||
| }); | ||
|
|
||
| useEffect(() { | ||
| logs.add('Effect: by ${incrementBy * 2}'); | ||
| increment.call(incrementBy * 2); | ||
| return null; | ||
| }, [incrementBy]); | ||
|
|
||
| return Column( | ||
| textDirection: TextDirection.ltr, | ||
| children: [ | ||
| GestureDetector( | ||
| onTap: () => increment.call(), | ||
| child: const Text( | ||
| 'Increment', | ||
| textDirection: TextDirection.ltr, | ||
| ), | ||
| ), | ||
| Text( | ||
| 'Count: ${count.value}', | ||
| textDirection: TextDirection.ltr, | ||
| ), | ||
| ], | ||
| ); | ||
| }); | ||
| } | ||
|
|
||
| await tester.pumpWidget(counter(1)); | ||
| expect(find.text('Increment'), findsOneWidget); | ||
| expect(find.text('Count: 2'), findsOneWidget); | ||
| expect(logs, ['Effect: by 2']); | ||
| logs.clear(); | ||
|
|
||
| // Tap button - effect should NOT re-run | ||
| await tester.tap(find.byType(GestureDetector)); | ||
| await tester.pump(); | ||
| expect(find.text('Increment'), findsOneWidget); | ||
| expect(find.text('Count: 3'), findsOneWidget); | ||
| expect(logs, <String>[]); // No effect re-run | ||
| logs.clear(); | ||
|
|
||
| // Tap button again | ||
| await tester.tap(find.byType(GestureDetector)); | ||
| await tester.pump(); | ||
| expect(find.text('Increment'), findsOneWidget); | ||
| expect(find.text('Count: 4'), findsOneWidget); | ||
| expect(logs, <String>[]); // Still no effect re-run | ||
| logs.clear(); | ||
|
|
||
| // Change incrementBy prop - effect SHOULD re-run | ||
| await tester.pumpWidget(counter(10)); | ||
| expect(find.text('Increment'), findsOneWidget); | ||
| expect(find.text('Count: 24'), findsOneWidget); | ||
| expect(logs, ['Effect: by 20']); | ||
| logs.clear(); | ||
|
|
||
| // Tap button - uses new incrementBy value | ||
| await tester.tap(find.byType(GestureDetector)); | ||
| await tester.pump(); | ||
| expect(find.text('Increment'), findsOneWidget); | ||
| expect(find.text('Count: 34'), findsOneWidget); | ||
| expect(logs, <String>[]); // No effect re-run | ||
| logs.clear(); | ||
| }); |
There was a problem hiding this comment.
Duplicate test implementation.
This test is functionally identical to the "useEffect shouldn't re-fire when event handlers change" test (lines 241-311). The test name suggests it should verify useEffectEvent behavior when wrapped in a custom hook, but the implementation doesn't create or use any custom hook.
Consider either:
- Removing this duplicate test, or
- Implementing a proper custom hook test:
// Example custom hook that wraps useEffectEvent
EffectEvent<void Function([int?])> useLoggedIncrement(
ValueNotifier<int> count,
int incrementBy,
) {
return useEffectEvent(([int? amount]) {
count.value += amount ?? incrementBy;
});
}
// Then test that hook's behavior🤖 Prompt for AI Agents
In `@packages/flutter_hooks/test/use_effect_event_test.dart` around lines 313 -
385, The test duplicates an earlier one and should either be removed or
rewritten to actually exercise a custom hook; implement the custom hook
approach: add a hook like EffectEvent<void Function([int?])>
useLoggedIncrement(ValueNotifier<int> count, int incrementBy) that wraps
useEffectEvent and updates count (and optionally logs), then update the test's
HookBuilder to call useLoggedIncrement instead of creating the event inline
(keep the rest of the assertions the same to verify stability when tapping and
when incrementBy changes); ensure you reference useLoggedIncrement and
useEffectEvent in the revised test and remove the old duplicate behavior if you
choose deletion instead.
See #488
Adds
useEffectEvent, a new primitive hook inspired by React's useEffectEvent. This hook extracts non-reactive logic out ofuseEffect, allowing callbacks to access the latest values and states without adding them to the effect's dependencies.Summary
EffectEvent<T>wrapper class that provides type-safe access to the latest callback via ref indirectionuseEffectEvent<T>hook that returns anEffectEvent<T>wrapperevent.call()which reads from the internal ref at call timeUse Case
When an effect registers a callback with an external system (WebSocket, Timer, etc.), that system holds onto the callback reference. If the effect doesn't re-run when props change, the external system has a stale callback.
API
Implementation
The hook uses ref indirection: the
EffectEventwrapper holds a reference to a mutableObjectRef, not the callback itself. When the widget rebuilds,didUpdateHookupdates the ref with the latest callback. Callingevent.call()reads from the ref at invocation time, so it always returns the current callback.Why the
EffectEventWrapper?The hook returns an
EffectEvent<T>wrapper instead ofTdirectly because Dart generics cannot preserve the exact function signature when returning. Alternatives considered:Function- Loses type-safetyuseEffectEvent0,useEffectEvent1<A>, etc.) - Verbose, doesn't scaleThe wrapper provides type-safety (
event.callreturnsT) and a unified interface.Caveats
EffectEventinstance each build; never include inuseEffect's keys