Using document.querySelector in React? Should I use refs instead? How?

R. Kohlisch picture R. Kohlisch · Dec 5, 2019 · Viewed 34.9k times · Source

I am building a carousel right now, in React. To scroll to the individual slides I am using document.querySelector like so :

useEffect(() => {
    document.querySelector(`#slide-${activeSlide}`).scrollIntoView({
      behavior: 'smooth',
      block: 'nearest',
      inline: 'nearest'
    });
  }, [activeSlide]);

Is this bad practice? After all, I am accessing the DOM directly here? What would be the React way of doing this?

edit: full return method

return (
    <>
      <button onClick={() => setActiveSlide(moveLeft)}>PREV</button>
      <Wrapper id="test">
        {children.map((child, i) => {
          return (
            <Slide id={`slide-${i}`} key={`slide-${i}`}>
              {child}
            </Slide>
          );
        })}
      </Wrapper>

      <button onClick={() => setActiveSlide(moveRight)}>NEXT</button>
    </>
  );

Answer

T.J. Crowder picture T.J. Crowder · Dec 5, 2019

I can't answer the "should you" part of whether to use refs for this instead other than if you do, you don't need those id values unless you use them for something else.

But here's how you would:

  1. Use useRef(null) to create the ref.

    const activeSlideRef = useRef(null);
    
  2. Put it on the Slide that's currently active

    <Slide ref={i === activeSlide ? activeSlideRef : null} ...>
    
  3. In your useEffect, use the ref's current property

    useEffect(() => {
        if (activeSlideRef.current) {
            activeSlideRef.current.scrollIntoView({
              behavior: 'smooth',
              block: 'nearest',
              inline: 'nearest'
            });
        }
    }, [activeSlide]);
    

    (I think activeSlide is a reasonable dependency for that effect. You can't use the ref, the ref itself doesn't vary...)

Live example, I've turned some of your components into divs for convenience:

const {useEffect, useRef, useState} = React;

function Deck({children}) {
    const [activeSlide, setActiveSlide] = useState(0);
    const activeSlideRef = useRef(null);

    useEffect(() => {
        if (activeSlideRef.current) {
            activeSlideRef.current.scrollIntoView({
              behavior: 'smooth',
              block: 'nearest',
              inline: 'nearest'
            });
        }
    }, [activeSlide]);

    const moveLeft = Math.max(0, activeSlide - 1);
    const moveRight = Math.min(children.length - 1, activeSlide + 1);

    return (
        <React.Fragment>
          <button onClick={() => setActiveSlide(moveLeft)}>PREV</button>
          <div id="test">
            {children.map((child, i) => {
              const active = i === activeSlide;
              return (
                <div className={`slide ${active ? "active" : ""}`} ref={active ? activeSlideRef : null} id={`slide-${i}`} key={`slide-${i}`}>
                  {child}
                </div>
              );
            })}
          </div>

          <button onClick={() => setActiveSlide(moveRight)}>NEXT</button>
        </React.Fragment>
    );
}

ReactDOM.render(
    <Deck>
      <div>slide 0 </div>
      <div>slide 1 </div>
      <div>slide 2 </div>
      <div>slide 3 </div>
      <div>slide 4 </div>
      <div>slide 5 </div>
      <div>slide 6 </div>
      <div>slide 7 </div>
      <div>slide 8 </div>
      <div>slide 9 </div>
    </Deck>,
    document.getElementById("root")
);
.slide {
  height: 4em;
  vertical-align: middle;
  text-align: center;
}
#test {
  overflow: scroll;
  max-height: 20em;
}
.active {
  font-weight: bold;
  color: blue;
}
<div id="root"></div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.10.2/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.10.2/umd/react-dom.production.min.js"></script>


In a comment you've asked:

Do you know whether it's possible to disable useEffect here for the first render?

To keep non-state per-component info around, interestingly you use useRef. The docs for useRef point out that it's not just for DOM element references, it's also for per-component non-state data. So you could have

const firstRenderRef = useRef(true);

then in your useEffect callback, check firstRenderRef.current &mndash if it's true, set it false, otherwise do the scrolling:

const {useEffect, useRef, useState} = React;

