muszynov
muszynov

Reputation: 329

React custom hook scroll listener fired only once

I try to implement scroll indicator in component using custom hook.

Here's the component: ...

const DetailListInfo: React.FC<Props> = props => {
  const container = useRef(null)
  const scrollable = useScroll(container.current)
  const { details } = props

  return (
    <div
      ref={container}
      className="content-list-info content-list-info-detailed"
    >
      {details && renderTypeDetails(details)}
      {scrollable && <ScrollIndicator />}
    </div>
  )
}

export default inject("store")(observer(DetailListInfo))

And the useScroll hook:

import React, { useState, useEffect } from "react"
import { checkIfScrollable } from "../utils/scrollableElement"

export const useScroll = (container: HTMLElement) => {
  const [isScrollNeeded, setScrollValue] = useState(true)
  const [isScrollable, setScrollable] = useState(false)

  const checkScrollPosition = (): void => {
    const scrollDiv = container
    const result =
      scrollDiv.scrollTop < scrollDiv.scrollHeight - scrollDiv.clientHeight ||
      scrollDiv.scrollTop === 0
    setScrollValue(result)
  }

  useEffect(() => {
    console.log("Hook called")
    if (!container) return null

    container.addEventListener("scroll", checkScrollPosition)
    setScrollable(checkIfScrollable(container))
    return () => container.removeEventListener("scroll", checkScrollPosition)
  }, [isScrollable, isScrollNeeded])

  return isScrollNeeded && isScrollable
}

So on every scroll in this passed component (containers are different, that's why I want to make customizable hook) I want to check for current scroll position to conditionally show or hide the indicator. The problem is, that hook is called only once, when component is rendered. It's not listening on scroll events. When this hooks was inside the component, it was working fine. What is wrong here?

Upvotes: 1

Views: 2005

Answers (2)

muszynov
muszynov

Reputation: 329

Hook that has a scroll listener inside:

export const ScrollIndicator: React.FC<Props> = props => {
  const { container } = props
  const [isScrollNeeded, setScrollValue] = useState(true)
  const [isScrollable, setScrollable] = useState(false)

  const handleScroll = (): void => {
    const scrollDiv = container
    const result =
      scrollDiv.scrollTop < scrollDiv.scrollHeight - scrollDiv.clientHeight ||
      scrollDiv.scrollTop === 0

    setScrollValue(result)
  }

  useEffect(() => {
    setScrollable(checkIfScrollable(container))
    container.addEventListener("scroll", handleScroll)
    return () => container.removeEventListener("scroll", handleScroll)
  }, [container, handleScroll])

  return isScrollable && isScrollNeeded && <Indicator />
}

In the render component there's need to check if the ref container exists. This will call hook only if container is already in DOM.

const scrollDiv = useRef(null)
{scrollDiv.current && <ScrollIndicator container={scrollDiv.current} />}

Upvotes: 0

Ramil Garipov
Ramil Garipov

Reputation: 473

Let's research your code:

const container = useRef(null)
const scrollable = useScroll(container.current) // initial container.current is null

// useScroll
const useScroll = (container: HTMLElement) => {
  // container === null at the first render
  ...

  // useEffect depends only from isScrollable, isScrollNeeded
  // these variables are changed inside the scroll listener and this hook body
  // but at the first render the container is null so the scroll subscription is not initiated 
  // and hook body won't be executed fully because there's return statement
  useEffect(() => {
    if (!container) return null
    ...
  }, [isScrollable, isScrollNeeded])
}

To make everything work correctly, your useEffect hooks should have all dependencies used inside the hook body. Pay attention to the warning notes in the documentation.

Also you can't pass only ref.current into a hook. This field is mutable and your hook will not be notified (re-executed) when ref.current is changed (when it's mounted). You should pass whole ref object to be able to get HTML Element by ref.current inside the useEffect.

The correct version of this function should look like:

export const useScroll = (ref: React.RefObject<HTMLElement>) => {
  const [isScrollNeeded, setScrollValue] = useState(true);
  const [isScrollable, setScrollable] = useState(false);

  useEffect(() => {
    const container = ref.current;

    if (!container) return;

    const checkScrollPosition = (): void => {
      const scrollDiv = container;
      const result =
        scrollDiv.scrollTop < scrollDiv.scrollHeight - scrollDiv.clientHeight ||
        scrollDiv.scrollTop === 0;
      setScrollValue(result);
      setScrollable(checkIfScrollable(scrollDiv));
    };

    container.addEventListener("scroll", checkScrollPosition);
    setScrollable(checkIfScrollable(container));
    return () => container.removeEventListener("scroll", checkScrollPosition);

    // this is not the best place to depend on isScrollNeeded or isScrollable
    // because every time on these variables are changed scroll subscription will be reinitialized
    // it seems that it is better to do all calculations inside the scroll handler
  }, [ref]);

  return isScrollNeeded && isScrollable
}

// somewhere in a render:
const ref = useRef(null);
const isScrollable = useScroll(ref);

Upvotes: 2

Related Questions