⭐ Prepare for a JavaScript interview fast with my new book!Learn more →

Pull to refresh from scratch as a React hook

The "pull to refresh" is a ubiquitous mobile UI pattern where a user pulls the page down and triggers the page refresh.

It's particularly convenient on mobile because it can be done with your thumb with just one hand.

Pull to refresh (Reddit iOS app)

The scenario is similar in most of the apps.

  1. You start pulling, and a small indicator appears at the top of the screen
  2. After some threshold (typically a small one, like 100px), the indicator somehow changes, meaning it can be released
  3. Releasing makes the page return to its initial position. Typically it will stay at the threshold level, and then the indicator starts spinning - the data is loading
  4. Finally, the page returns to its initial position and gets updated with the new data

Getting started

We are going to implement a React hook usePullRefresh which is supposed to be used like this:

const state = usePullToRefresh(ref, onTrigger)

Where ref is the page element to be pulled, and onTrigger is a callback to be called.

In our implementation, we're going to listen to touch events on the page element, and the whole logic will live inside those event handlers.

function usePullToRefresh(
  ref: React.RefObject<HTMLDivElement>,
  onTrigger: () => void
) {
  useEffect(() => {
    const el = ref.current;
    if (!el) return;

    // attach the event listener
    el.addEventListener("touchstart", handleTouchStart);

    function handleTouchStart(startEvent: TouchEvent) {
      // logic goes here
    }

    return () => {
      // don't forget to cleanup
      el.removeEventListener("touchstart", handleTouchStart);
    };
  }, [ref.current]);
}

CSS transform is our best friend. To make the page move, we will track the user's movement during touchmove and then update the transform of the page accordingly.

function usePullToRefresh(
  ref: React.RefObject<HTMLDivElement>,
  onTrigger: () => void
) {
  useEffect(() => {
    const el = ref.current;
    if (!el) return;

    // attach the event listener
    el.addEventListener("touchstart", handleTouchStart);

    function handleTouchStart(startEvent: TouchEvent) {
      const el = ref.current;
      if (!el) return;

      // get the initial Y position
      const initialY = startEvent.touches[0].clientY;

      el.addEventListener("touchmove", handleTouchMove);
      el.addEventListener("touchend", handleTouchEnd);

      function handleTouchMove(moveEvent: TouchEvent) {
        const el = ref.current;
        if (!el) return;

        // get the current Y position
        const currentY = moveEvent.touches[0].clientY;

        // get the difference
        const dy = currentY - initialY;

        // update the element's transform
        el.style.transform = `translateY(${dy}px)`;
      }

      function handleTouchEnd() {
        const el = ref.current;
        if (!el) return;

        // cleanup
        el.removeEventListener("touchmove", handleTouchMove);
        el.removeEventListener("touchend", handleTouchEnd);
      }
    }

    return () => {
      // let's not forget to cleanup
      el.removeEventListener("touchstart", handleTouchStart);
    };
  }, [ref.current]);
}

Moving the div with my finger

Making it slide back

Two things stand out immediately - moving the page up shouldn't be allowed. We are pulling down, after all.

And secondly, when we leave the page be, it should return to its initial position (and do it smoothly). We can do it by resetting the transform to zero at touchend.

To make it return smoothly, we can add a transition. e.g., transition: transform 0.4s ease-in-out.

