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.
The scenario is similar in most of the apps.
- You start pulling, and a small indicator appears at the top of the screen
- After some threshold (typically a small one, like 100px), the indicator somehow changes, meaning it can be released
- 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
- 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]);
}
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);
}
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
.
I find 0.4
to be a nice spot.
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.
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:
- https://github.com/bryaneaton13/react-pull-to-refresh
- https://github.com/thmsgbrt/react-simple-pull-to-refresh
The GIF examples come from my pet project, an RSS reader app JustFeed.