Skip to content

Modernize hook observation with a shared callback-ref core #758

Description

@thebuilder

Problem

useInView stores its DOM target in React state and starts observation from an
effect. Attaching the ref therefore causes an additional commit before the
observer is active. The hook also updates its latest callback ref during render
and uses render-phase state-reset logic when the observed node disappears.

useOnInView already observes from a callback ref, but it has separate target,
option, and cleanup lifecycle machinery. The two hooks can share a small private
callback-ref observer core while keeping their public behavior distinct.

This follows the useful direction of PR #742, but that PR is outdated relative
to the current implementation and should not be merged as-is. The replacement
must include current options such as scrollMargin, preserve fallback behavior,
avoid broadening public option types, and remain compatible with React 17–19.
The extra-render concern was also previously raised in #592.

Prerequisites

Before refactoring production code, add lifecycle characterization tests that
lock down the corrected behavior.

Proposed direction

  1. Characterize both hooks across mount, stable rerender, option changes, callback
    changes, ref movement, detach/reattach, skip, triggerOnce, fallback,
    merged refs, cleanup, and Strict Mode.
  2. Add a private callback-ref observer core that owns target attachment,
    option-driven reattachment, observe() registration, and idempotent cleanup.
  3. Publish changing callbacks during the commit phase rather than mutating refs
    during render.
  4. Migrate useOnInView first without behavior changes, then migrate
    useInView and remove its target-element state/effect cycle.
  5. Move detached-target reset out of render while preserving its existing
    initialInView, skip, and triggerOnce semantics.

Acceptance criteria

  • Characterization tests assert callback sequences, observer churn, cleanup,
    warnings, and render/commit counts before the refactor.
  • useInView no longer stores its target element in React state.
  • Both hooks use one private callback-ref observation core.
  • Production callback/option refs are not mutated during render.
  • Attaching the useInView ref no longer causes the current extra commit.
  • Target A → B replacement cannot let stale A cleanup detach B.
  • initialInView, first-false suppression, skip, threshold-aware
    triggerOnce, fallbackInView, merged refs, and option changes retain
    their current corrected behavior.
  • React 17, 18, and 19 packed consumers pass without warnings or missing
    React-export diagnostics.
  • Generated public declarations are unchanged.
  • Existing useInView, useOnInView, and observe size limits pass.

Non-goals

  • A React-19-only implementation or entry point
  • Dropping React 17/18 support
  • Using useEffectEvent from IntersectionObserver callbacks
  • Changing the class-based InView component
  • Removing fallbackInView or changing public option/return types
  • Adding onChange to IntersectionEffectOptions

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions