Creating a React Custom Hook using TDD

Matti Bar-Zeev - Nov 19 '21 - - Dev Community

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 description

(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();
   });
});
Enter fullscreen mode Exit fullscreen mode

Our test fails of course. I will write the minimal code to satisfy it.

const usePagination = () => {
   return {};
};

export default usePagination;
Enter fullscreen mode Exit fullscreen mode

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');
   });
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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();
   });
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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);
   });
});
Enter fullscreen mode Exit fullscreen mode

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);
   });
Enter fullscreen mode Exit fullscreen mode

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};
};
Enter fullscreen mode Exit fullscreen mode

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);
   });
Enter fullscreen mode Exit fullscreen mode

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};
};
Enter fullscreen mode Exit fullscreen mode

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);
   });
Enter fullscreen mode Exit fullscreen mode

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};
};
Enter fullscreen mode Exit fullscreen mode

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');
   });
Enter fullscreen mode Exit fullscreen mode

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};
};
Enter fullscreen mode Exit fullscreen mode

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);
       });
   });
Enter fullscreen mode Exit fullscreen mode

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};
};
Enter fullscreen mode Exit fullscreen mode

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();
   });
Enter fullscreen mode Exit fullscreen mode

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);
       });
Enter fullscreen mode Exit fullscreen mode

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});
   };
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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);
       });
   });
Enter fullscreen mode Exit fullscreen mode

And here is the code to make it pass:

const goNext = () => {
       const nextCursor = cursor + 1;
       setCursor(nextCursor);
   };
Enter fullscreen mode Exit fullscreen mode

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);
       });
Enter fullscreen mode Exit fullscreen mode

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);
       });
   });
Enter fullscreen mode Exit fullscreen mode

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]);
Enter fullscreen mode Exit fullscreen mode

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();
       });
Enter fullscreen mode Exit fullscreen mode

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]);
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .