In this post join me as I create a React Custom Hook which will encapsulate the logic behind a simple Pagination component.
A Pagination component is a component which lets the users navigate between content “pages”. The users can move up and down the list of pages but also have the ability to go directly to a page they desire, something of this sort:
(Image taken from material UI)
I’m starting from the list of requirements for this hook:
- It should receive a total pages number
- It can receive and initial cursor, but if it didn’t the initial cursor is the first index
- It should return the following:
- The total pages count
- The current cursor position
- A goNext() method for getting to the next page
- A goPrev() method for getting to the previous page
- A setCursor() method to set the cursor to a specific index
- If an “onChange” callback handler is passed to the hook it will be invoked when the cursor changes with the current cursor position as an argument
I’m creating 2 files: UsePagination.js which will be my custom hook and UsePagination.test.js which will be my test for it. I launch Jest in watch mode and dive in.
For testing the hook logic I will be using the react-hooks-testing-library which allows me to test my hook without having to wrap it with a component. Makes the tests a lot more easy to maintain and focused.
First of all, let's make sure that there is a UsePagination custom hook:
import {renderHook, act} from '@testing-library/react-hooks';
import usePagination from './UsePagination';
describe('UsePagination hook', () => {
it('should exist', () => {
const result = usePagination();
expect(result).toBeDefined();
});
});
Our test fails of course. I will write the minimal code to satisfy it.
const usePagination = () => {
return {};
};
export default usePagination;
I am not testing with the react-hooks-testing-library yet, since I don’t have a need for that yet. Also remember, I’m writing the minimal code to make my tests pass and that’s it.
Ok, moving forward I would like to test the first requirement. I realize that the hook cannot work if no total pages were given to it, so I’d like to throw an error if no total pages number were given to it. Let’s test that:
it('should throw if no total pages were given to it', () => {
expect(() => {
usePagination();
}).toThrow('The UsePagination hook must receive a totalPages argument for it to work');
});
No error is thrown at the moment. I will add it to the hook’s code. I decide that the hook will receive it’s args in an object format, and so:
const usePagination = ({totalPages} = {}) => {
if (!totalPages) {
throw new Error('The UsePagination hook must receive a totalPages argument for it to work');
}
return {};
};
export default usePagination;
The tests run but something is wrong. The first test I wrote fails now because I didn’t pass totalPages for it and now it throws. I will fix that:
it('should exist', () => {
const result = usePagination({totalPages: 10});
expect(result).toBeDefined();
});
Great. Now let’s refactor a bit. I don’t like this error string written like that instead of a constant I can share and make sure that the test is always aligned with the hook. The refactor is easy:
export const NO_TOTAL_PAGES_ERROR = 'The UsePagination hook must receive a totalPages argument for it to work';
const usePagination = ({totalPages} = {}) => {
if (!totalPages) {
throw new Error(NO_TOTAL_PAGES_ERROR);
}
return {};
};
export default usePagination;
And my test can use it:
import usePagination, {NO_TOTAL_PAGES_ERROR} from './UsePagination';
describe('UsePagination hook', () => {
it('should exist', () => {
const result = usePagination({totalPages: 10});
expect(result).toBeDefined();
});
it('should throw if no total pages were given to it', () => {
expect(() => {
usePagination();
}).toThrow(NO_TOTAL_PAGES_ERROR);
});
});
Are there any other mandatory args to be validated? Nope, I think this is it.
Moving on I would like to test that the hook returns the totalPages back. Here I start to use the renerHook method to make sure my hooks acts as it would in the “real world”:
it('should return the totalPages that was given to it', () => {
const {result} = renderHook(() => usePagination({totalPages: 10}));
expect(result.current.totalPages).toEqual(10);
});
The test fails and so we write the code to fix that:
const usePagination = ({totalPages} = {}) => {
if (!totalPages) {
throw new Error(NO_TOTAL_PAGES_ERROR);
}
return {totalPages};
};
NOTE: I jumped a step here since the minimal code to satisfy the test would be returning a hard coded 10 as the totalPages, but it is redundant in this case since the logic here is really straightforward.
Now I would like to check that the hook returns the current cursor position. I will start with the requirement of “if it did not receive a cursor position as an arg, it should initialize it as 0”:
it('should return 0 as the cursor position if no cursor was given to it
', () => {
const {result} = renderHook(() => usePagination({totalPages: 10}));
expect(result.current.cursor).toEqual(0);
});
The code for passing this test is simple. I will return a hard coded 0 as the cursor from the hook ;)
const usePagination = ({totalPages} = {}) => {
if (!totalPages) {
throw new Error(NO_TOTAL_PAGES_ERROR);
}
return {totalPages, cursor: 0};
};
But we have another requirement which is “when the hook receives a cursor it should return that, and not the default value”:
it('should return the received cursor position if it was given to it', () => {
const {result} = renderHook(() => usePagination({totalPages: 10, cursor: 5}));
expect(result.current.cursor).toEqual(5);
});
Obviously the test fails since we are returning a hardcoded 0. This is how I tweak the code to make it pass:
const usePagination = ({totalPages, cursor} = {}) => {
if (!totalPages) {
throw new Error(NO_TOTAL_PAGES_ERROR);
}
cursor = cursor || 0;
return {totalPages, cursor};
};
Good for now.
The hook has to return a few methods. For now we will only test that it does return these methods with no intention of invoking them:
it('should return the hooks methods', () => {
const {result} = renderHook(() => usePagination({totalPages: 10}));
expect(typeof result.current.goNext).toEqual('function');
expect(typeof result.current.goPrev).toEqual('function');
expect(typeof result.current.setCursor).toEqual('function');
});
And the code to satisfy it:
const usePagination = ({totalPages, cursor} = {}) => {
if (!totalPages) {
throw new Error(NO_TOTAL_PAGES_ERROR);
}
cursor = cursor || 0;
const goNext = () => {};
const goPrev = () => {};
const setCursor = () => {};
return {totalPages, cursor, goNext, goPrev, setCursor};
};
The scaffold for our custom hook is ready. Now I need to start adding the hook’s logic into it.
I will start with the simplest bit of logic which is setting the cursor by using the setCursor method. I would like to invoke it and check that the cursor really changed. I simulate how React runs in the browser by wrapping the action I’m checking with the act() method:
describe('setCursor method', () => {
it('should set the hooks cursor to the given value
', () => {
const {result} = renderHook(() => usePagination({totalPages: 10}));
act(() => {
result.current.setCursor(4);
});
expect(result.current.cursor).toEqual(4);
});
});
NOTE: I created it in a nested “describe” for better order and readability.
And the test fails! If I try to do something naive like setting the cursor value on the hook's setCursor exposed method it still does not work since my hook fails to persist this value. We need some stateful code here :)
I will use the useState hook in order to create a cursor state for the hook:
const usePagination = ({totalPages, initialCursor} = {}) => {
if (!totalPages) {
throw new Error(NO_TOTAL_PAGES_ERROR);
}
const [cursor, setCursor] = useState(initialCursor || 0);
const goNext = () => {};
const goPrev = () => {};
return {totalPages, cursor, goNext, goPrev, setCursor};
};
This requires some explanations - first of all I changed the cursor arg name to initialCursor so that it won’t conflict with the useState returned variable. Second, I removed my own setCursor method and exposed the setCursor method returning from the useState hook.
Running the tests again and while the last one passes, both the first and fifth fail. The fifth fails because I’m passing “cursor” and not “initialCursor”, while the first one fails over “Invalid hook call. Hooks can only be called inside of the body of a function component” so we need to wrap it with renderHook(), and now it looks like this:
it('should exist', () => {
const {result} = renderHook(() => usePagination({totalPages: 10}));
expect(result.current).toBeDefined();
});
On top of that, let's add a test which checks that we cannot set a cursor which is outside the boundaries of the total pages count. Here are 2 tests which check that:
it('should not set the hooks cursor if the given value is above the total pages', () => {
const {result} = renderHook(() => usePagination({totalPages: 10}));
act(() => {
result.current.setCursor(15);
});
expect(result.current.cursor).toEqual(0);
});
it('should not set the hooks cursor if the given value is lower than 0', () => {
const {result} = renderHook(() => usePagination({totalPages: 10}));
act(() => {
result.current.setCursor(-3);
});
expect(result.current.cursor).toEqual(0);
});
Wow… The challenge here is that the useState does not allow me to run some logic in the setCursor method it returns.
Update: The following can also be done with useState but still not in the most elegant manner. Please see this thread here and the updated code in the hooks package itself
What I can do is to convert it to the useReducer hook. This kinda cancels what I recently did with the setCursor method, as the code evolves:
const SET_CURSOR_ACTION = 'setCursorAction';
...
const [cursor, dispatch] = useReducer(reducer, initialCursor || 0);
const setCursor = (value) => {
dispatch({value, totalPages});
};
And My reducer function is external to the hook function like so (don’t worry, I will paste the entire code at the bottom of the post):
function reducer(state, action) {
let result = state;
if (action.value > 0 && action.value < action.totalPages) {
result = action.value;
}
return result;
}
I have no cases here so there is no real need for a switch-case statement.
Nice. All tests pass so we can move on.
Next is the goNext() method exposed from the hook. I would like to see it moving to the next cursor position first:
describe('goNext method', () => {
it('should set the hooks cursor to the next value', () => {
const {result} = renderHook(() => usePagination({totalPages: 2}));
act(() => {
result.current.goNext();
});
expect(result.current.cursor).toEqual(1);
});
});
And here is the code to make it pass:
const goNext = () => {
const nextCursor = cursor + 1;
setCursor(nextCursor);
};
But that’s not the end of it. I would like to make sure that when we reach the last page, goNext() will have no effect on the cursor position anymore. Here is the test for it:
it('should not set the hooks cursor to the next value if we reached the last page', () => {
const {result} = renderHook(() => usePagination({totalPages: 5, initialCursor: 4}));
act(() => {
result.current.goNext();
});
expect(result.current.cursor).toEqual(4);
});
Gladly for me the logic inside the state reducer takes care of that :)
I will do the same for the goPrev method.
Ok, so we got these 2 methods covered, now we would like to implement the callback handler feature of the hook. When we pass a callback handler to the hook, it should be invoked when the cursor changes, be it by moving next/prev or set explicitly.
Here is the test for it:
describe('onChange callback handler', () => {
it('should be invoked when the cursor changes by setCursor method', () => {
const onChangeSpy = jest.fn();
const {result} = renderHook(() => usePagination({totalPages: 5, onChange: onChangeSpy}));
act(() => {
result.current.setCursor(3);
});
expect(onChangeSpy).toHaveBeenCalledWith(3);
});
});
For that I will use the useEffect hook in order to monitor over changes in the cursor state and when they happen and a callback is defined the hook will invoke it with the current cursor as the argument:
useEffect(() => {
onChange?.(cursor);
}, [cursor]);
But we’re not done. I suspect that the callback handler will be called when the hook initializes as well and this is wrong. I will add a test to make sure it does not happen:
it('should not be invoked when the hook is initialized', () => {
const onChangeSpy = jest.fn();
renderHook(() => usePagination({totalPages: 5, onChange: onChangeSpy}));
expect(onChangeSpy).not.toHaveBeenCalled();
});
As I suspected, the test fails. For making sure the onChange handler is not called when the hook initializes I will use a flag which indicates whether the hook is initializing or not, and invoke the handler only when it is not. In order to persist it across renders but not force a new render when it changes (like with state) I will use the useRef hook:
const isHookInitializing = useRef(true);
useEffect(() => {
if (isHookInitializing.current) {
isHookInitializing.current = false;
} else {
onChange?.(cursor);
}
}, [cursor]);
And there we have it. A custom hook which was fully created using TDD :)
Challenge yourself - see if you can implement a cyclic mode for the pagination (for instance, once it reaches the end it goes back to the beginning) using TDD 🤓
Here is the full hook code:
import {useEffect, useReducer, useRef, useState} from 'react';
export const NO_TOTAL_PAGES_ERROR = 'The UsePagination hook must receive a totalPages argument for it to work';
const usePagination = ({totalPages, initialCursor, onChange} = {}) => {
if (!totalPages) {
throw new Error(NO_TOTAL_PAGES_ERROR);
}
const [cursor, dispatch] = useReducer(reducer, initialCursor || 0);
const setCursor = (value) => {
dispatch({value, totalPages});
};
const goNext = () => {
const nextCursor = cursor + 1;
setCursor(nextCursor);
};
const goPrev = () => {
const prevCursor = cursor - 1;
setCursor(prevCursor);
};
const isHookInitializing = useRef(true);
useEffect(() => {
if (isHookInitializing.current) {
isHookInitializing.current = false;
} else {
onChange?.(cursor);
}
}, [cursor]);
return {totalPages, cursor, goNext, goPrev, setCursor};
};
function reducer(state, action) {
let result = state;
if (action.value > 0 && action.value < action.totalPages) {
result = action.value;
}
return result;
}
export default usePagination;
As always, if you have any ideas on how to make this better or any other technique, be sure to share with the rest of us!
Cheers
Hey! If you liked what you've just read check out @mattibarzeev on Twitter 🍻
Photo by Todd Quackenbush on Unsplash
Top comments (13)
Nice tutorial 👏.
Small bug in the implementation, this is the test to find it
Hey, thanks for the feedback :)
I might have changed the implementation since then, but I will have a look.
Thanks!
I really liked the approach of this article using TDD and also enjoyed working with the
react-hooks
helpers. I referenced a bunch of various pagination tutorials and gists on the web and even old stuff I'd written a while back to find a solution I liked. I followed along mostly similar to what you have here but then completely tore down the entire design. I realized that, in my design,usePagination
hook only needed to take care of generating the pagination links and nothing else. Totally SRP cohesive. Then, the React component would deal with pretty much only rendering the pagination controls. The consumer would, in fact, take care of theconst [currentPage, setCurrentPage]
state and simply listen foronPageChanged
callback, update the current page, then have a listener inuseEffect
that would regenerate the paging links based off of the updated current page. There's a lot of code between the three, but I can show the tests for myusePagination
as I think you'll find it interesting that it's basically a completely different approach!Here are the relevant scripts if you'd like to see the full approach: Pagination.tsx, usePagination.ts, Storybook consumer story.
A couple of things I'd note:
Thanks for the kind words @roblevintennis :)
Surely there are better ways to implement a Pagination React hook than what I did, and yours does looks like a more robust one. My goal in the article was to focus on the TDD aspect of developing such custom component, so it was never meant to be a complete Pagination solution :)
Having said that, I am not sure how I feel about the Hook supplying the array of links... In my case the hook is much "dumber" in the sense that I leave the buffering (and in your case, the offset ) to be something that the consuming component should take care of.
As for the cyclic nature of the pagination, you're right, this is more of a carousel kinda feature, but heck, why not? ;)
In any case, thanks for sharing your approach!
Gotcha, yeah, there are probably many viable approached — I'd say again that your tutorial on setting up the TDD test bed really really helped and was quite valuable! Not sure if you've ever read Kent Beck's TDD by example? But I have had a long love/hate with unit tests but one case where I feel they shine is when you feel a bit uncertain as to how to go about something and, for me, implementing pagination is quite a challenge indeed!
Good stuff…the dev world is definitely better when we share these ideas! Thanks :)
Cannot agree more :)
Great tutorial! Thank you!
Instead of using
useReducer
for such a simple use case, you could create a new function that would check the new cursor value before calling hook'ssetCursor
:...alrhough this also looks complicated so I'm not sure what's better.
I'd also rename
cursor
topage
as I'd reservecursor
for GraphQL paginationcursor
which is usually not a number but a string.Sorry for formatting, I'm typing this on my phone w/ autocorrect.
Wow, I replay this tutorial =) Thx.
Great tutorial! Super understandable!!
Great stuff! Tutorials are so much easier to follow when you use a TDD cycle. Divide and conquer simply works.
Great tutor and with TDD, it is excellent. Hope to see more tutors with TDD approach
We need more testing content. Tnx great post.
Thansk! Is there any testing content you find lacking in particular?