Problem
When using useEffect with external systems (WebSockets, timers, event subscriptions), callbacks registered with these systems can become stale. The problem occurs when:
- An effect registers a callback with an external system
- The effect depends only on some values (e.g.,
roomId), not others (e.g., theme)
- When
theme changes, the effect doesn't re-run, so the external system still holds the old callback
- 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:
- Return
Function type - Loses type-safety; callers would need to cast or use dynamic invocation
- 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
Problem
When using
useEffectwith external systems (WebSockets, timers, event subscriptions), callbacks registered with these systems can become stale. The problem occurs when:roomId), not others (e.g.,theme)themechanges, the effect doesn't re-run, so the external system still holds the old callbackthemevalueThe dilemma:
themein dependencies → effect re-runs on theme change (unnecessary reconnection)theme→ callback sees stale valuesProposed Solution #487
Add a
useEffectEventhook that extracts non-reactive logic into a function that always reads the latest values via ref indirection.Proposed API:
Alternative Solutions
1. Manual ref pattern
Problems:
2. Include all values in dependencies
Problems:
Additional Context
This hook is modeled after React's
useEffectEventhook added in v19.2:Why the
EffectEventwrapper 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:
Functiontype - Loses type-safety; callers would need to cast or use dynamic invocationuseEffectEvent0,useEffectEvent1<A>,useEffectEvent2<A, B>, etc. for different argument counts - Verbose and doesn't scaleThe wrapper class pattern was chosen because it:
event.callreturnsT,T extends Function)