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 PostComponent
s 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>
))
}
...
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>
))
}
...
);
}
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 PostComponent
s, and what if we need to paginate some, say, UserCard
s or CommentComponent
s? 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 Post
s "like button", and delete a post on double click.
Create a new React project
npx create-react-app pagination-example
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;
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();
}}
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>
);
}
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;
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 entryProp
in 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>
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;
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;
}
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>
);
}
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);
}
}
Now, let's return to the Pagination.js
. Import useRef
Hook
import React, { useState, useEffect, Fragment, useRef } from "react";
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}
...
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 = "";
}
}}
...
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;
};
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}
>
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;
}
}
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;
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)