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

2

45

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>
    </>
  );
Exhume answered 5/12, 2019 at 16:0 Comment(2)
Is the element you're sliding into view rendered by your component?Glyceryl
Yes. I'll edit my post to include the full return blockExhume
G
55

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(() => {
         activeSlideRef.current?.scrollIntoView({
           behavior: "smooth",
           block: "nearest",
           inline: "nearest"
         });
     }, [activeSlideRef.current]);
    

Notice the optional chaining (?.) after activeSlideRef.current since it can be null, and notice using activeSlideRef.current as the useEffect dependency.

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(() => {
        activeSlideRef.current?.scrollIntoView({
          behavior: "smooth",
          block: "nearest",
          inline: "nearest"
        });
    }, [activeSlideRef.current]);

    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>
Glyceryl answered 5/12, 2019 at 16:22 Comment(7)
thank you very much, this works! Do you know whether it's possible to disable useEffect here for the first render?Exhume
@R.Kohlisch - I've added to the end of the answer to show how to do that.Glyceryl
this is so amazing, thank you so much. I wish I could upvote this answer more!!Exhume
Refs are more reliable for getting the specific DOM node you care about. Using querySelector or something similar (getElementsByClass or getElementById) can give unexpected results if there are multiple elements that match the query, especially if there is one outside the element that matches it. If you're writing a component that will show up multiple times in many places, refs become more of a clear-cut winner.Talya
Use useLayouEffect instead of useEffect. Follow the link for more informationHibbs
@UkuLele - There's no state change in the callback, so there's no particular reason to use useLayoutEffect over useEffect, so I followed the React documentation guidelines: "we recommend starting with useEffect first and only trying useLayoutEffect if that causes a problem." (their emphasis).Glyceryl
Step 2 in this answer (assigning a ref conditionally) is absolute genius. I've been fighting for days with a custom typeahead, where I only want to call some custom scrolling logic for keyboard events, because it screws up with mouse scrolling. So there was a need to have a ref to a child in a parent, and this solution works beautifully. Great stuff.Pincer
R
29

Adding to the accepted answer and trying to answer the 'should' part of the question, wrt using refs for DOM manipulation:

  • refs make it easier to uniquely identify + select in linear time the corresponding element (as compared to id which multiple elements can, by mistake, have the same value for + compared to document.querySelector which needs to scan the DOM to select the correct element)
  • refs are aware of react component lifecycle, so react would make sure that refs are updated to null when component unmounts and more out of the box convenience.
  • refs as a concept + syntax are platform agnostic, so you can use the same understanding in react native and the browser, while query selector is a browser thing
  • for SSR, where there is no DOM, refs can still be used to target react elements

ofcourse, using query selector is not incorrect and it wouldn't break your functionality if you use it in the react world generally, but it is better to use something provided by the framework as it comes with some default benefits in most cases.

Ross answered 20/9, 2021 at 20:12 Comment(1)
Thanks for the answer, I was pondering this myself and I mostly agree with you, however in some situations using the query selector seems more appropriate. For example if you were doing some sort of animation on all the images on a page as it appears in the viewport using the intersectionObserver. In this case, usually you can query the dom for all the images to observe; perhaps at an ancestor level, otherwise you would have to make an image component where each one has its own intersectionOberserver. Maybe that's the react way?Sodamide

© 2022 - 2024 — McMap. All rights reserved.