DEV Community

DmitryJima
DmitryJima

Posted on

Let's Build Animated Pagination with React!

Hello there! In this tutorial, we are going to build a universal "smart" (i.e. stateful) pagination React functional Component with transition effect, suitable for listing out dynamic on-page data that doesn’t need a separate URL: users, comments, products, etc. This can be a useful feature in administration panels and comment sections of your projects, when you have a relatively long list of data that you might need to filter, search, and change dynamically.


The final goal

If you need to have a URL for each page, I would suggest getting the best from your client-side routing library of choice, for instance react-router, @reach-router, or, in-case of static-site generators, you can look up a Gatsby or Next.js-specific solution.

Besides that, we are going to touch upon (pun intended) the browser touch events to flip our pages on mobile and tablet devices, examine one of the solutions for zero-dependency replayable animations in React, and see some use cases of refs, as well as stopPropagation() method of the React SyntheticEvent.

For convenience, I’ve created a small NPM module react-animated-pagination as well as a demo website for it that you can refer to and customize in your projects.

Prerequisites: intermediate JavaScript knowledge, a solid understanding of React and React Hooks. This post is aimed at fellow Reacters who are already familiar with the library's key features and might have already built some amazing projects, but want to see more examples on parts not so extensively covered in the official docs, like usage of refs and event propagation.

Problem and Objective Overview

In a React application, a typical use case for pagination is listing a set of items stored in the application's (or component’s) state. Usually, we need to iterate over an Array of JavaScript Objects, and create a React Component for each Object with a defined key prop and some itemName prop specific for the Component (post for PostComponent, user for UserCard, etc).

For instance, let's say we have fetched some posts from our API, and want display them as PostComponents that take specific handler methods as props:

