DEV Community

Cover image for Creating a React Custom Hook using TDD

Creating a React Custom Hook using TDD

Matti Bar-Zeev on November 19, 2021

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...
Collapse
 
tmerlet profile image
tmerlet

Nice tutorial 👏.

Small bug in the implementation, this is the test to find it

Image description

Collapse
 
mbarzeev profile image
Matti Bar-Zeev

Hey, thanks for the feedback :)
I might have changed the implementation since then, but I will have a look.

Thanks!

Collapse
 
roblevintennis profile image
Rob Levin • Edited

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 the const [currentPage, setCurrentPage] state and simply listen for onPageChanged callback, update the current page, then have a listener in useEffect 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 my usePagination as I think you'll find it interesting that it's basically a completely different approach!

import { renderHook, act } from '@testing-library/react-hooks';
import { usePagination } from './usePagination';

describe('generate paging', () => {
  it('should work for smaller totals', () => {
    const { result } = renderHook(() => usePagination({ offset: 2 }));
    act(() => {
      expect(result.current.generate(1, 4)).toStrictEqual([1, 2, 3, 4]);
      expect(result.current.generate(1, 5)).toStrictEqual([1, 2, 3, 4, 5]);
      expect(result.current.generate(1, 6)).toStrictEqual([1, 2, 3, '...', 6]);
      expect(result.current.generate(5, 6)).toStrictEqual([1, 2, 3, 4, 5, 6]);
      expect(result.current.generate(5, 7)).toStrictEqual([1, 2, 3, 4, 5, 6, 7]);
      expect(result.current.generate(6, 8)).toStrictEqual([1, '...', 4, 5, 6, 7, 8]);
    });
  });
  it('should generate pagination with offset of 2', () => {
    const { result } = renderHook(() => usePagination({ offset: 2 }));
    act(() => {
      // Edge case only 1 total pages
      expect(result.current.generate(10, 1)).toEqual([1]);
      expect(result.current.generate(1, 20)).toEqual([1, 2, 3, '...', 20]);
      expect(result.current.generate(2, 20)).toEqual([1, 2, 3, 4, '...', 20]);
      expect(result.current.generate(3, 20)).toEqual([1, 2, 3, 4, 5, '...', 20]);
      expect(result.current.generate(4, 20)).toEqual([1, 2, 3, 4, 5, 6, '...', 20]);
      expect(result.current.generate(5, 20)).toEqual([1, 2, 3, 4, 5, 6, 7, '...', 20]);
      expect(result.current.generate(6, 20)).toEqual([1, '...', 4, 5, 6, 7, 8, '...', 20]);
      expect(result.current.generate(7, 20)).toEqual([1, '...', 5, 6, 7, 8, 9, '...', 20]);
      expect(result.current.generate(8, 20)).toEqual([1, '...', 6, 7, 8, 9, 10, '...', 20]);
      expect(result.current.generate(9, 20)).toEqual([1, '...', 7, 8, 9, 10, 11, '...', 20]);
      expect(result.current.generate(10, 20)).toEqual([1, '...', 8, 9, 10, 11, 12, '...', 20]);
      expect(result.current.generate(11, 20)).toEqual([1, '...', 9, 10, 11, 12, 13, '...', 20]);
      expect(result.current.generate(12, 20)).toEqual([1, '...', 10, 11, 12, 13, 14, '...', 20]);
      expect(result.current.generate(13, 20)).toEqual([1, '...', 11, 12, 13, 14, 15, '...', 20]);
      expect(result.current.generate(14, 20)).toEqual([1, '...', 12, 13, 14, 15, 16, '...', 20]);
      expect(result.current.generate(15, 20)).toEqual([1, '...', 13, 14, 15, 16, 17, '...', 20]);
      expect(result.current.generate(16, 20)).toEqual([1, '...', 14, 15, 16, 17, 18, 19, 20]);
      expect(result.current.generate(17, 20)).toEqual([1, '...', 15, 16, 17, 18, 19, 20]);
      expect(result.current.generate(18, 20)).toEqual([1, '...', 16, 17, 18, 19, 20]);
      expect(result.current.generate(19, 20)).toEqual([1, '...', 17, 18, 19, 20]);
      expect(result.current.generate(20, 20)).toEqual([1, '...', 18, 19, 20]);
      // Test higher page and total
      expect(result.current.generate(999, 1200)).toEqual([
        1,
        '...',
        997,
        998,
        999,
        1000,
        1001,
        '...',
        1200,
      ]);
    });
  });
  it('should generate pagination with offset of 1', () => {
    const { result } = renderHook(() => usePagination({ offset: 1 }));
    act(() => {
      // Edge case only 1 total pages
      expect(result.current.generate(10, 1)).toEqual([1]);
      expect(result.current.generate(1, 20)).toEqual([1, 2, '...', 20]);
      expect(result.current.generate(2, 20)).toEqual([1, 2, 3, '...', 20]);
      expect(result.current.generate(3, 20)).toEqual([1, 2, 3, 4, '...', 20]);
      expect(result.current.generate(4, 20)).toEqual([1, '...', 3, 4, 5, '...', 20]);
      expect(result.current.generate(5, 20)).toEqual([1, '...', 4, 5, 6, '...', 20]);
      expect(result.current.generate(6, 20)).toEqual([1, '...', 5, 6, 7, '...', 20]);
      expect(result.current.generate(7, 20)).toEqual([1, '...', 6, 7, 8, '...', 20]);
      expect(result.current.generate(8, 20)).toEqual([1, '...', 7, 8, 9, '...', 20]);
      expect(result.current.generate(9, 20)).toEqual([1, '...', 8, 9, 10, '...', 20]);
      expect(result.current.generate(10, 20)).toEqual([1, '...', 9, 10, 11, '...', 20]);
      expect(result.current.generate(11, 20)).toEqual([1, '...', 10, 11, 12, '...', 20]);
      expect(result.current.generate(12, 20)).toEqual([1, '...', 11, 12, 13, '...', 20]);
      expect(result.current.generate(13, 20)).toEqual([1, '...', 12, 13, 14, '...', 20]);
      expect(result.current.generate(14, 20)).toEqual([1, '...', 13, 14, 15, '...', 20]);
      expect(result.current.generate(15, 20)).toEqual([1, '...', 14, 15, 16, '...', 20]);
      expect(result.current.generate(16, 20)).toEqual([1, '...', 15, 16, 17, '...', 20]);
      expect(result.current.generate(17, 20)).toEqual([1, '...', 16, 17, 18, '...', 20]);
      expect(result.current.generate(18, 20)).toEqual([1, '...', 17, 18, 19, 20]);
      expect(result.current.generate(19, 20)).toEqual([1, '...', 18, 19, 20]);
      expect(result.current.generate(20, 20)).toEqual([1, '...', 19, 20]);
      // Test higher page and total
      expect(result.current.generate(999, 1200)).toEqual([1, '...', 998, 999, 1000, '...', 1200]);
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

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:

  • I don't recall ever seeing circular pagination (that's usually something I'd see like tabbing around a modal or tabs, but not pagination). Not sure if the UX is ideal.
  • Keyboard navigation is important. You should be able to tab through the paging controls. Perhaps even better would be to tab "into" controls, then use arrows (Zendesk Garden's pagination does this)
  • Probably the most challenging is large data sets and not having to loop huge data sets. The solution I went with uses currying approach which is completely independent of the size of the data set as it's only worried about the paging controls and where to place ellipses aka gap, and offsets.
Collapse
 
mbarzeev profile image
Matti Bar-Zeev

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!

Collapse
 
roblevintennis profile image
Rob Levin

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 :)

Thread Thread
 
mbarzeev profile image
Matti Bar-Zeev

Cannot agree more :)

Collapse
 
vfonic profile image
Viktor • Edited

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's setCursor:

const handleSetCursor = useCallback(newCursor => {
  const result = newCursor;
  if (newCursor < 0)...

  return result;
}, [totalPages, cursor])
Enter fullscreen mode Exit fullscreen mode

...alrhough this also looks complicated so I'm not sure what's better.

I'd also rename cursor to page as I'd reserve cursor for GraphQL pagination cursor which is usually not a number but a string.

Sorry for formatting, I'm typing this on my phone w/ autocorrect.

Collapse
 
vrunishkajo profile image
vrunishkajo

Wow, I replay this tutorial =) Thx.

Collapse
 
raiben23 profile image
Alex Cáceres

Great tutorial! Super understandable!!

Collapse
 
nikoheikkila profile image
Niko Heikkilä

Great stuff! Tutorials are so much easier to follow when you use a TDD cycle. Divide and conquer simply works.

Collapse
 
aytacg26 profile image
Aytac Güley

Great tutor and with TDD, it is excellent. Hope to see more tutors with TDD approach

Collapse
 
automateeverythingm profile image
Marko Pavic

We need more testing content. Tnx great post.

Collapse
 
mbarzeev profile image
Matti Bar-Zeev

Thansk! Is there any testing content you find lacking in particular?