217

I have built a component in React which is supposed to update its own style on window scroll to create a parallax effect.

The component render method looks like this:

  function() {
    let style = { transform: 'translateY(0px)' };

    window.addEventListener('scroll', (event) => {
      let scrollTop = event.srcElement.body.scrollTop,
          itemTranslate = Math.min(0, scrollTop/3 - 60);

      style.transform = 'translateY(' + itemTranslate + 'px)');
    });

    return (
      <div style={style}></div>
    );
  }

This doesn't work because React doesn't know that the component has changed, and therefore the component is not rerendered.

I've tried storing the value of itemTranslate in the state of the component, and calling setState in the scroll callback. However, this makes scrolling unusable as this is terribly slow.

Any suggestion on how to do this?

BenMorel
  • 34,448
  • 50
  • 182
  • 322
Alejandro Pérez
  • 2,557
  • 2
  • 17
  • 14
  • 12
    Never bind an external event handler inside a render method. Rendering methods (and any other custom methods you call from `render` in the same thread) should only be concerned with logic pertaining to rendering/updating the actual DOM in React. Instead, as shown by @AustinGreco below, you should use the given React lifecycle methods to create and remove your event binding. This makes it self-contained inside the component and ensures no leaking by making sure the event binding is removed if/when the component that uses it is unmounted. – Mike Driver Apr 19 '15 at 11:55

17 Answers17

297

You should bind the listener in componentDidMount, that way it's only created once. You should be able to store the style in state, the listener was probably the cause of performance issues.

Something like this:

componentDidMount: function() {
    window.addEventListener('scroll', this.handleScroll);
},

componentWillUnmount: function() {
    window.removeEventListener('scroll', this.handleScroll);
},

handleScroll: function(event) {
    let scrollTop = event.srcElement.body.scrollTop,
        itemTranslate = Math.min(0, scrollTop/3 - 60);

    this.setState({
      transform: itemTranslate
    });
},
Austin Greco
  • 32,997
  • 6
  • 55
  • 59
  • 36
    I found that setState'ing inside scroll event for animation is choppy. I had to manually set the style of components using refs. – Ryan Rho May 13 '15 at 21:38
  • @RyanRho I've experienced this. Reacts diff algorithm just isnt fast enough for that many triggers consecutively. I made the tentative decision to manually edit the style attribute using refs. – adsy Oct 20 '15 at 15:43
  • @TalasanNicholson I also needed this so I posted an example using refs – adrian_reimer Feb 17 '16 at 20:36
  • 2
    What would the "this" inside handleScroll be pointed to? In my case it is "window" not component. I ends up passing the component as a parameter – yuji May 13 '16 at 09:06
  • 10
    @yuji you can avoid needing to pass the component by binding this in the constructor: `this.handleScroll = this.handleScroll.bind(this)` will bind this within `handleScroll` to the component, instead of window. – Matt Parrilla Jul 21 '16 at 13:10
  • I ran into a performance issue and found that the lodash `_.debounce` method solved the issue of too many scroll events firing. https://lodash.com/docs/4.17.5#debounce – eagercoder Apr 16 '18 at 19:51
  • 1
    Note that srcElement is not available in Firefox. – Paulin Trognon Aug 06 '18 at 07:15
  • I think this is a good answer but still an expensive operation. We could return the function `handleScroll` without having to call the `setState` per each scroll executed, in this way you could set the state only when needed. – Enmanuel Duran Oct 01 '18 at 21:47
  • 5
    didn't work for me, but what did was setting scrollTop to `event.target.scrollingElement.scrollTop` – George Nov 14 '18 at 19:15
  • @George it is probably because you are setting to scroll some other elements than the `body` – 5413668060 Jul 02 '19 at 09:51
  • wrap the handleScroll function content with requestAnimationFrame. It will help the perf significantly. – jwchang Jan 28 '20 at 11:09
111

with hooks:

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

function MyApp () {

    const [offset, setOffset] = useState(0);

    useEffect(() => {
        const onScroll = () => setOffset(window.pageYOffset);
        // clean up code
        window.removeEventListener('scroll', onScroll);
        window.addEventListener('scroll', onScroll, { passive: true });
        return () => window.removeEventListener('scroll', onScroll);
    }, []);

    console.log(offset); 
};
Ahmed Boutaraa
  • 1,908
  • 1
  • 12
  • 10
57

You can pass a function to the onScroll event on the React element: https://facebook.github.io/react/docs/events.html#ui-events

<ScrollableComponent
 onScroll={this.handleScroll}