const ContainerComponent = ({ posts, handleRemoveFromFeed, ... }) => {
...
  return (
    <div className="mainContainerDiv">
...
  {
    posts && posts.map(post => (
      <PostComponent>
        key={post.uniqueId}
        post={post}
        handleRemoveFromFeed={handleRemoveFromFeed}
        handleUpvote={handleUpvote}
        handleDownvote={handleDownvote}
        handleAddComment={handleAddComment}
      </PostComponent>
    ))
  }
...
Enter fullscreen mode Exit fullscreen mode

Now, our posts Array is listed alright, with all the handlers working correctly. We defined the key prop, so that the React Diffing Algorithm knows about the rendered elements (in case some of them gets removed from the state, with the handleRemoveFromFeed handler, for example), the value of the post prop is the value of the item in the current iteration of the Array.

But turns out, we got hundreds of posts we need to display! We'd better present them neatly in a "page-like" format. Adding basic pagination is not that complex, we can simply add some logic to our parent component with the useState and useEffect Hooks:

// This snippet is not a fully working example, 
// just an overview of one of the solutions
import React, { useState, useEffect } from "react";

const ContainerComponent = ({ posts, handleRemoveFromFeed, ... }) => {
...
  // The number of items to display
  const itemsPerPage = 5;
  // The Array of subarrays with posts divided according to the value of itemsPerPage
  const [pages, setPages] = useState([]);
  // The index of pages Array - this will be the current visible page
  const [currentPage, setCurrentPage] = useState(0);

  // A handler for changing the page
  const handlePageChange = (pageNo) => {
    setCurrentPage(pageNo);
  };

  // In the first useEffect Hook, we assemble and re-assemble
  // pages Array that contains several subArrays of our passed-in
  // posts Array, every time the posts prop changes 
  // (e.g. on post being removed)
  useEffect(() => {
    let workingPages = [];
    let workingPagesCurrentIndex = 0;

    if (posts) {
      posts.forEach(post => {
        if (
          workingPages[workingPagesCurrentIndex] &&
          workingPages[workingPagesCurrentIndex].length === itemsPerPage
        )
          workingPagesCurrentIndex++;

        if (workingPages[workingPagesCurrentIndex] === undefined)
          workingPages[workingPagesCurrentIndex] = [];

        workingPages[workingPagesCurrentIndex].push(item);
      });
    }

    setPages([...workingPages]);
  }, [posts, setPages]);

  // This Hooks runs every time when currentPage index changes 
  // withhandlePageChange() or when the number of pages changes
  // (for instance, when we delete an item)
  useEffect(() => {
    if (!pages[currentPage]) {
      setCurrentPage(currentPage - 1 > -1 ? currentPage - 1 : 0);
    }
  }, [currentPage, pages]);

  return (
    <div className="mainContainerDiv">
  ...
  {
  /*
  Buttons for handling changing the page
  */
  }
  <button 
    onClick={() => handlePageChange(currentPage - 1)}
    disabled={currentPage === 0 ? true : false}
  >
    Previous
  </button>
  <button 
    onClick={() => handlePageChange(currentPage + 1)}
    disabled={currentPage === pages.length - 1 ? true : false}
  >
    Next
  <button>
  {
  /*
  Iterate over the current subarray of posts to display them
  */
  }
  {
    pages.length && pages[currentPage] && pages[currentPage].map(post => (
      <PostComponent>
        key={post.uniqueId}
        post={post}
        handleRemoveFromFeed={handleRemoveFromFeed}
        handleUpvote={handleUpvote}
        handleDownvote={handleDownvote}
        handleComment={handleComment}
      </PostComponent>
    ))
  }
  ...
  );
}
Enter fullscreen mode Exit fullscreen mode

This logic provides us with a basic pagination solution: to divide our Array-to-iterate state into an Array of subarrays called pages, with a state (currentPage) that indicates the currently visible section of the Array.

Defined inside the ContainerComponent, even this crude solution looks pretty huge, and let's not forget that we have some much to add! We need to create buttons that indicate the current page and other available pages to navigate the pagination, and we'd also better to have both top and bottom navigation for convenience. We definitely need to create a separate Pagination Component in order to avoid a total mess!

Extracting this logic in a separate stateful Component is not hard: it will take the posts to iterate over as props, and will contain all the buttons, navigation and styles, easy! However, here comes the catch: how do we pass all the handlers and universal data our paginated components might require? Of course, if we use Redux, MobX, or some other State Management library, this is not a big deal: our Post Components can receive all their handlers and required data from the Application's main state. But not all the projects (even relatively large ones) need Redux, and even not all Redux-based projects have all their state lifted up to Redux. Moreover, our Pagination right now is tailored exclusively for rendering PostComponents, and what if we need to paginate some, say, UserCards or CommentComponents? Do they need to have a special version of the PaginationUserCard or PaginationCommentComponent? Definitely not.

We need to create a universal Pagination Component suitable for the majority of cases. It will take the React Component to render (UserCard, PostComponent, etc.) as props, as well as some parameters, like, how many items are allowed per page, and whether we need to show bottom and top navigation.

We will build a small TODO-ish application that fetches JSON data - an Array of posts - from the jsonplaceholder API, converts it into an array of JavaScript Objects to be stored in the component’s state, iterates through the array and displays each item as a React component, which can be deleted on double click. Instead of making an immense list, the items will be neatly paginated with a special Pagination component we’re going to implement, the number of pages will be dynamic, changing when we delete items. Going back and forth between pages will be visually emphasized with a transition effect and support swipes. We will also add a universal prop totalLikes required by all the paginated items.

Basic Setup

At this step, we will create the base of our application, that will be able to fetch an array of posts from the jsonplaceholder API, store it in the component’s state, display them on the page by creating instances of Post component, handle click on a Posts "like button", and delete a post on double click.

Create a new React project

npx create-react-app pagination-example
Enter fullscreen mode Exit fullscreen mode

And inside the src folder create a new file Post.js. Add the following code:

import React from "react";

const Post = ({ 
post, handleDelete, handleLikePost, totalLikes
}) => {

  return (
    <div
      className={`post`}
      // delete post with double-click on the post's container div
      onDoubleClick={(e) => {
        handleDelete(post.id);
      }}
    >
      <h3>{post.title}</h3>
      <p>{post.body}</p>
      {/* Show how many likes the post has */}
      <div>
        Likes: {post.likes ? post.likes : 0}{" "}
        {post.likes && totalLikes ? `out of ${totalLikes}` : ""}
      </div>
      <button
        className="post__likeBtn"
        // Like post on click
        onClick={(e) => {
          handleLikePost(post.id);
        }}
        // Avoid propagating the double click on the button 
        // so the post won't get deleted accidently 
        onDoubleClick={(e) => {
          e.stopPropagation();
        }}
      >
        <span role="img" aria-label="like button">
          💖
        </span>
      </button>
    </div>
  );
};

export default Post;

Enter fullscreen mode Exit fullscreen mode

The structure of the above component is typical: it’s a “dumb” stateless component that takes all the data it needs to display from the post prop and the actual handler for deleting the post is passed through the handleDelete prop.

The only unusual part might be this handler:

onDoubleClick={(e) => {
  e.stopPropagation();
}}
Enter fullscreen mode Exit fullscreen mode

Here we evoke a special method of React's synthetic event e.stopPropagation(), to avoid firing double-click event on the button's parent element. You've probably already encountered the e.preventDefault() method, usually implemented when submitting React-controlled forms with AJAX request, this one does roughly the same: overrides the default browser behavior. We will return to this method once we implement touch events in our Pagination.

Please note: here we use onDoubleClick event to handle delete logic for a mere demonstration and learning purpose. Putting something unobvious like this on double-click or double-tap might result in a horrifying user experience.

Let’s see our brand-new component in action. Open App.js file, delete all the initial code, and add the following:

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

import Post from "./Post";

export default function App() {
  // Loading indicator state
  const [isLoading, setIsLoading] = useState(false);
  // Posts state
  const [posts, setPosts] = useState([]);
  // Likes state
  const [totalLikes, setTotalLikes] = useState(0);

  // Handle delete post using Array.filter() higher order function
  const handleDelete = (id) => {

    // In a real-world application we would probably track the changing
    // number of likes with useEffect() Hook, but since we are in full
    // control over the data, we can directly set totalLikes here for simplicity
    let postTodelete = posts.find((p) => p.id === id);
    if (postTodelete.likes && totalLikes) {
      setTotalLikes((totalLikes) => totalLikes - postTodelete.likes);
    }

    // Note that we don't mutate the original state
    let postsFiltered = [...posts.filter((p) => p.id !== id)];

    setPosts((posts) => [...postsFiltered]);
  };

  // Once again, here we simplify the "liking logic" greatly
  const handleLikePost = (id) => {
    let workingPosts = [...posts];

    workingPosts.find((p) => p.id === id).likes
      ? workingPosts.find((p) => p.id === id).likes++
      : (workingPosts.find((p) => p.id === id).likes = 1);

    setPosts((posts) => [...workingPosts]);
    setTotalLikes((totalLikes) => totalLikes + 1);
  };

  // Fetch the data from API on the first render of the App
  useEffect(() => {
    const fetchPosts = async () => {
      try {
        setIsLoading(true);

        const posts = await fetch(
          "https://jsonplaceholder.typicode.com/posts",
          {
            method: "GET"
          }
        ).then((res) => res.json());

        setPosts([...posts]);
        setIsLoading(false);
      } catch (err) {
        console.log(err);
        setIsLoading(false);
      }
    };

    fetchPosts();
  }, []);

  // As soon is isLoading is equal to false and posts.length !== 0
  // we iterate over the huge Array of Objects to render Post components
  // on each iteration
  return (
    <div className="App">
      <h1>React Simple Pagination</h1>
      <h2>Basic setup</h2>
      <h3>Total Likes: {totalLikes ? totalLikes : 0}</h3>
      {isLoading && posts.length === 0 ? (
        <div>Loading...</div>
      ) : (
        posts &&
        posts.map((post) => (
          <Post
            key={post.id}
            post={post}
            handleDelete={handleDelete}
            handleLikePost={handleLikePost}
            totalLikes={totalLikes}
          />
        ))
      )}
    </div>
  );
}

Enter fullscreen mode Exit fullscreen mode

We defined a stateful App component, with the state being a posts Array, isLoading boolean, and totalLikes number. We defined a handler for deleting a post from the state array via the Array.filter() and also a handler for "liking" a post. Inside useEffect (with an empty array [] as the second parameter to run it only on the initial render), we defined and invoked asynchronous function fetchPosts() that sets the isLoading to true when the loading process starts, sets it to false when it finishes, and sets the posts to the response of the request. The function code is wrapped into a try...catch block. If you need a reference to fetching data with React Hooks, please, check out this wonderful article by Robin Wieruch.

In the return part we first check if our load has started, and display a “Loading...” message to the screen. Once isLoading is set to false, we iterate through the state array of posts with .map() method and "return" a Post component for each item of the array, passing the item itself as the post prop, .id property for its key, handleDelete and handleLikePost handlers for the respective prop.

Launch the project, and open the browser tab. Run the application with npm start, the result should look something like this:

Try and double click some posts to see them disappear, check if the "liking" logic functions correctly.

Everything’s working, but our page is inappropriately huge! Let’s fix this by implementing the Pagination component.

Building Pagination component

Create a new file called Pagination.js that will export the Pagination component, and the pagination.css file containing all the styles needed by the Pagination.

In Pagination.js add the following code:

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

import "./pagination.css";

const Pagination = ({
  items,
  itemsOnPage,
  entryProp,
  children
}) => {
  // This will be our state for handling paginated items
  const [pages, setPages] = useState([]);
  // This will hold the currently visible part of the paginated items
  const [currentPage, setCurrentPage] = useState(0);

  // A simple handler for setting the currently visible page
  const handlePageChange = (pageNo) => {
    setCurrentPage(pageNo);
  };

  // Here we re-assembly the pages state on the first render and 
  // every the length of items array or itemsOnPage number change
  useEffect(() => {
    let itemsPerPage = itemsOnPage ? itemsOnPage : 5;
    let workingPages = [];
    let workingPagesCurrentIndex = 0;

    if (items) {
      items.forEach((item) => {
        if (
          workingPages[workingPagesCurrentIndex] &&
          workingPages[workingPagesCurrentIndex].length === itemsPerPage
        )
          workingPagesCurrentIndex++;

        if (workingPages[workingPagesCurrentIndex] === undefined)
          workingPages[workingPagesCurrentIndex] = [];

        workingPages[workingPagesCurrentIndex].push(item);
      });
    }

    setPages([...workingPages]);
  }, [items, itemsOnPage, setPages]);

  // If we were on a page that no longer exists,
  // "redirect" to the previous page
  useEffect(() => {
    if (!pages[currentPage]) {
      setCurrentPage(currentPage - 1 > -1 ? currentPage - 1 : 0);
    }
  }, [currentPage, pages]);

  return (
    <div className="pagintaion__paginationContainer">
    </div>
  );
}

export default Pagination;
Enter fullscreen mode Exit fullscreen mode

Before we jump into the return part, let's recap the code above: basically, it is the same logic we've discussed in the "Problem and Objective" part. We have some state that will hold paginated data, it's pretty dynamic since it is re-assembled in useEffect(), the handlePageChange() handler's logic is pretty straightforward, as well.

What makes it different, is the entryPropin the Component's arguments' list. This one will allow us to make the component universal and suitable for many use cases. Let's have a look at how it works!

In the Pagination's return statement add the following code:

<div className="pagintaion__paginationContainer">
  {/* 
    Here we define basic controls for our pagination: first amd previous buttons,
    the numbered buttons with active classname, and next and last buttons.
    You can use any icons 
    */}
  <div className="paginationContainer__topNavControls paginationControls">
      <button
        className="paginationControls__arrowBtn"
        onClick={() => handlePageChange(0)}
        disabled={currentPage === 0 ? true : false}
      >
      First
      </button>
      <button
        className="paginationControls__arrowBtn"
        onClick={() => handlePageChange(currentPage - 1)}
        disabled={currentPage === 0 ? true : false}
      >
      Prev
      </button>
      {/* 
        Here we iterate over the pages to render the numbered buttons
        The logic is pretty straightforward, here we use string literals
        and inidices to enumerate the buttons and also to hide some buttons
        if there are too many of them
      */}
      {pages &&
        pages.map((page, index) => (
          <button
            className={`paginationContols__pageNoBtn
                    ${
                      index === currentPage
                        ? "paginationContols__pageNoBtn--active"
                        : ""
                    }
                    ${
                      pages.length > 10 &&
                      index !== 0 &&
                      index !== pages.length - 1 &&
                      (currentPage > index
                        ? currentPage - index > 3
                        : index - currentPage > 3)
                        ? "paginationContols__pageNoBtn--hidden"
                        : ""
                    }
                    ${
                      pages.length > 10 &&
                      index !== 0 &&
                      index !== pages.length - 1 &&
                      currentPage > index &&
                      currentPage - index === 3
                        ? "paginationContols__pageNoBtn--dotsBefore"
                        : ""
                    }
                    ${
                      pages.length > 10 &&
                      index !== 0 &&
                      index !== pages.length - 1 &&
                      index > currentPage &&
                      index - currentPage === 3
                        ? "paginationContols__pageNoBtn--dotsAfter"
                        : ""
                    }
                    `}
            key={index}
            onClick={() => handlePageChange(index)}
            disabled={index === currentPage}
          >
            {index + 1}
          </button>
        ))}
      <button
        className="paginationControls__arrowBtn"
        onClick={() => handlePageChange(currentPage + 1)}
        disabled={currentPage === pages.length - 1 ? true : false}
      >
        Next
      </button>
      <button
        className="paginationControls__arrowBtn"
        onClick={() => handlePageChange(pages.length - 1)}
        disabled={currentPage === pages.length - 1 ? true : false}
      >
        Last
      </button>
    </div>
  {/* 
    Here comes the main catch for making our component universal:
    instead of directly passing the children Component to render, 
    we *clone* it with handler props passed from the Parent, while
    the actual "meat" of the component is passed here
  */}
  <div className={`paginationContainer__currentPageDiv`}>
    {pages.length &&
      pages[currentPage] &&
      pages[currentPage].map((item, index) => {
        let objectToClone = {};
        objectToClone[entryProp] = item;
        return (
          <Fragment key={item.id ? item.id : index}>
            {React.cloneElement(children, objectToClone)}
          </Fragment>
        );
      })}
  </div>
</div>
Enter fullscreen mode Exit fullscreen mode

In the code above, we iterate through the pages and render control buttons, as well as the paginationContainer__currentPageDiv div. Here our component becomes universal: we use a special React method React.cloneElement() that allows us to merge the passed-in Children Component (e.g. our Post component) with the current Object in iteration, in which we assign one of the properties to the value of item in iteration. We can name this Object's property with the required prop name using the bracket notation property accessor to which we pass the entryProp string.

objectToClone[entryProp] = item;
Enter fullscreen mode Exit fullscreen mode

This little technique allows the Pagination Component to be used with virtually any Component, if it has an "entry point", hence the entryProp name.

Let's add some styling to the pagination.css

.paginationControls {
  display: flex;
  flex-direction: row;

  flex-wrap: wrap;

  margin-left: auto;
  margin-right: auto;
  justify-content: center;
}

.paginationContols__pageNoBtn {
  display: block;

  background: transparent;
  border: transparent;

  min-width: 2em;

  cursor: pointer;
}
.paginationContols__pageNoBtn--active {
  border: 1px blue solid;
}
.paginationContols__pageNoBtn--hidden {
  display: none;
}
.paginationContols__pageNoBtn--dotsAfter::after {
  content: " ... ";
  color: black;
}
.paginationContols__pageNoBtn--dotsBefore::before {
  content: " ... ";
  color: black;
}

.paginationControls__arrowBtn {
  display: block;

  background: transparent;
  border: transparent;

  cursor: pointer;
}
Enter fullscreen mode Exit fullscreen mode

and implement our Pagination in the App Component. Rewrite the App.js in the following way:

...
import Pagination from "./Pagination";
...
export default function App() {
...
  return (
    <div className="App">
      <h1>React Simple Pagination</h1>
      <h2>This pagination is dynamic</h2>
      <h3>Total Likes: {totalLikes ? totalLikes : 0}</h3>
      {
      isLoading && posts.length === 0 
      ? (
        <div>Loading...</div>
      ) 
      : (
        <Pagination
          itemsOnPage={5}
          items={posts}
          entryProp="post"
          children={
            <Post
              handleDelete={handleDelete}
              handleLikePost={handleLikePost}
              totalLikes={totalLikes}
            />
          }
        />
      )
      }
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Instead of iterating over the posts Array, we pass it to our brand-new Pagination Component. The entryProp is "post", and we pass Post as the children prop with all other props directly passed here, except for the entry one.

Let's test our pagination! The result should be something like this:

Hey-ya! It works, but looks slightly dull. Give us some action!

Adding Page Transitions

Jokes aside, animations can be a really important part of the UX. Not only it helps developers to flex their front-end skills, but it is also an important tool for telling the end-user what is going on. Without any transitions, our Pagination might provoke some headaches instead of providing convenience.

There is a ton of ways to make animations with React, and many of them rely heavily on external libraries. But for something as simple as making some page transitions, it is definitely not a must. Let's add some transitions with the help of CSS3 Animations, React refs and Animation events.

Add the following code to the pagination.css file, those are some really basic transition animations:

@keyframes nextPage {
  0% {
    opacity: 0;
    transform: translate(10em, 0);
  }

  100% {
    opacity: 1;
    transform: translate(0, 0);
  }
}

@keyframes prevPage {
  0% {
    opacity: 0;
    transform: translate(-10em, 0);
  }

  100% {
    opacity: 1;
    transform: translate(0, 0);
  }
}
Enter fullscreen mode Exit fullscreen mode

Now, let's return to the Pagination.js. Import useRef Hook

import React, { useState, useEffect, Fragment, useRef } from "react";
Enter fullscreen mode Exit fullscreen mode

Refs are sometimes called an "escape hatch" in React applications. The "React way" of building software is declarative: the library abstracts a huge portion of written code when developing complex applications, and gives us an opportunity to reason about different parts of it in a component-scoped way, without the need to write lines and lines of document.createElement(...)'s. In order to make the application work predictably, direct DOM tree manipulation should be avoided. However, sometimes we still need to access an HTML element directly, imperatively. Here come the ref that help us to avoid an absolute mess with random attempts to document.getElementById().

In the Pagination component, add the following code and rewrite the handlePageChange handler as follows:

const Pagination = ({...}) =>
...
  let currentPageRef = useRef(null);

  const handlePageChange = (pageNo) => {
    if (currentPage > pageNo) {
      currentPageRef.current.style.animation = "prevPage .5s forwards";
    } else {
      currentPageRef.current.style.animation = "nextPage .5s forwards";
    }
    setCurrentPage(pageNo);
  };
...
      <div
        className={`paginationContainer__currentPageDiv`}
        ref={currentPageRef}
...
Enter fullscreen mode Exit fullscreen mode

We created a new ref called currentPageRef, and made it a ref of the paginationContainer__currentPageDiv div, the actual element will now be available at currentPageRef.current property. We added some logic to the handler, so we can add our animation from the stylesheet in different cases.

However, if we try this code out right now, it will disappoint us: the animation runs only once when flipping through several next or previous pages. We need a way to "unplug" the animation and then add it once again.

There are several ways to re-run CSS3 animations with React, some of them might be a little hacky and not so reliable (like, for instance, using myRef.current.dashOffset statement to signal React that something has changed), so it might be better to use one of the React's animation events: onAnimationEnd() that will fire as soon as the element's animation finishes.

Add the following handler to the paginationContainer__currentPageDiv:

...
      <div
        className={`paginationContainer__currentPageDiv`}
        ref={currentPageRef}
        onAnimationEnd={() => {
          if (currentPageRef.current) {
            currentPageRef.current.style.animation = "";
          }
        }}
...
Enter fullscreen mode Exit fullscreen mode

And test our application once again. The result should be:

Now we're talking! Please also note that I've added some Bootstrap SVG icons for the pagination control buttons. You can add these icons to your project, just copy and paste them from the sandbox, or you can make some of your own!

Swipe It!

In the mobile-first age, our Pagination definitely better have some touch support! Let's make it swipeable with Touch events.

In the Pagination component's body add the following code:

  // Touch event handling
  // This will signal that the page is being swiped
  const [isDragging, setIsDragging] = useState(false);
  // This will be the point of the initial touch
  const [initialTouch, setInitialTouch] = useState(0);
  // These positions are needed to determine whether to move the page or not,
  // as well as to decide of the page should be flipped
  const [posLeft, setPosLeft] = useState(0);
  const [prevLeft, setPrevLeft] = useState(0);

  // This object will hold the current page container's style
  const divStyle = {
    position: isDragging ? "relative" : "static",
    left: isDragging ? posLeft : 0
  };

  // onTouchStart we signal our container to become position: relative, so that
  // the left property affects its position
  // We also set the initialTouch state and the prevLeft state
  const _onTouchStart = (event) => {
    setIsDragging(true);
    setInitialTouch(event.nativeEvent.touches[0].clientX);

    const { left } = extractPositionDelta(event.nativeEvent.touches[0]);

    if (posLeft + left <= 0) {
      setPosLeft(posLeft + left);
    }
  };

  // Here we decide if the page should be moved, 30 might be a good balance 
  // between too stiff and too sensitive
  const _onTouchMove = (event) => {
    if (!isDragging) {
      return;
    }
    const { left } = extractPositionDelta(event.nativeEvent.touches[0]);

    if (Math.abs(posLeft) + Math.abs(left) > 30) {
      setPosLeft(posLeft + left);
    }
  };

  // When the use removes finger from the screen, we need to determine if 
  // his or her intention was to flip the page; once again, 30 works well
  // In the end we set our state to the initial values
  const _onTouchEnd = (event) => {
    setIsDragging(false);

    let delta = Math.abs(prevLeft) - Math.abs(posLeft);

    if (delta < -30 && posLeft < initialTouch) {
      if (pages[currentPage + 1]) handlePageChange(currentPage + 1);
    } else if (delta > 30 && posLeft > initialTouch) {
      if (pages[currentPage - 1]) handlePageChange(currentPage - 1);
    }

    setPosLeft(0);
    setPrevLeft(0);
    setInitialTouch(0);
  };

  const extractPositionDelta = (event) => {
    const left = event.clientX;

    const delta = {
      left: left - prevLeft
    };

    setPrevLeft(left);

    return delta;
  };
Enter fullscreen mode Exit fullscreen mode

Here are our handlers for Touch events. Let's add them to the container div:

      <div
        ref={currentPageRef}
        className={`paginationContainer__currentPageDiv`}
        onAnimationEnd={() => {
          if (currentPageRef.current) {
            currentPageRef.current.style.animation = "";
          }
        }}
        style={divStyle}
        onTouchStart={_onTouchStart}
        onTouchMove={_onTouchMove}
        onTouchEnd={_onTouchEnd}
        onTouchCancel={_onTouchEnd}
      >
Enter fullscreen mode Exit fullscreen mode

Now our Pagination can be swiped! Try it out on a mobile screen or in the developer tools simulation.

Our Pagination is all fancy, but our Post is kinda lame. Moreover, deleting a post behaves weirdly on a touch screen now! As a little bonus, let's cheer it up, and add some animations to a Post leaving the state!

Crate a file post.css and add the following code:

.post {
  transition: 0.3s ease-in-out;
}
.post__likeBtn {
  display: block;
  margin-left: auto;
  margin-right: auto;
  margin-top: 0.5em;

  height: 3em;
  width: 3em;

  cursor: pointer;
}

.post--deleting--left {
  animation: postDeletedLeft 0.5s forwards;
}

@keyframes postDeletedLeft {
  0% {
    opacity: 1;
    transform: translate(0, 0);
  }
  100% {
    opacity: 0;
    transform: translate(-10em, 0);
    display: none;
  }
}
Enter fullscreen mode Exit fullscreen mode

Here we defined some basic animation for our post being deleted. Now, rewrite Post.js in the following way:

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

// import CSS
import "./post.css";

const Post = ({ post, handleDelete, handleLikePost, totalLikes }) => {
  // Now, onClick we will signal the post that it is being deleted,
  // instead of invoking handleDelete() directly
  const [isDeleting, setIsDeleting] = useState(false);

  // We will need a ref to a timeout so that our component works correctly
  let timeoutRef = useRef(null);

  // This will be the handler on the double-click
  const deletePost = () => {
    setIsDeleting(true);
  };

  // This will be the handler on touch screens
  // We use e.stopPropagation(); to avoid messing app the pagination's
  // Touch event logic
  // Delete on double tap
  const [firstTap, setFirstTap] = useState("");
  let touchRef = useRef(null);

  const _onTouchEnd = (e) => {
    if (!firstTap) {
      setFirstTap(new Date().getTime());
      timeoutRef.current = setTimeout(() => {
        setFirstTap("");
      }, 200);
      return;
    } else if (firstTap && timeoutRef.current) {
      e.stopPropagation();
      setFirstTap("");
      setIsDeleting(true);
    }
  };

  // Here we use the timeoutRef to delete the post after the animation runs
  useEffect(() => {
    if (isDeleting) {
      timeoutRef.current = setTimeout(() => handleDelete(post.id), 500);
    }
  }, [isDeleting]);

  // Unmount cleanup to avoid memory leaks
  useEffect(() => () => clearTimeout(timeoutRef.current), []);

  useEffect(() => clearTimeout(touchRef.current), []);

  return (
    <div
      // xchange the className to run the animation
      className={`post ${isDeleting ? `post--deleting--left` : ""}`}
      // this one for mouse double-click
      onDoubleClick={() => {
        deletePost();
      }}
      // this one for touch screen double-tap
      onTouchEnd={(e) => _onTouchEnd(e)}
    >
      <h3>{post.title}</h3>
      <p>{post.body}</p>
      <div>
        Likes: {post.likes ? post.likes : 0}{" "}
        {post.likes && totalLikes ? `out of ${totalLikes}` : ""}
      </div>
      <button
        className="post__likeBtn"
        onClick={(e) => {
          handleLikePost(post.id);
        }}
        // run e.stopPropagation(); to avoid firing delete event
        onDoubleClick={(e) => {
          e.stopPropagation();
        }}
        onTouchEnd={(e) => {
          e.stopPropagation();
        }}
      >
        <span role="img" aria-label="like button">
          💖
        </span>
      </button>
    </div>
  );
};

export default Post;
Enter fullscreen mode Exit fullscreen mode

Now check out the browser, it should be something like this:

Excellent! Our pagination is working! You can also check out some additional parameters in the sandbox, like topNav and bottomNav that determine top and bottom controls respectively.

Conclusion

Great! It certainly was not a short tutorial, but I hope it was productive: we've touched upon several React techniques which are not that widely covered, but can be quite useful in the real-world applications. We've seen Touch events in React in action, learned one of the usages of the onAnimationEnd(), and saw how refs can help us to escape the declarative React code to achieve some cool effects. And, the last but not least, we've built a dynamic pagination component that you can use in your projects.

If you need inspiration, you can check out some pagination examples I've made for the NPM module, all of them have their source code in the example directory in the GitHub repo.

Hope you've enjoyed this tutorial, would really appreciate hearing from you!

Have a good one!

Top comments (0)