Steven Matthews
Steven Matthews

Reputation: 11285

When to scrollIntoView in React with an API call

So I have a React application, and I want to scroll to the bottom of a div.

   componentDidMount() {
     this.pullMessages();
     this.scrollToBottom();
    }

  pullMessages() {
    var api = new API(this.props.keycloak);
    var merchant_id = this.props.location.pathname.substr(this.props.location.pathname.lastIndexOf('/') + 1);
    api.get("merchantMessages", { "repl_str": merchant_id }).then(
      response => this.loadMessages(response.data)
    ).catch(function (error) {
      console.log(error);
    })
  }

 loadMessages(data) {
    var count = 0;

    var messagevals = [];

    data.reverse().forEach(function (obj) {
      messagevals.push(generateMessage(obj, count, {}));
      count++;
    });

    this.setState({ messages: messagevals });

    this.setState({ isLoading: false });

  }

  scrollToBottom = () => {
    // Using this method because the reference didn't work
      var bottomele = document.getElementById("bottom-scroll");
      if (bottomele !== null) {
        bottomele.scrollIntoView();
      }
  }

render() {

if (this.state.isLoading) {

  return (<Loading />)
}
else {
  return (

<div>
<div id="messages-container">
    <div id="messages">
        { this.state.messages.map((message, index) =>
        <div className={ "message " + message.position} key={index}>{message.text}</div>
        }
        <div className="bottom-scroll" id="bottom-scroll" ref={(el)=> { this.messagesEndRef = el; }}>
        </div>
    </div>
</div>
</div>
....

It is populated via an API call (and a loading modal is shown until this API call fills an array in state)

My problem is that I want to scroll to the bottom once the messages div is populated.

My issue is that my scroll to the bottom code seems to be executing before the messages are filled in, and so no scrolling happens.

How do I make sure to scroll only when my messages are populated and rendered? I've considered putting it in componentDidUpdate(), but the problem with that is I only want this scroll action to happen on first load, and then on message send.

Upvotes: 1

Views: 2369

Answers (2)

Mustafa
Mustafa

Reputation: 158

You just need to use async/await to stop until data load. So you can update scroll when the data loading in your component as below:

pullMessages()
{
  const { keycloak, location } = this.props;
  const api = new API(this.props.keycloak);
  const merchant_id = location.pathname.substr(location.pathname.lastIndexOf('/') + 1);

  // this api.get function is async
  api.get("merchantMessages", { "repl_str": merchant_id }).then((response) =>
  {
    this.loadMessages(response.data);
    this.scrollToBottom(); // <--- this could work
  }).catch(function (error)
  {
    console.log(error);
  });
}

That previous example was first option to work normally. But you could do better as below:

componentDidMount()
{
  this.pullMessages()
  .then(() =>
  {
    this.scrollToBottom();
  });
}

// The main thing is to use async operator
// to make function async and to wait also
// When you put the "async" keyword before the function
// the the function going to be async
async pullMessages()
{
  const { keycloak, location } = this.props;
  const api = new API(this.props.keycloak);
  const merchant_id = location.pathname.substr(location.pathname.lastIndexOf('/') + 1);

  // this api.get function is async
  api.get("merchantMessages", { "repl_str": merchant_id }).then((response) =>
  {
    this.loadMessages(response.data);
  }).catch(function (error)
  {
    console.log(error);
  });
}

Actually the async/await came with ES7 and it uses Promise in background for you.

async function MDN

To MDN example:

var resolveAfter2Seconds = function()
{
  console.log("starting slow promise");
  return new Promise(resolve => {
    setTimeout(function() {
      resolve("slow");
      console.log("slow promise is done");
    }, 2000);
  });
};

// and after that promise return async function, it will able to use as below

resolveAfter2Seconds()
  .then((result) =>
  {
    console.log('process has been finished');
  })
  .catch(() =>
  {
    console.log(error);
  });

// but we preferd to use that short one

// create async arrow function
const getData = async () =>
{
  // When we use the await operator, it has to wait until process have finished
  const getMyRemoteValue = await API.get(....);
  // to use await keyword, the parent function must be async function

  return getMyRemoteValue;
}

getData()
  .then((yourRemoteData) =>
  {
    console.log('Your remote data is ready to use: ', yourRemoteData);
  });

Also this answer might be helpful about scrolling around elements.

Upvotes: 0

Dacre Denny
Dacre Denny

Reputation: 30370

Consider making the following changes to your component:

  • Avoid interactions with the DOM via queries/getElementById, and instead work with element refs. You'll typically want to create refs in your components constructor via React.createRef()
  • To achieve the required scroll behaviour, consider controlling the "messages-container" element that "owns the scrollbar", rather than scrolling to a placeholder element at the end of your "messages-container" (ie 'bottom-scroll')
  • Call scrollToBottom() after your data has loaded, to account for the asynchronous nature of the data request (ie, call scrollToBottom() after you get a response from axios). In the case of your code, a good place to call scrollToBottom() would be in loadMessages()
  • Ensure that the updated data is present in the UI before invoking the scroll behaviour. You can ensure that data is present by calling scrollToBottom() in the callback of setState() (ie when the messages state data is updated)

In code, these changes could be implemented as shown below:

constructor(props) {
   super(props);

   /* 
   Create ref to messages-container in constructor 
   */
   this.containerRef = React.createRef();
}

componentDidMount() {
     this.pullMessages();

     /* Remove this, we'll instead do this in loadMessages() 
     this.scrollToBottom();
     */
}

loadMessages(data) {
    var count = 0;
    var messagevals = [];
    data.reverse().forEach(function (obj) {
        messagevals.push(generateMessage(obj, count, {}));
        count++;
    });

    /* After updating the message list with loaded data, cause the 
    messages-container to scroll to bottom. We do this via a call back
    passed to setState() for this state update */
    this.setState({ messages: messagevals, isLoading : false }, () => {

        this.scrollToBottom()
    });
}    

scrollToBottom = () => {

    const containerElement = this.containerRef.current;

    if(containerElement) {
        /* If container element exists, then scroll to bottom of
           container */
        containerElement.scrollTop = containerElement.scrollHeight;
    }
}

render() {

    if (this.state.isLoading) {
        return (<Loading />)
    }
    else {
        return (<div>
        {/* Add ref here */ }
        <div id="messages-container" ref={this.containerRef}>
            <div id="messages">
                { this.state.messages.map((message, index) =>
                <div className={ "message " + message.position} 
                     key={index}>{message.text}</div>
                }
                {/* 
                This can go:
                <div className="bottom-scroll" 
                     id="bottom-scroll" 
                     ref={(el)=> { this.messagesEndRef = el; }}>
                </div>
                */}
            </div>
        </div>
        </div>)
    }
}

Hope that helps!

Upvotes: 1

Related Questions