Skip to content

Add useEffectEvent hook added in React v19.2 #488

@jezsung

Description

@jezsung

Problem

When using useEffect with external systems (WebSockets, timers, event subscriptions), callbacks registered with these systems can become stale. The problem occurs when:

  1. An effect registers a callback with an external system
  2. The effect depends only on some values (e.g., roomId), not others (e.g., theme)
  3. When theme changes, the effect doesn't re-run, so the external system still holds the old callback
  4. When the external system invokes the callback, it sees stale theme value
class ChatRoom extends HookWidget {
  final String roomId;
  final ThemeData theme;

  @override
  Widget build(BuildContext context) {
    useEffect(() {
      final connection = createConnection(roomId);
      connection.onConnected = () {
        showNotification('Connected!', theme);  // Captures theme from this build
      };
      connection.connect();
      return connection.disconnect;
    }, [roomId]);  // Only depends on roomId
    // Problem: when theme changes, callback still sees old theme!
  }
}

The dilemma:

  • Include theme in dependencies → effect re-runs on theme change (unnecessary reconnection)
  • Omit theme → callback sees stale values

Proposed Solution #487

Add a useEffectEvent hook that extracts non-reactive logic into a function that always reads the latest values via ref indirection.

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!
  }
}

Proposed API:

/// A wrapper class that holds an effect event callback.
class EffectEvent<T extends Function> {
  /// Always returns the latest callback stored in the shared ref.
  T get call;
}

/// Extracts non-reactive logic into a function that reads the latest state.
EffectEvent<T> useEffectEvent<T extends Function>(T callback);

Alternative Solutions

1. Manual ref pattern

final themeRef = useRef(theme);

useEffect(() {
  themeRef.value = theme;  // Update ref every build
});

useEffect(() {
  final connection = createConnection(roomId);
  connection.onConnected = () {
    showNotification('Connected!', themeRef.value);
  };
  connection.connect();
  return connection.disconnect;
}, [roomId]);

Problems:

  • Boilerplate - must manually sync ref with prop
  • Error-prone - easy to forget to update the ref
  • Poor readability - obscures the relationship between prop and usage
  • Timing issues - ref updates in a separate effect

2. Include all values in dependencies

useEffect(() {
  final connection = createConnection(roomId);
  connection.onConnected = () {
    showNotification('Connected!', theme);
  };
  connection.connect();
  return connection.disconnect;
}, [roomId, theme]);  // Reconnects when theme changes!

Problems:

  • Causes unnecessary side effects (reconnection on theme change)
  • May be impossible for some external systems that shouldn't be restarted

Additional Context

This hook is modeled after React's useEffectEvent hook added in v19.2:

Why the EffectEvent wrapper class?

The hook returns an EffectEvent<T> wrapper instead of the callback function directly because it was impossible to ensure type-safety when returning the same typed function as the provided callback through Dart generics.

Alternative approaches considered:

  1. Return Function type - Loses type-safety; callers would need to cast or use dynamic invocation
  2. Multiple hook variants - Provide useEffectEvent0, useEffectEvent1<A>, useEffectEvent2<A, B>, etc. for different argument counts - Verbose and doesn't scale

The wrapper class pattern was chosen because it:

  • Provides full type-safety (event.call returns T, T extends Function)
  • Offers a unified interface regardless of callback signature

Metadata

Metadata

Assignees

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions