Summary
I propose extending the callback signature of useEffect (and subsequently useLayoutEffect) to accept an optional metadata argument containing the transaction details of the dependency change that triggered the runtime execution loop. This payload exposes the current dependency snapshots, the previous dependency snapshots, and an array of numeric indices pointing directly to the specific elements that failed equality checks.
Basic example
;
Motivation
In the legacy class-component architecture, the componentDidUpdate(prevProps, prevState) lifecycle method provided granular, explicit insight into exactly why a component re-rendered. This allowed developers to write straightforward differential execution logic inside a single, cohesive lifecycle block.
Modern functional components lack an ergonomic equivalent. When a multi-dependency useEffect fires, the callback executes blindly, completely unaware of which dependency triggered the execution frame.
To bypass this limitation, developers currently resort to two anti-patterns:
- Fragmentation: Splitting a single conceptually unified side-effect into 3 or 4 disconnected
useEffectcalls simply because different trigger sources require slightly different handling. This scatters state logic and multiplies reconciliation hooks. - Boilerplate Bloat: Manually introducing
useRefsnapshots to store previous values and running custom, unoptimized diffing logic directly inside user-land scripts.
By providing a structured change descriptor—conceptually similar to how useReducer delivers an action payload to categorize execution—we allow developers to safely branch execution paths within a unified, high-performance side effect.
Detailed Design
The proposed typing signature updates the standard EffectCallback definition to support an optional introspection parameter:
type DependencyList = ReadonlyArray<unknown>;
interface DependencyChangeDelta<T extends DependencyList> {
currentDepValues: T;
previousDepValues: T;
updatedDepIndices: number[]; // Array of indices where Object.is() evaluated to false
}
type EffectCallback<T extends DependencyList> = (
delta?: DependencyChangeDelta<T>
) => void | Destructor;
Fiber Reconciler Core Mechanics:
During the commit phase, React already iterates over the dependency array to compare the memoizedState of the hook against the newly passed values using Object.is.
Because this comparison occurs natively inside the core architecture loop, capturing the indices of failing elements adds virtually zero computational overhead. Instead of discarding this change matrix after evaluation, the reconciler compiles the results into a temporary transaction object and injects it directly into the user-allocated effect callback.
Drawbacks
- Array Index Fragility: Relying on hardcoded numeric indices (
updatedDepIndices.includes(0)) inside the logic block can become brittle if developers reorder items within the dependency array. However, this risk is mitigated by standard linting matrices (react-hooks/exhaustive-deps) and robust unit testing patterns. - Argument Complexity: Adding arguments to an historically empty callback signature increases the conceptual surface layer of the hook for newer developers, though it remains entirely opt-in.
Alternatives
- User-land Custom Hook Wrapper: Developers can build abstractions utilizing tracking references (
useRef). However, performing custom snapshot arrays and running secondary evaluation loops in user-land scripts entirely bypasses the internal optimizations of the React Fiber reconciliation diffing algorithm, introducing unnecessary performance overhead and rendering latency. - Object-keyed Dependencies: Instead of numeric indices, the change payload could map to named keys. However, because React dependency lists are natively ordered arrays rather than keyed dictionaries, utilizing indices preserves direct architectural alignment with the existing hook specification. To illustrate the user-land overhead, a developer would have to implement a heavy tracking abstraction similar to the following:
- Example Custom Hook Declaration
;
- Example Custom Hook Implementation
;
;
Ultimately, this user-land abstraction fails because duplicating dependency tracking and performing iterative identity-checking passes inside individual component lifecycles forces unoptimized computation loops outside the native Fiber tree evaluation cycle, introducing completely unnecessary execution overhead and micro-render delays across complex layout structures.
Adoption Strategy
This feature is completely non-breaking and backwards-compatible. If an existing useEffect declaration omits the callback parameter, the argument is ignored entirely. This can be cleanly introduced in any minor version release without requiring breaking structural transformations or codemods.
How we teach this
This capability should be introduced under advanced documentation guidelines as Effect Trigger Introspection. It should be explicitly framed as an optimization pattern meant to consolidate complex, multi-variable synchronization logic inside enterprise dashboard or data-fetching layouts, drawing a clear functional parallel to how action payloads orchestrate execution inside useReducer.
Unresolved questions
- Batching Manifestation: If multiple independent state parameters update within a single batched event loop transaction, how should concurrent index changes be sequenced? (The design specifies that
updatedDepIndicesnaturally aggregates all mutations encountered during that specific render sweep).