function Deck({children}) {
    const [activeSlide, setActiveSlide] = useState(0);
    const activeSlideRef = useRef(null);
    // *** Use a ref with the initial value `true`
    const firstRenderRef = useRef(true);

    console.log("render");

    useEffect(() => {
        // *** After render, don't do anything, just remember we've seen the render
        if (firstRenderRef.current) {
            console.log("set false");
            firstRenderRef.current = false;
        } else if (activeSlideRef.current) {
            console.log("scroll");
            activeSlideRef.current.scrollIntoView({
              behavior: 'smooth',
              block: 'nearest',
              inline: 'nearest'
            });
        }
    }, [activeSlide]);

    const moveLeft = Math.max(0, activeSlide - 1);
    const moveRight = Math.min(children.length - 1, activeSlide + 1);

    return (
        <React.Fragment>
          <button onClick={() => setActiveSlide(moveLeft)}>PREV</button>
          <div id="test">
            {children.map((child, i) => {
              const active = i === activeSlide;
              return (
                <div className={`slide ${active ? "active" : ""}`} ref={active ? activeSlideRef : null} id={`slide-${i}`} key={`slide-${i}`}>
                  {child}
                </div>
              );
            })}
          </div>

          <button onClick={() => setActiveSlide(moveRight)}>NEXT</button>
        </React.Fragment>
    );
}

ReactDOM.render(
    <Deck>
      <div>slide 0 </div>
      <div>slide 1 </div>
      <div>slide 2 </div>
      <div>slide 3 </div>
      <div>slide 4 </div>
      <div>slide 5 </div>
      <div>slide 6 </div>
      <div>slide 7 </div>
      <div>slide 8 </div>
      <div>slide 9 </div>
    </Deck>,
    document.getElementById("root")
);
.slide {
  height: 4em;
  vertical-align: middle;
  text-align: center;
}
#test {
  overflow: scroll;
  max-height: 10em;
}
.active {
  font-weight: bold;
  color: blue;
}
<div id="root"></div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.10.2/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.10.2/umd/react-dom.production.min.js"></script>


As a thought experiment, I wrote a hook to make the ergonomics a bit easier:

function useInstance(instance = {}) {
    // assertion: instance && typeof instance === "object"
    const ref = useRef(instance);
    return ref.current;
}

Usage:

const inst = useInstance({first: true});

In useEffect, if inst.first is true, do inst.first = false;; otherwise, do the scrolling.

Live:

const {useEffect, useRef, useState} = React;

function useInstance(instance = {}) {
    // assertion: instance && typeof instance === "object"
    const ref = useRef(instance);
    return ref.current;
}

function Deck({children}) {
    const [activeSlide, setActiveSlide] = useState(0);
    const activeSlideRef = useRef(null);
    const inst = useInstance({first: true});

    console.log("render");

    useEffect(() => {
        // *** After render, don't do anything, just remember we've seen the render
        if (inst.first) {
            console.log("set false");
            inst.first = false;
        } else if (activeSlideRef.current) {
            console.log("scroll");
            activeSlideRef.current.scrollIntoView({
              behavior: 'smooth',
              block: 'nearest',
              inline: 'nearest'
            });
        }
    }, [activeSlide]);

    const moveLeft = Math.max(0, activeSlide - 1);
    const moveRight = Math.min(children.length - 1, activeSlide + 1);

    return (
        <React.Fragment>
          <button onClick={() => setActiveSlide(moveLeft)}>PREV</button>
          <div id="test">
            {children.map((child, i) => {
              const active = i === activeSlide;
              return (
                <div className={`slide ${active ? "active" : ""}`} ref={active ? activeSlideRef : null} id={`slide-${i}`} key={`slide-${i}`}>
                  {child}
                </div>
              );
            })}
          </div>

          <button onClick={() => setActiveSlide(moveRight)}>NEXT</button>
        </React.Fragment>
    );
}

ReactDOM.render(
    <Deck>
      <div>slide 0 </div>
      <div>slide 1 </div>
      <div>slide 2 </div>
      <div>slide 3 </div>
      <div>slide 4 </div>
      <div>slide 5 </div>
      <div>slide 6 </div>
      <div>slide 7 </div>
      <div>slide 8 </div>
      <div>slide 9 </div>
    </Deck>,
    document.getElementById("root")
);
.slide {
  height: 4em;
  vertical-align: middle;
  text-align: center;
}
#test {
  overflow: scroll;
  max-height: 10em;
}
.active {
  font-weight: bold;
  color: blue;
}
<div id="root"></div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.10.2/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.10.2/umd/react-dom.production.min.js"></script>