But there's a catch. The transition can't always be applied to the page because it breaks the pulling (you can try it - it's a mess). This is why we will add transition at touchend, and then we will remove it at transitionend event.

function handleTouchEnd() {
  const el = ref.current;
  if (!el) return;

  // return the element to its initial position
  el.style.transform = "translateY(0)";

  // add transition
  el.style.transition = "transform 0.2s";

  // listen for transition end event
  el.addEventListener("transitionend", onTransitionEnd);

  // cleanup
  el.removeEventListener("touchmove", handleTouchMove);
  el.removeEventListener("touchend", handleTouchEnd);
}

function onTransitionEnd() {
  const el = ref.current;
  if (!el) return;

  // remove transition
  el.style.transition = "";

  // cleanup
  el.removeEventListener("transitionend", onTransitionEnd);
}

Page smoothly returns to its initial position

Lovely!

Adding tension

One thing that bothers me is that I don't want the user to pull the page down as much as he wants. In real apps, it usually behaves as if there is an invisible tension (like a spring) that makes it harder to pull.

We can make it work that way if we replace the translateY(${dy}px) with some function f(dy). It should equal dy for small dy, but for bigger values, it should accent to some value (say, 128px), no matter how far you pull.

function handleTouchMove(moveEvent: TouchEvent) {
  const el = ref.current;
  if (!el) return;

  // get the current Y position
  const currentY = moveEvent.touches[0].clientY;

  // get the difference
  const dy = currentY - initialY;

  if (dy < 0) return;

  // now we are using the `appr` function
  el.style.transform = `translateY(${appr(dy)}px)`;
}

// more code

const MAX = 128;
const k = 0.4;
function appr(x: number) {
  return MAX * (1 - Math.exp((-k * x) / MAX));
}

We can change how fast y(x) approaches the MAX value by varying k.

Here are some of the values on a chart and how they compare to y = x.

Function behaviour depending on values of k

I find 0.4 to be a nice spot.

Now it's impossible to pull the page too much down

OK, the next logical step is to add an indicator.

Adding the indicator

The simplest indicator is a simple arrow. It should become visible once a certain threshold is reached, and when pulled sufficiently, the arrow should flip, signaling to the user that it can be let go.

if (dy > TRIGGER_THRESHOLD) { // 100px
  // flip the arrow
} else if (dy > SHOW_INDICATOR_THRESHOLD) { // 50px
  // add the arrow
} else {
  // remove the arrow
}

We are going to add the arrow indicator to the parent element, because, we don't want it to move together with the page. Adding and removing the indicator is quite easy, here's the actual implementation.

const parentEl = el.parentNode as HTMLDivElement;
if (dy > TRIGGER_THRESHOLD) {
    flipArrow(parentEl);
} else if (dy > SHOW_INDICATOR_THRESHOLD) {
    addPullIndicator(parentEl);
} else {
    removePullIndicator(parentEl);
}

// ...

function addPullIndicator(el: HTMLDivElement) {
  const indicator = el.querySelector(".pull-indicator");
  if (indicator) {
    // already added

    // make sure the arrow is not flipped
    if (indicator.classList.contains("flip")) {
      indicator.classList.remove("flip");
    }
    return;
  }

  const pullIndicator = document.createElement("div");
  pullIndicator.className = "pull-indicator";
  pullIndicator.innerHTML = "<i class='fa-solid fa-arrow-down'></i>";
  el.appendChild(pullIndicator);
}

function removePullIndicator(el: HTMLDivElement) {
  const pullIndicator = el.querySelector(".pull-indicator");
  if (pullIndicator) {
    pullIndicator.remove();
  }
}

function flipArrow(el: HTMLDivElement) {
  const pullIndicator = el.querySelector(".pull-indicator");
  if (pullIndicator && !pullIndicator.classList.contains("flip")) {
    pullIndicator.classList.add("flip");
  }
}

The CSS for the indicator can look somewhat like this.

.pull-indicator {
  position: absolute;
  top: 16px;
  left: 0;
  width: 100%;
  background-color: transparent;
  display: flex;
  align-items: center;
  justify-content: center;
  color: var(--color-text-lighter);
  transition: transform 0.2s ease-in-out;
  z-index: 10;
}

.pull-indicator i {
  transition: transform 0.2s ease-in-out;
}

.pull-indicator.flip i {
  transform: rotate(180deg);
}

The important part is the transform: rotate(180deg), which makes the arrow flip.

Out pull-to-refresh hook with the indicator

Finally, we want to run the callback as soon as the page is released (and only if the threshold is passed).

Let's update the handleTouchEnd:

function handleTouchEnd(endEvent: TouchEvent) {
  const el = ref.current;
  if (!el) return;

  // return the element to its initial position
  el.style.transform = "translateY(0)";
  removePullIndicator(el.parentNode as HTMLDivElement);

  // add transition
  el.style.transition = "transform 0.2s";

  // run the callback
  const y = endEvent.changedTouches[0].clientY;
  const dy = y - initialY;
  if (dy > TRIGGER_THRESHOLD) {
    onTrigger();
  }

  // listen for transition end event
  el.addEventListener("transitionend", onTransitionEnd);

  // cleanup
  el.removeEventListener("touchmove", handleTouchMove);
  el.removeEventListener("touchend", handleTouchEnd);
}

Note how we used changedTouches, instead of touches on the event. The touch event is fired after the page is released, so the touches array is empty.

What's next?

Awesome, we made it!

Our pull-to-refresh hook has some room for improvement.

For one thing, we might wanna change the UI to work like on the first example from the reddit app - refreshing the page changes the indicator to a spinner.

The implementation out there don't use hooks you might wanna try or learn from:

The GIF examples come from my pet project, an RSS reader app JustFeed.

🔥 100+ questions with answers
🔥 50+ exercises with solutions
🔥 ECMAScript 2023
🔥 PDF & ePUB