Building a sticky chatbox


by Sam Selikoff

Building a sticky chatbox

Last month I hosted Ember NYC's project night, and the audience and I built a sticky chatbox component together. My goal wasn't to end up with a prefabricated solution for everyone to use; instead, I wanted to work through the problem as a group, discussing our thought process and opinions as we went along.

I built this component last summer as part of a client prototype and found it an interesting and fun challenge. It looks like this:

Normally, when a scrollable <div> gets new content, its scrollbar is unaffected. You can see on the left that to keep reading, the user must scroll the chatbox each time a new message comes in.

The sticky chatbox on the right is different. When the user is scrolled to the bottom, new messages appear and "push" the chat log up, so the user doesn't have to keep scrolling. But, if the user scrolls back up to read through older messages, the chat box doesn't snap to the bottom. The scrollbar stays in place, so the user isn't interrupted while reading through the log. This is the behavior found in most modern chat apps like Slack and Twitch.

My approach

When writing complex components, I like to start by identifying the various states in which the component can exist. Identifying state can be tricky; sometimes I find it helpful to try to explain how the interface should behave as if I were talking to a non-technical business or product person. How might we talk about this component together?

When the user is scrolled to the bottom, new messages should show up. If they scroll up to read old messages, the chat should stay still.

From this plain-English description, the states of the component jump out at us:

  1. The user is scrolled to the bottom
  2. The user is not scrolled to the bottom

Of course, we can think of other states in which the component could exist  --  for example, if the user was scrolled to the top  -- but those states aren't relevant here, since they don't affect behavior. The only states that affect behavior are the two we've listed.

Given these possible states, I gave my component an isScrolledToBottom boolean property that I could use to adjust the component's scrolling behavior. I then needed to update this property every time the state of the component changed.

How might I achieve this? The first thing that came to mind was an addon I had used in previous projects: DockYard's Ember In Viewport. This addon lets you render a component to the screen that fires an action whenever that component enters or exits the viewport.

Sounds like just what I needed. If I rendered this component at the end of the chat list, I'd then be able to know whenever the user reached the bottom, and set the state accordingly. If they started scrolling up to read old messages, the component would leave the viewport, and I'd be able to use another action to update the state.

So, I wrote a simple {{in-viewport}} component using the mixin from the addon. You can see the full implementation of that component in the Twiddle below. I then used it in my component's template:

<!-- chat-box.hbs -->
<ul>
  {{#each messages as |message|}}
    <li>{{message.text}}</li> 
  {{/each}}

  {{in-viewport
    did-enter=(action (mut isScrolledToBottom) true)
    did-exit=(action (mut isScrolledToBottom) false)}}
</ul>

All that remained was to write the component's behavior. If the user was scrolled to the bottom, the component's <ul> should scroll down each time a new message was rendered. The scrolling should happen after the new message was appended to the DOM — sounds like a perfect use case for the didRender hook:

// chat-box.js
import Ember from 'ember';

export default Ember.Component.extend({

  didRender() {
    this._super(...arguments);

    if (this.get('isScrolledToBottom')) {
      this.$('ul')[0].scrollTop = this.$('ul')[0].scrollHeight;
    }
  }
});

Et voilà! Our chat box lets the user read through the backlog, and then autoscrolls when they're all caught up.

Check out this Twiddle for a full working example: https://ember-twiddle.com/92b9e154ed4b8dc554727bf794da88f6?openFiles=templates.components.chat-box.hbs%2C

The group's approach

To my delight, several members of the group from the project night suggested a completely different strategy. The idea was simple: check the state of the scrollbar the moment a new message arrives. If the scrollbar was at the bottom, autoscroll the chatbox; otherwise, leave it alone.

We still needed to store the state of the scrollbar, so we kept the isScrolledToBottom property; but now, we needed to set this property whenever the component was about to re-render.

It took a bit of experimentation. We started out by trying to calculate the scroll position at the beginning of the didRender hook. The problem here is that in didRender, the chatbox had already been updated  --  so even if the user had been scrolled to the bottom, the fact that the new message had already been appended meant they no longer were.

Eventually we realized that we needed to calculate the scroll position just before the new message was added to the DOM. We pulled up the guides for a component's re-render lifecycle hooks:

Both willUpdate and willRender seemed like good candidates. Looking at the documentation for each, we found that willRender is called on both initial render and re-renders, while willUpdate is only called on re-renders. Since we only cared about new messages, we went with willUpdate.

After a little more experimentation, we were able to write a formula to calculate the state of the scrollbar. We then used this formula to set the component's state in willUpdate:

import Ember from 'ember';

export default Ember.Component.extend({

  willUpdate() {
    this._super(...arguments);

    let box = this.$('ul')[0];
    let isScrolledToBottom = box.scrollTop + box.clientHeight === box.scrollHeight;

    this.set('isScrolledToBottom', isScrolledToBottom);
  },

  didRender() {
    this._super(...arguments);

    if (this.get('isScrolledToBottom')) {
      this.$('ul')[0].scrollTop = this.$('ul')[0].scrollHeight;
    }
  }
});

Now the state would be correct even after the new messages were appended, so the code in didRender worked just as before. Cool!

Here's the Twiddle: https://ember-twiddle.com/bfca7855e100560b62651f0cb7673963?openFiles=components.chat-box.js%2C

Tradeoffs

After going through both solutions, Luke Melia pointed out that spying on scroll behavior is quite expensive (which is why the Ember In Viewport addon makes you explicitly opt-in to this behavior). He said that using the first approach could significantly affect performance, especially on mobile. In many cases, then, the willUpdate solution would be the superior choice.

For our demo app, the willUpdate solution was sufficient — the only time we used the isScrolledToBottom property was when re-rendering the list. If you open the Twiddle, however, you'll notice that the state of our component can "lie":

If you scroll the chatbox after a new message has been rendered, you'll notice that the isScrolledToBottom property won't change right away; in fact, it won't update to reflect the "true" state of the scrollbar until the next message arrives.

If we were to add additional behavior to this component that relied on isScrolledToBottom being accurate, we could run into some issues. How might this happen? You could imagine updating the interface to show an indicator that new messages had arrived. You'd want that indicator to clear once the user had read through all the messages. In this case, there could be a long time between when the user had caught up and when the next message arrived, so the interface could fall "out of sync" with the actual state of the user's behavior.

This is just one example of something that could affect your decision. Different approaches often favor competing goals, like performance versus accuracy. It's up to you to decide which strategy is most appropriate based on the unique priorities and needs of your application.

Conclusion

Building the sticky chatbox as a group helped us all see the problem with a bit more clarity. We learned:

  • Spying on viewport scrolling is expensive, and should only be done when necessary.
  • The willRender and willUpdate hooks are a great place to take measurements or perform visual calculations on a component's DOM before Ember re-renders it.
  • The didRender hook is useful if you need to update a component's DOM in response to a re-render, for example after the component receives new attrs.
  • Nearly all design decisions have tradeoffs.

So reference the API docs often, keep pairing, and if you're in New York be sure to join us at Ember NYC's next Project Night!

Questions?

Send us a tweet:

Or ask us in Inside EmberMap, our private Slack workspace for subscribers.