/>

Another answer that is similar: https://stackoverflow.com/a/36207913/1255973

Community
  • 1
  • 1
Con Antonakos
  • 1,735
  • 20
  • 23
  • 5
    Is there any benefit/drawback to this method vs manually adding an event listener to the window element @AustinGreco mentioned? – Dennis Jan 02 '18 at 04:56
  • 3
    @Dennis One benefit is that you don't have to manually add/remove the event listeners. While this might be a simple example if you manually manage several event listeners all over your application it's easy to forget to properly remove them upon updates, which can lead to memory bugs. I would always use the built-in version if possible. – F Lekschas Feb 24 '19 at 22:03
  • 4
    It's worth noting that this attaches a scroll handler to the component itself, not to the window, which is a very different thing. @Dennis The benefits of onScroll are that it's more cross-browser and more performant. If you can use it you probably should, but it may not be useful in cases like the one for the OP – Beau Apr 12 '19 at 18:25
  • 1
    I can not get this above example to work at all. Can someone out there PLEASE provide me with a link to how to use React's `onScroll` synthetic event? – MadHatter Aug 21 '20 at 02:27
  • @Beau could you please point me to a source and reason for "it's more cross-browser and more performant"? Are you suggesting using `overflow: hidden` on `` and `overflow: scroll` in the appropriate `
    ` will be more performant?
    – youjin Oct 21 '20 at 17:08
  • 1
    @youjin Some versions of IE and Safari on iOS can be a bit wonky with `addEventListener` as well as scrolling, and jQuery smooths a lot of that out for you (that's kinda the whole point of jQuery). Look at the browser support for both if you're curious. I'm not sure that jQuery is any more performant than vanilla js (in fact I'm sure it's not), but attaching a scroll handler to the element itself rather than the `window` is since the event won't have to bubble up through the DOM to be handled. There's always tradeoffs though.. – Beau Oct 22 '20 at 23:37
  • @Beau I'd have thought having like... a `ParallaxContainer` that listens with an `onScroll` and making it delegate state down is better (more contained, 'react' style etc.) than directly interacting with window? – AncientSwordRage Feb 06 '23 at 12:58
26

My solution for making a responsive navbar ( position: 'relative' when not scrolling and fixed when scrolling and not at the top of the page)

componentDidMount() {
    window.addEventListener('scroll', this.handleScroll);
}

componentWillUnmount() {
    window.removeEventListener('scroll', this.handleScroll);
}
handleScroll(event) {
    if (window.scrollY === 0 && this.state.scrolling === true) {
        this.setState({scrolling: false});
    }
    else if (window.scrollY !== 0 && this.state.scrolling !== true) {
        this.setState({scrolling: true});
    }
}
    <Navbar
            style={{color: '#06DCD6', borderWidth: 0, position: this.state.scrolling ? 'fixed' : 'relative', top: 0, width: '100vw', zIndex: 1}}
        >

No performance issues for me.

robins_
  • 269
  • 3
  • 3
21

to help out anyone here who noticed the laggy behavior / performance issues when using Austins answer, and wants an example using the refs mentioned in the comments, here is an example I was using for toggling a class for a scroll up / down icon:

In the render method:

<i ref={(ref) => this.scrollIcon = ref} className="fa fa-2x fa-chevron-down"></i>

In the handler method:

if (this.scrollIcon !== null) {
  if(($(document).scrollTop() + $(window).height() / 2) > ($('body').height() / 2)){
    $(this.scrollIcon).attr('class', 'fa fa-2x fa-chevron-up');
  }else{
    $(this.scrollIcon).attr('class', 'fa fa-2x fa-chevron-down');
  }
}

And add / remove your handlers the same way as Austin mentioned:

componentDidMount(){
  window.addEventListener('scroll', this.handleScroll);
},
componentWillUnmount(){
  window.removeEventListener('scroll', this.handleScroll);
},

docs on the refs.

adrian_reimer
  • 459
  • 5
  • 10
  • 4
    You saved my day! For updating, you actually don't need to use jquery to modify the classname at this point, because it is already a native DOM element. So you could simply do `this.scrollIcon.className = whatever-you-want`. – southp Mar 09 '16 at 08:59
  • 2
    this solution breaks React encapsulation although I'm still not sure of a way around this without laggy behavior - maybe a debounced scroll event (at maybe 200-250 ms) would be a solution here – Jordan Jun 09 '16 at 21:27
  • nope debounced scroll event only helps make scrolling smoother (in a non-blocking sense), but it takes 500ms to a second for the updates to state to apply in the DOM :/ – Jordan Jun 09 '16 at 21:45
  • I used this solution as well, +1. I agree you don't need jQuery: just use `className` or `classList`. Also, **I did not need `window.addEventListener()`**: I just used React's `onScroll`, and it's as fast, as long as you don't update props/state! – BenMorel Sep 27 '19 at 16:12
19

An example using classNames, React hooks useEffect, useState and styled-jsx:

import classNames from 'classnames'
import { useEffect, useState } from 'react'

const Header = _ => {
  const [ scrolled, setScrolled ] = useState()
  const classes = classNames('header', {
    scrolled: scrolled,
  })
  useEffect(_ => {
    const handleScroll = _ => { 
      if (window.pageYOffset > 1) {
        setScrolled(true)
      } else {
        setScrolled(false)
      }
    }
    window.addEventListener('scroll', handleScroll)
    return _ => {
      window.removeEventListener('scroll', handleScroll)
    }
  }, [])
  return (
    <header className={classes}>
      <h1>Your website</h1>
      <style jsx>{`
        .header {
          transition: background-color .2s;
        }
        .header.scrolled {
          background-color: rgba(0, 0, 0, .1);
        }
      `}</style>
    </header>
  )
}
export default Header
giovannipds
  • 2,860
  • 2
  • 31
  • 39
18

I found that I can't successfully add the event listener unless I pass true like so:

componentDidMount = () => {
    window.addEventListener('scroll', this.handleScroll, true);
},
Jean-Marie Dalmasso
  • 1,027
  • 12
  • 14
  • It's working. But can you figure it why we have to pass true boolean to this listener. – shah chaitanya Feb 12 '19 at 12:19
  • 2
    From w3schools: [https://www.w3schools.com/jsref/met_document_addeventlistener.asp] `userCapture`: Optional. A Boolean value that specifies whether the event should be executed in the capturing or in the bubbling phase. Possible values: true - The event handler is executed in the capturing phase false- Default. The event handler is executed in the bubbling phase – Jean-Marie Dalmasso Feb 13 '19 at 13:20
13

Function component example using useEffect:

Note: You need to remove the event listener by returning a "clean up" function in useEffect. If you don't, every time the component updates you will have an additional window scroll listener.

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

const ScrollingElement = () => {
  const [scrollY, setScrollY] = useState(0);

  function logit() {
    setScrollY(window.pageYOffset);
  }

  useEffect(() => {
    function watchScroll() {
      window.addEventListener("scroll", logit);
    }
    watchScroll();
    // Remove listener (like componentWillUnmount)
    return () => {
      window.removeEventListener("scroll", logit);
    };
  }, []);

  return (
    <div className="App">
      <div className="fixed-center">Scroll position: {scrollY}px</div>
    </div>
  );
}
Richard
  • 2,396
  • 23
  • 23
8

If what you're interested in is a child component that's scrolling, then this example might be of help: https://codepen.io/JohnReynolds57/pen/NLNOyO?editors=0011

class ScrollAwareDiv extends React.Component {
  constructor(props) {
    super(props)
    this.myRef = React.createRef()
    this.state = {scrollTop: 0}
  }

  onScroll = () => {
     const scrollTop = this.myRef.current.scrollTop
     console.log(`myRef.scrollTop: ${scrollTop}`)
     this.setState({
        scrollTop: scrollTop
     })
  }

  render() {
    const {
      scrollTop
    } = this.state
    return (
      <div
         ref={this.myRef}
         onScroll={this.onScroll}
         style={{
           border: '1px solid black',
           width: '600px',
           height: '100px',
           overflow: 'scroll',
         }} >
        <p>This demonstrates how to get the scrollTop position within a scrollable 
           react component.</p>
        <p>ScrollTop is {scrollTop}</p>
     </div>
    )
  }
}
user2312410
  • 141
  • 2
  • 4
6

My bet here is using Function components with new hooks to solve it, but instead of using useEffect like in previous answers, I think the correct option would be useLayoutEffect for an important reason:

The signature is identical to useEffect, but it fires synchronously after all DOM mutations.

This can be found in React documentation. If we use useEffect instead and we reload the page already scrolled, scrolled will be false and our class will not be applied, causing an unwanted behavior.

An example:

import React, { useState, useLayoutEffect } from "react"

const Mycomponent = (props) => {
  const [scrolled, setScrolled] = useState(false)

  useLayoutEffect(() => {
    const handleScroll = e => {
      setScrolled(window.scrollY > 0)
    }

    window.addEventListener("scroll", handleScroll)

    return () => {
      window.removeEventListener("scroll", handleScroll)
    }
  }, [])

  ...

  return (
    <div className={scrolled ? "myComponent--scrolled" : ""}>
       ...
    </div>
  )
}

A possible solution to the problem could be https://codepen.io/dcalderon/pen/mdJzOYq

const Item = (props) => { 
  const [scrollY, setScrollY] = React.useState(0)

  React.useLayoutEffect(() => {
    const handleScroll = e => {
      setScrollY(window.scrollY)
    }

    window.addEventListener("scroll", handleScroll)

    return () => {
      window.removeEventListener("scroll", handleScroll)
    }
  }, [])

  return (
    <div class="item" style={{'--scrollY': `${Math.min(0, scrollY/3 - 60)}px`}}>
      Item
    </div>
  )
}
Calderón
  • 146
  • 1
  • 5
  • I'm curious about the `useLayoutEffect`. I'm trying to see what you've mentioned. – giovannipds Mar 24 '20 at 03:40
  • If you don't mind, could you please provide a repo + visual example of this happening? I just couldn't reproduce what you've mentioned as an issue of `useEffect` here in comparison to `useLayoutEffect`. – giovannipds Mar 24 '20 at 05:01
  • Sure! [https://github.com/calderon/uselayout-vs-uselayouteffect](https://github.com/calderon/uselayout-vs-uselayouteffect). This happened to me just yesterday with a similar behavior. BTW, I'm a react newbie and possibly I'm totally wrong :D :D – Calderón Mar 24 '20 at 13:26
  • Actually I've been trying this many times, reloading a lot, and sometimes it appears header in red instead of blue, which means it is applying `.Header--scrolled` class sometimes, but a 100% times `.Header--scrolledLayout` is applied correctly thanks useLayoutEffect. – Calderón Mar 24 '20 at 14:02
  • I moved repo to https://github.com/calderon/useeffect-vs-uselayouteffect – Calderón Mar 25 '20 at 09:51
6

Update for an answer with React Hooks

These are two hooks - one for direction(up/down/none) and one for the actual position

Use like this:

useScrollPosition(position => {
    console.log(position)
  })

useScrollDirection(direction => {
    console.log(direction)
  })

Here are the hooks:

import { useState, useEffect } from "react"

export const SCROLL_DIRECTION_DOWN = "SCROLL_DIRECTION_DOWN"
export const SCROLL_DIRECTION_UP = "SCROLL_DIRECTION_UP"
export const SCROLL_DIRECTION_NONE = "SCROLL_DIRECTION_NONE"

export const useScrollDirection = callback => {
  const [lastYPosition, setLastYPosition] = useState(window.pageYOffset)
  const [timer, setTimer] = useState(null)

  const handleScroll = () => {
    if (timer !== null) {
      clearTimeout(timer)
    }
    setTimer(
      setTimeout(function () {
        callback(SCROLL_DIRECTION_NONE)
      }, 150)
    )
    if (window.pageYOffset === lastYPosition) return SCROLL_DIRECTION_NONE

    const direction = (() => {
      return lastYPosition < window.pageYOffset
        ? SCROLL_DIRECTION_DOWN
        : SCROLL_DIRECTION_UP
    })()

    callback(direction)
    setLastYPosition(window.pageYOffset)
  }

  useEffect(() => {
    window.addEventListener("scroll", handleScroll)
    return () => window.removeEventListener("scroll", handleScroll)
  })
}

export const useScrollPosition = callback => {
  const handleScroll = () => {
    callback(window.pageYOffset)
  }

  useEffect(() => {
    window.addEventListener("scroll", handleScroll)
    return () => window.removeEventListener("scroll", handleScroll)
  })
}
dowi
  • 1,005
  • 15
  • 30
4

Here is another example using HOOKS fontAwesomeIcon and Kendo UI React
[![screenshot here][1]][1]

import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';


const ScrollBackToTop = () => {
  const [show, handleShow] = useState(false);

  useEffect(() => {
    window.addEventListener('scroll', () => {
      if (window.scrollY > 1200) {
        handleShow(true);
      } else handleShow(false);
    });
    return () => {
      window.removeEventListener('scroll');
    };
  }, []);

  const backToTop = () => {
    window.scroll({ top: 0, behavior: 'smooth' });
  };

  return (
    <div>
      {show && (
      <div className="backToTop text-center">
        <button className="backToTop-btn k-button " onClick={() => backToTop()} >
          <div className="d-none d-xl-block mr-1">Top</div>
          <FontAwesomeIcon icon="chevron-up"/>
        </button>
      </div>
      )}
    </div>
  );
};

export default ScrollBackToTop;```


  [1]: https://i.stack.imgur.com/ZquHI.png
Jonathan Sanchez
  • 7,316
  • 1
  • 24
  • 20
  • This is awesome. I had a problem in my useEffect() changing my navbar sticky's state on scroll using window.onscroll()... found out through this answer that window.addEventListener() and window.removeEventListener() are the right approach for controlling my sticky navbar with a functional component... thanks! – Michael Jun 30 '20 at 06:25
4

If you find the above answers not working for you, try this:

React.useEffect(() => {
    document.addEventListener('wheel', yourCallbackHere)
    return () => {
        document.removeEventListener('wheel', yourCallbackHere)
    }
}, [yourCallbackHere])

Basically, you need to try document instead of window, and wheel instead of scroll.

Happy coding!

Yumin Gui
  • 389
  • 3
  • 9
  • 1
    thanks a lot, why wheel, it's new feature ?, wheel is working fine, It's waste 2-3 hours for this small task – Arpit Aug 24 '21 at 08:14
2

I often get a warning about rendering. This code works, but not sure if it's the best solution.

   const listenScrollEvent = () => {
    if (window.scrollY <= 70) {
        setHeader("header__main");
    } else if (window.scrollY >= 70) {
        setHeader("header__slide__down");
    }
};


useEffect(() => {
    window.addEventListener("scroll", listenScrollEvent);
    return () => {
        window.removeEventListener("scroll", listenScrollEvent);
    }
}, []);
1

To expand on @Austin's answer, you should add this.handleScroll = this.handleScroll.bind(this) to your constructor:

constructor(props){
    this.handleScroll = this.handleScroll.bind(this)
}
componentDidMount: function() {
    window.addEventListener('scroll', this.handleScroll);
},

componentWillUnmount: function() {
    window.removeEventListener('scroll', this.handleScroll);
},

handleScroll: function(event) {
    let scrollTop = event.srcElement.body.scrollTop,
        itemTranslate = Math.min(0, scrollTop/3 - 60);

    this.setState({
      transform: itemTranslate
    });
},
...

This gives handleScroll() access to the proper scope when called from the event listener.

Also be aware you cannot do the .bind(this) in the addEventListener or removeEventListener methods because they will each return references to different functions and the event will not be removed when the component unmounts.

nbwoodward
  • 2,816
  • 1
  • 16
  • 24
1

I solved the problem via using and modifying CSS variables. This way I do not have to modify the component state which causes performance issues.

index.css

:root {
  --navbar-background-color: rgba(95,108,255,1);
}

Navbar.jsx

import React, { Component } from 'react';
import styles from './Navbar.module.css';

class Navbar extends Component {

    documentStyle = document.documentElement.style;
    initalNavbarBackgroundColor = 'rgba(95, 108, 255, 1)';
    scrolledNavbarBackgroundColor = 'rgba(95, 108, 255, .7)';

    handleScroll = () => {
        if (window.scrollY === 0) {
            this.documentStyle.setProperty('--navbar-background-color', this.initalNavbarBackgroundColor);
        } else {
            this.documentStyle.setProperty('--navbar-background-color', this.scrolledNavbarBackgroundColor);
        }
    }

    componentDidMount() {
        window.addEventListener('scroll', this.handleScroll);
    }

    componentWillUnmount() {
        window.removeEventListener('scroll', this.handleScroll);
    }

    render () {
        return (
            <nav className={styles.Navbar}>
                <a href="/">Home</a>
                <a href="#about">About</a>
            </nav>
        );
    }
};

export default Navbar;

Navbar.module.css

.Navbar {
    background: var(--navbar-background-color);
}
webpreneur
  • 795
  • 9
  • 15
1
constructor() {
    super()
      this.state = {
        change: false
      }
  }

  componentDidMount() {
    window.addEventListener('scroll', this.handleScroll);
    console.log('add event');
  }

  componentWillUnmount() {
    window.removeEventListener('scroll', this.handleScroll);
    console.log('remove event');
  }

  handleScroll = e => {
    if (window.scrollY === 0) {
      this.setState({ change: false });
    } else if (window.scrollY > 0 ) {
      this.setState({ change: true });
    }
  }

render() { return ( <div className="main" style={{ boxShadow: this.state.change ? 0px 6px 12px rgba(3,109,136,0.14):none}} ></div>

This is how I did it and works perfect.