117

I'm building a form - series of questions (radio buttons) the user needs to answer before he can move on to the next screen. For fields validation I'm using yup (npm package) and redux as state management.

For one particular scenario/combination a new screen (div) is revealed asking for a confirmation (checkbox) before the user can proceed. I want to apply the validation for this checkbox only if displayed.

How can I check if an element (div) is displayed in the DOM using React?

The way I thought of doing it was to set a varibale 'isScreenVisible' to false and if the conditions are met I would change the state to 'true'.

I'm doing that check and setting 'isScreenVisible' to true or false in _renderScreen() but for some reason it's going into an infinite loop.

My code:

class Component extends React.Component {

  constructor(props) {
    super(props);

    this.state = {
      formisValid: true,
      errors: {},
      isScreenVisible: false
    }

    this.FormValidator = new Validate();
    this.FormValidator.setValidationSchema(this.getValidationSchema());
  }

  areThereErrors(errors) {
    var key, er = false;
    for(key in errors) {
      if(errors[key]) {er = true}
    }
    return er;
  }

  getValidationSchema() {
    return yup.object().shape({
      TravelInsurance: yup.string().min(1).required("Please select an option"),
      MobilePhoneInsurance: yup.string().min(1).required("Please select an option"),
      Confirmation: yup.string().min(1).required("Please confirm"),
    });
  }

  //values of form fields
  getValidationObject() {
    let openConfirmation = (this.props.store.Confirmation === true)? 'confirmed': ''

    return {
      TravelInsurance: this.props.store.TravelInsurance,
      MobilePhoneInsurance: this.props.store.MobilePhoneInsurance,
      Confirmation: openConfirmation,
    }
  }

  setSubmitErrors(errors) {
    this.setState({errors: errors});
  }

  submitForm() {
    var isErrored, prom, scope = this, obj = this.getValidationObject();
    prom = this.FormValidator.validateSubmit(obj);

    prom.then((errors) => {
      isErrored = this.FormValidator.isFormErrored();

      scope.setState({errors: errors}, () => {
        if (isErrored) {
        } else {
          this.context.router.push('/Confirm');
        }
      });
    });
  }

  saveData(e) {
    let data = {}
    data[e.target.name] = e.target.value

    this.props.addData(data)

    this.props.addData({
      Confirmation: e.target.checked
    })
  }

  _renderScreen = () => {
    const {
      Confirmation
    } = this.props.store

    if(typeof(this.props.store.TravelInsurance) !== 'undefined' && typeof(this.props.store.MobilePhoneInsurance) !== 'undefined') &&
    ((this.props.store.TravelInsurance === 'Yes' && this.props.store.MobilePhoneInsurance === 'No') ||
    (this.props.store.TravelInsurance === 'No' && this.props.store.MobilePhoneInsurance === 'Yes')){

        this.setState({
            isScreenVisible: true
        })

          return(
            <div>
                <p>Please confirm that you want to proceed</p>

                  <CheckboxField
                    id="Confirmation"
                    name="Confirmation"
                    value={Confirmation}
                    validationMessage={this.state.errors.Confirmation}
                    label="I confirm that I would like to continue"
                    defaultChecked={!!Confirmation}
                    onClick={(e)=> {this.saveData(e)} }
                  />
                </FormLabel>
            </div>
          )
      }
      else{
        this.setState({
            isScreenVisible: false
        })
      }
  }

  render(){
    const {
      TravelInsurance,
      MobilePhoneInsurance
    } = this.props.store

    return (
      <div>           
          <RadioButtonGroup
            id="TravelInsurance"
            name="TravelInsurance"
            checked={TravelInsurance}
            onClick={this.saveData.bind(this)}
            options={{
              'Yes': 'Yes',
              'No': 'No'
            }}
            validationMessage={(this.state.errors.TravelInsurance) ? this.state.errors.TravelInsurance : null }
          />

        <RadioButtonGroup
          id="MobilePhoneInsurance"
          name="MobilePhoneInsurance"
          checked={MobilePhoneInsurance}
          onClick={this.saveData.bind(this)}
          options={{
            'Yes': 'Yes',
            'No': 'No'
          }}
          validationMessage={(this.state.errors.MobilePhoneInsurance) ? this.state.errors.MobilePhoneInsurance : null }
        />

        this._renderScreen()

        <ButtonRow
            primaryProps={{
                children: 'Continue',
                onClick: e=>{
                this.submitForm();
            }
        }}
      </div>
    )
  }
}

const mapStateToProps = (state) => {
  return {
    store: state.Insurance,
  }
}

const Insurance = connect(mapStateToProps,{addData})(Component)

export default Insurance

12 Answers 12

238

Here is a reusable hook that takes advantage of the IntersectionObserver API.

The hook

export default function useOnScreen(ref: RefObject<HTMLElement>) {

  const [isIntersecting, setIntersecting] = useState(false)

  const observer = useMemo(() => new IntersectionObserver(
    ([entry]) => setIntersecting(entry.isIntersecting)
  ), [ref])


  useEffect(() => {
    observer.observe(ref.current)
    return () => observer.disconnect()
  }, [])

  return isIntersecting
}

Usage

const DummyComponent = () => {
  
  const ref = useRef<HTMLDivElement>(null)
  const isVisible = useOnScreen(ref)
  
  return <div ref={ref}>{isVisible && `Yep, I'm on screen`}</div>
}
Sign up to request clarification or add additional context in comments.

12 Comments

IntersectionObserver doesn't work with Safari 10
Doesn't this create a new IntersectionObserver on every render? I believe the similar implementation in stackoverflow.com/a/67826055/1727807 will avoid this.
Using useMemo in this hook is fairly simple: const observer = useMemo(() => new IntersectionObserver(…), [ref, rootMargin]). This is a topic on it’s own and the threshold from when to use useMemo to prevent extra processing really depends on your implementation
putting observer inside useEffect is totally fine I think, won't even need usememo
@RobBauer for TS just need to verify if (ref.current) before calling the .observe
|
46

You can attach a ref to the element that you want to check if it is on the viewport and then have something like:

  /**
   * Check if an element is in viewport
   *
   * @param {number} [offset]
   * @returns {boolean}
   */
  isInViewport(offset = 0) {
    if (!this.yourElement) return false;
    const top = this.yourElement.getBoundingClientRect().top;
    return (top + offset) >= 0 && (top - offset) <= window.innerHeight;
  }


  render(){

     return(<div ref={(el) => this.yourElement = el}> ... </div>)

  }

You can attach listeners like onScroll and check when the element will be on the viewport.

You can also use the Intersection Observer API with a polyfil or use a HoC component that does the job

6 Comments

how does your code work, a little explanation will do
When is isInViewport() called?
This is the first time I am seeing an answer getting 40+ upvotes without proper code explanation.
For posterity sake, seems like isInViewport() would be called in your onScroll listener, which the author does mention, but vaguely.
is there a way to get all element/product id's in current viewport?
|
27

Based on Avraam's answer I wrote a Typescript-compatible small hook to satisfy the actual React code convention.

import { useRef, useEffect, useState } from "react";
import throttle from "lodash.throttle";

/**
 * Check if an element is in viewport

 * @param {number} offset - Number of pixels up to the observable element from the top
 * @param {number} throttleMilliseconds - Throttle observable listener, in ms
 */
export default function useVisibility<Element extends HTMLElement>(
  offset = 0,
  throttleMilliseconds = 100
): [Boolean, React.RefObject<Element>] {
  const [isVisible, setIsVisible] = useState(false);
  const currentElement = useRef<Element>();

  const onScroll = throttle(() => {
    if (!currentElement.current) {
      setIsVisible(false);
      return;
    }
    const top = currentElement.current.getBoundingClientRect().top;
    setIsVisible(top + offset >= 0 && top - offset <= window.innerHeight);
  }, throttleMilliseconds);

  useEffect(() => {
    document.addEventListener('scroll', onScroll, true);
    return () => document.removeEventListener('scroll', onScroll, true);
  });

  return [isVisible, currentElement];
}

Usage example:

const Example: FC = () => {
  const [ isVisible, currentElement ] = useVisibility<HTMLDivElement>(100);

  return <Spinner ref={currentElement} isVisible={isVisible} />;
};

You can find the example on Codesandbox. I hope you will find it helpful!

5 Comments

hello, it is better to provide this in codesandbox or any of your choice. Please?
Yes, appended a link to the Codesandbox.
Any insights on how to make it work it divs, e.g a div that will contain items and its just a small part of the interface, that is fixed.
Wouldn't the createRef get lost on rerender? Maybe use a useRef instead
set default to true so it is hidden on default, otherwise it requires a user input for the hook to check.
10

@Alex Gusev answer without lodash and using useRef

import { MutableRefObject, useEffect, useRef, useState } from 'react'

/**
 * Check if an element is in viewport
 * @param {number} offset - Number of pixels up to the observable element from the top
 */
export default function useVisibility<T>(
  offset = 0,
): [boolean, MutableRefObject<T>] {
  const [isVisible, setIsVisible] = useState(false)
  const currentElement = useRef(null)

  const onScroll = () => {
    if (!currentElement.current) {
      setIsVisible(false)
      return
    }
    const top = currentElement.current.getBoundingClientRect().top
    setIsVisible(top + offset >= 0 && top - offset <= window.innerHeight)
  }

  useEffect(() => {
    document.addEventListener('scroll', onScroll, true)
    return () => document.removeEventListener('scroll', onScroll, true)
  })

  return [isVisible, currentElement]
}

usage example:

 const [beforeCheckoutSubmitShown, beforeCheckoutSubmitRef] = useVisibility<HTMLDivElement>()

 return (
     <div ref={beforeCheckoutSubmitRef} />

Comments

9

After trying out the different proposed solutions with TypeScript, we have been facing errors due to the first render setting the default useRef to null.

Here you have our solution just in case it helps other people 😊

The hook

useInViewport.ts:

import React, { useCallback, useEffect, useState } from "react";

export function useInViewport(): { isInViewport: boolean; ref: React.RefCallback<HTMLElement> } {
    const [isInViewport, setIsInViewport] = useState(false);
    const [refElement, setRefElement] = useState<HTMLElement | null>(null);

    const setRef = useCallback((node: HTMLElement | null) => {
        if (node !== null) {
            setRefElement(node);
        }
    }, []);

    useEffect(() => {
        if (refElement && !isInViewport) {
            const observer = new IntersectionObserver(
                ([entry]) => entry.isIntersecting && setIsInViewport(true)
            );
            observer.observe(refElement);

            return () => {
                observer.disconnect();
            };
        }
    }, [isInViewport, refElement]);

    return { isInViewport, ref: setRef };
}

Usage

SomeReactComponent.tsx:

import { useInViewport } from "../layout/useInViewport";

export function SomeReactComponent() {
    const { isInViewport, ref } = useInViewport();

    return (
        <>
            <h3>A component which only renders content if it is in the current user viewport</h3>

            <section ref={ref}>{isInViewport && (<ComponentContentOnlyLoadedIfItIsInViewport />)}</section>
        </>
    );
}

Solution thanks to @isma-navarro 😊

Comments

5

I have had the same problem, and, looks, I found the pretty good solution in pure react jsx, without installing any libraries.

import React, {Component} from "react";
    
    class OurReactComponent extends Component {

    //attach our function to document event listener on scrolling whole doc
    componentDidMount() {
        document.addEventListener("scroll", this.isInViewport);
    }

    //do not forget to remove it after destroyed
    componentWillUnmount() {
        document.removeEventListener("scroll", this.isInViewport);
    }

    //our function which is called anytime document is scrolling (on scrolling)
    isInViewport = () => {
        //get how much pixels left to scrolling our ReactElement
        const top = this.viewElement.getBoundingClientRect().top;

        //here we check if element top reference is on the top of viewport
        /*
        * If the value is positive then top of element is below the top of viewport
        * If the value is zero then top of element is on the top of viewport
        * If the value is negative then top of element is above the top of viewport
        * */
        if(top <= 0){
            console.log("Element is in view or above the viewport");
        }else{
            console.log("Element is outside view");
        }
    };

    render() {
        // set reference to our scrolling element
        let setRef = (el) => {
            this.viewElement = el;
        };
        return (
            // add setting function to ref attribute the element which we want to check
            <section ref={setRef}>
                {/*some code*/}
            </section>
        );
    }
}

export default OurReactComponent;

I was trying to figure out how to animate elements if the are in viewport.

Here is work project on CodeSandbox.

Comments

3

This is based on the answer from Creaforge but more optimized for the case when you want to check if the component has become visible (and in TypeScript).

Hook

function useWasSeen() {
  // to prevents runtime crash in IE, let's mark it true right away
  const [wasSeen, setWasSeen] = React.useState(
    typeof IntersectionObserver !== "function"
  );

  const ref = React.useRef<HTMLDivElement>(null);
  React.useEffect(() => {
    if (ref.current && !wasSeen) {
      const observer = new IntersectionObserver(
        ([entry]) => entry.isIntersecting && setWasSeen(true)
      );
      observer.observe(ref.current);
      return () => {
        observer.disconnect();
      };
    }
  }, [wasSeen]);
  return [wasSeen, ref] as const;
}

Usage

const ExampleComponent = () => {
  const [wasSeen, ref] = useWasSeen();  
  return <div ref={ref}>{wasSeen && `Lazy loaded`}</div>
}

Keep in mind that if your component is not mounted at the same time as the hook is called you would have to make this code more complicated. Like turning dependency array into [wasSeen, ref.current]

1 Comment

In case you want to render an element before it enters a screen you can pass the offset: function useWasSeen(offset = 0) { /* ... */ const observer = new IntersectionObserver(([entry]) => entry.isIntersecting && setWasSeen(true), { rootMargin: offset + 'px' });
2

TypeScript based approach to @Creaforge's Intersection Observer approach, that fixes the issue with ref.current being potentially undefined if the hook was called before the element is mounted:

export default function useOnScreen<Element extends HTMLElement>(): [
  boolean,
  React.RefCallback<Element>,
] {
  const [intersecting, setIntersecting] = useState(false);
  const observer = useMemo(
    () => new IntersectionObserver(([entry]) => setIntersecting(entry.isIntersecting)),
    [setIntersecting],
  );

  const currentElement = useCallback(
    (ele: Element | null) => {
      if (ele) {
        observer.observe(ele);
      } else {
        observer.disconnect();
        setIntersecting(false);
      }
    },
    [observer, setIntersecting],
  );

  return [intersecting, currentElement];
}

Usage:

const [endOfList, endOfListRef] = useOnScreen();
...
return <div ref={endOfListRef} />

1 Comment

It's worth noting, you can get the boundingClientRect from your entry param so you can also do bounding checks for specific sides.
0

based on @GuCier 's comment. we could add another parameter to specify the container element. This might be useful for modals, tables, lists, any non-body element with a scroll.

import { useEffect, useMemo, useState } from 'react';

// A custom hook that receives an element and an optional
// parent element (defaults to body) and returns a boolean
// value based on whether the element in question
// is visible inside the said element.
// This information is useful if we want to prevent unnecessary
// image or complex component renders.
export const useIsVisible = (elRef, containerRef) => {
  const [isIntersecting, setIntersecting] = useState(false);

  const observer = useMemo(
    () =>
      new IntersectionObserver(
        ([entry]) => setIntersecting(entry.isIntersecting),
        { root: containerRef?.current }
      ),
    [elRef, containerRef] //eslint-disable-line
  );

  useEffect(() => {
    observer.observe(elRef.current);
    return () => observer.disconnect();
  }, []); //eslint-disable-line

  return isIntersecting;
};

Comments

0

supports fast scrolling with the scroll bar, no visual errors, typescript friendly:

import {useEffect, useState, useRef, RefObject} from 'react';

/**
 * Custom Hook to determine if an element is in the viewport.
 * @param rootMargin Margin around the root, similar to the CSS margin property.
 * @returns A boolean state indicating visibility.
 */
export const useInView = (ref: RefObject<HTMLElement>, rootMargin: string = '0px') => {
    const [isInView, setInView] = useState(false);

    // Use a single instance of IntersectionObserver
    const observerRef = useRef<IntersectionObserver>();

    useEffect(() => {
        if (observerRef.current) {
            observerRef.current.disconnect(); // Disconnect previous observer if it exists
        }

        observerRef.current = new IntersectionObserver((entries) => {
            // Set state based on whether the element is intersecting
            setInView(entries[0].isIntersecting);
        }, { rootMargin });

        const { current } = ref;
        if (current) {
            observerRef.current.observe(current);
        }

        return () => {
            observerRef.current?.disconnect();
        };
    }, [ref, rootMargin]); // Re-run effect if ref or rootMargin changes

    return isInView;
};

Comments

0

Observe Element In | Out viewport

import { useEffect, useRef } from 'react'

const MyCard = () => {
  const cardRef = useRef(null)

  useEffect(() => {
    const observer = new IntersectionObserver(
      ([ent]: IntersectionObserverEntry[]) => {
        alert(ent.isIntersecting ? 'In' : 'Out')
      },
      { threshold: [1] }
    )

    observer.observe(cardRef.current)

    return () => observer.disconnect()
  }, [])

  return <div ref={cardRef}>This is an observable Element</div>
}

export default MyCard

Comments

-1

Answer based on the post from @Alex Gusev

React hook to check whether the element is visible with a few fixes and based on the rxjs library.

import React, { useEffect, createRef, useState } from 'react';
import { Subject, Subscription } from 'rxjs';
import { debounceTime, throttleTime } from 'rxjs/operators';

/**
 * Check if an element is in viewport
 * @param {number} offset - Number of pixels up to the observable element from the top
 * @param {number} throttleMilliseconds - Throttle observable listener, in ms
 * @param {boolean} triggerOnce - Trigger renderer only once when element become visible
 */
export default function useVisibleOnScreen<Element extends HTMLElement>(
  offset = 0,
  throttleMilliseconds = 1000,
  triggerOnce = false,
  scrollElementId = ''
): [boolean, React.RefObject<Element>] {
  const [isVisible, setIsVisible] = useState(false);
  const currentElement = createRef<Element>();

  useEffect(() => {
    let subscription: Subscription | null = null;
    let onScrollHandler: (() => void) | null = null;
    const scrollElement = scrollElementId
      ? document.getElementById(scrollElementId)
      : window;
    const ref = currentElement.current;
    if (ref && scrollElement) {
      const subject = new Subject();
      subscription = subject
        .pipe(throttleTime(throttleMilliseconds))
        .subscribe(() => {
          if (!ref) {
            if (!triggerOnce) {
              setIsVisible(false);
            }
            return;
          }

          const top = ref.getBoundingClientRect().top;
          const visible =
            top + offset >= 0 && top - offset <= window.innerHeight;
          if (triggerOnce) {
            if (visible) {
              setIsVisible(visible);
            }
          } else {
            setIsVisible(visible);
          }
        });
      onScrollHandler = () => {
        subject.next();
      };
      if (scrollElement) {
        scrollElement.addEventListener('scroll', onScrollHandler, false);
      }
      // Check when just loaded:
      onScrollHandler();
    } else {
      console.log('Ref or scroll element cannot be found.');
    }

    return () => {
      if (onScrollHandler && scrollElement) {
        scrollElement.removeEventListener('scroll', onScrollHandler, false);
      }
      if (subscription) {
        subscription.unsubscribe();
      }
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [offset, throttleMilliseconds, triggerOnce, scrollElementId]);

  return [isVisible, currentElement];
}

1 Comment

seems a very complicated solution. also rxjs 🤮

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.