Skip to content

Add useEffectEvent hook#487

Open
jezsung wants to merge 5 commits intorrousselGit:masterfrom
jezsung:master
Open

Add useEffectEvent hook#487
jezsung wants to merge 5 commits intorrousselGit:masterfrom
jezsung:master

Conversation

@jezsung
Copy link
Contributor

@jezsung jezsung commented Jan 19, 2026

See #488

Adds useEffectEvent, a new primitive hook inspired by React's useEffectEvent. This hook extracts non-reactive logic out of useEffect, allowing callbacks to access the latest values and states without adding them to the effect's dependencies.

Summary

  • Add EffectEvent<T> wrapper class that provides type-safe access to the latest callback via ref indirection
  • Add useEffectEvent<T> hook that returns an EffectEvent<T> wrapper
  • The callback is accessed via event.call() which reads from the internal ref at call time

Use 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.

class ChatRoom extends HookWidget {
  final String roomId;
  final ThemeData theme;

  @override
  Widget build(BuildContext context) {
    final onConnected = useEffectEvent(() {
      showNotification('Connected!', theme);  // Always sees latest theme
    });

    useEffect(() {
      final connection = createConnection(roomId);
      connection.onConnected = () => onConnected.call();
      connection.connect();
      return connection.disconnect;
    }, [roomId]);  // No theme dependency needed
  }
}

API

class EffectEvent<T extends Function> {
  /// Always returns the latest callback stored in the shared ref.
  T get call;
}

EffectEvent<T> useEffectEvent<T extends Function>(T callback);

Implementation

The hook uses ref indirection: the EffectEvent wrapper holds a reference to a mutable ObjectRef, not the callback itself. When the widget rebuilds, didUpdateHook updates the ref with the latest callback. Calling event.call() reads from the ref at invocation time, so it always returns the current callback.

Why the EffectEvent Wrapper?

The hook returns an EffectEvent<T> wrapper instead of T directly because Dart generics cannot preserve the exact function signature when returning. Alternatives considered:

  • Return Function - Loses type-safety
  • Multiple variants (useEffectEvent0, useEffectEvent1<A>, etc.) - Verbose, doesn't scale

The wrapper provides type-safety (event.call returns T) and a unified interface.

Caveats

  1. Only call inside Effects or event handlers - Do not call during build
  2. Don't pass to child widgets - Effect events are for local use
  3. Unstable identity - Returns a new EffectEvent instance each build; never include in useEffect's keys

Adds a new hook inspired by React's useEffectEvent for extracting
non-reactive logic into functions that read the latest state.
@coderabbitai
Copy link

coderabbitai bot commented Jan 19, 2026

📝 Walkthrough

Walkthrough

A 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

Cohort / File(s) Summary
Hook Implementation
packages/flutter_hooks/lib/src/primitives.dart
Added useEffectEvent<T extends Function>(T callback), EffectEvent<T>, and backing hook/state (_EffectEventHook<T>, _EffectEventHookState<T>) with docs and examples. Note: the block is inserted twice in the diff (duplicate).
Tests
packages/flutter_hooks/test/use_effect_event_test.dart
New test suite validating latest-state capture across rebuilds, argument passing, return values, event identity across builds, interaction with useEffect dependencies, and debug diagnostics.
Docs (en + translations)
README.md, packages/flutter_hooks/resources/translations/ja_jp/README.md, .../ko_kr/README.md, .../pt_br/README.md, .../zh_cn/README.md
Added useEffectEvent entry to Primitives table and translated descriptions indicating extraction of non-reactive logic from useEffect.

Sequence Diagram(s)

(Skipped)

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related issues

Possibly related PRs

Poem

🐰 I nibble callbacks, keeping the freshest bite,
tucked in a tiny ref until effects take flight.
I hop from rebuild to build with a quiet hum,
non‑reactive logic ready when effects come,
tests applaud — a little rabbit drum.

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Add useEffectEvent hook' directly and clearly summarizes the main change in the PR, which introduces a new useEffectEvent hook primitive to Flutter Hooks.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 event after 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
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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: Missing super calls in lifecycle methods.

Other hooks in this file call super.initHook() and super.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;
  }

Comment on lines +313 to +385
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();
});
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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:

  1. Removing this duplicate test, or
  2. 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant