From Render-Props to Hooks

Before you begin...

This post is a direct followup to "React’s Render Props in Practice" - A post I published which describes the path from a naive component to using render props, and its benefits.
Reading it before you jump into this one will help you understand this post better.

In almost any hooks intro talk, hooks are described as a good alternative for render-props when it comes to providing more tweaking flexibility to a component.
Yes, it makes total sense if you perceive hooks as small logic nuggets, which you can add to any component and reuse, but this all remained as something I still needed to prove for myself, given that render-props really made perfect sense to begin with and were introduced as the be-all-and-end-all solution for creating flexible components.

Back then I posted an elaborated article about taking a sample page navigation component and going from the naive implementation to the render-props one, to enable tweaking it much more easily. The natural next step would be to convert this component to a hooks driven component, and this is what this post is about.

So we're picking off where we have a nice basic pager component which we can use with render props to create new and diverse pager components easily.

Our starting point is the basic-pager.js component which looks like this:

import React from "react";

class PagerBasic extends React.Component {
 state = {
   cursor: this.props.cursor || 0,
   pagesCount: this.props.pagesCount || 0
 };

 changePage = (newCursor) => {
   this.setState(
     ({ cursor }) => ({
       cursor: newCursor
     }),
     () => this.props.onPageChange(this.state.cursor)
   );
 };

 render() {
   const { cursor, pagesCount } = this.state;
   return this.props.children({
     cursor,
     pagesCount,
     goPrev: () => {
       this.changePage(this.state.cursor - 1);
     },
     goNext: () => {
       this.changePage(this.state.cursor + 1);
     },
     changePage: this.changePage
   });
 }
}

export default PagerBasic;

And one of its usages was this prev-next-pager component:

function PrevNextPager(props) {
 return (
   <PagerBasic {...props}>
     {({ cursor, pagesCount, goPrev, goNext }) => {
       const prevBtnText = cursor - 1 < 0 ? "N/A" : "< Previous";
       const nextBtnText = cursor + 1 < pagesCount ? "Next >" : "N/A";
       return (
         <div>
           <span onClick={goPrev}>{prevBtnText}</span>
           {<span> | </span>}
           <span onClick={goNext}>{nextBtnText}</span>
         </div>
       );
     }}
   </PagerBasic>
 );
}

Let's start

First thing first, let's take the base component and convert it from a class component to a function one:

import React, { useState } from "react";

const PagerBasic = (props) => {
 const [cursor, setCursor] = useState(props.cursor || 0);
 const [pagesCount] = useState(props.pagesCount || 0);

 const changePage = (newCursor) => {
   setCursor(newCursor);
   props.onPageChange(cursor);
 };

 return props.children({
   cursor,
   pagesCount,
   goPrev: () => {
     changePage(cursor - 1);
   },
   goNext: () => {
     changePage(cursor + 1);
   },
   changePage
 });
};

export default PagerBasic;

Hmmm…. that was pretty smooth. All is still working as it used to.

Ok, so that was the first step, but we don’t want to stop here. We want to use hooks, custom hooks to be precise. We will start with the fist one, which is the usePager hook (still on the same file of the component):

import React, { useState } from "react";

// The hook 
function usePager(initialCursor, initialPagesCount, pageChangeCallback) {
 const [cursor, setCursor] = useState(initialCursor);
 const [pagesCount] = useState(initialPagesCount);

 const changePage = (newCursor) => {
   setCursor(newCursor);
   pageChangeCallback(cursor);
 };

 return [cursor, pagesCount, changePage];
} 

// The component 
const PagerBasic = (props) => {
 const [cursor, pagesCount, changePage] = usePager(
   props.cursor || 0,
   props.pagesCount || 0,
   props.onPageChange
 );

 return props.children({
   cursor,
   pagesCount,
   goPrev: () => {
     changePage(cursor - 1);
   },
   goNext: () => {
     changePage(cursor + 1);
   },
   changePage
 });
};

export default PagerBasic;

Still on the same file, the hook here holds the state of the cursor position and a function to change the cursor, changePage (I know the names here can be much better, but please bear with me)

After verifying it still works, let’s extract that hook to its own file. We will call it use-pager-hook.js.
Since we need to call the props callback after the state is set, we will use useEffect to call it when the cursor changes (as it depends on the cursor).
We need to make sure not to call the callback on the first change of the cursor though. We do that with the help the useRef hook, keeping an inner state for the hook. We will also add a goNext and a goPrev functions which the hook will expose.

The hook now looks like this:

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

const usePager = ({
 cursor: initialCursor,
 pagesCount: initialPagesCount,
 onPageChange: pageChangeCallback
}) => {
 const [cursor, setCursor] = useState(initialCursor);
 const [pagesCount] = useState(initialPagesCount);

 const firstUpdate = useRef(true);

 useEffect(() => {
   if (firstUpdate.current) {
     firstUpdate.current = false;
   } else {
     pageChangeCallback(cursor);
   }
 }, [cursor, pageChangeCallback]);

 const changePage = (newCursor) => {
   setCursor(newCursor);
 };

 const goPrev = () => {
   changePage(cursor - 1);
 };

 const goNext = () => {
   changePage(cursor + 1);
 };

 return [cursor, pagesCount, goPrev, goNext, changePage];
};

export default usePager;

And it’s usage looks something like this:

import React from "react";
import usePager from "./use-pager-hook";

const PagerBasic = (props) => {
 const [cursor, pagesCount, changePage] = usePager(
   props.cursor || 0,
   props.pagesCount || 0,
   props.onPageChange
 );

 return props.children({
   cursor,
   pagesCount,
   goPrev: () => {
     changePage(cursor - 1);
   },
   goNext: () => {
     changePage(cursor + 1);
   },
   changePage
 });
};

export default PagerBasic;

Now the question is, do we still need the PagerBasic, or can we use this hook in the component which PagerBasic wraps, such as the PrevNextPager component.
Let’s try and use our newly created custom hook instead:

function PrevNextPager(props) {
 const [cursor, pagesCount, goPrev, goNext] = usePager(props);
 const [prevBtnText, setPrevBtnText] = useState("");
 const [nextBtnText, setNextBtnText] = useState("");

 useEffect(() => {
   setPrevBtnText(cursor - 1 < 0 ? "N/A" : "< Previous");
   setNextBtnText(cursor + 1 < pagesCount ? "Next >" : "N/A");
 }, [cursor, pagesCount]);

 return (
   <div>
     <span onClick={goPrev}>{prevBtnText}</span>
     {<span> | </span>}
     <span onClick={goNext}>{nextBtnText}</span>
   </div>
 );
}

And the other, more complex component “QuickPager”? There it is:

function QuickPager(props) {
 const [cursor, pagesCount, goPrev, goNext, changePage] = usePager(props);
 const [prevBtnText, setPrevBtnText] = useState("");
 const [nextBtnText, setNextBtnText] = useState("");

 const buffer = new Array(props.pagesBuffer).fill(0); // useMemo?

 useEffect(() => {
   setPrevBtnText(cursor - 1 < 0 ? "N/A" : "< Previous");
   setNextBtnText(cursor + 1 < pagesCount ? "Next >" : "N/A");
 }, [cursor, pagesCount]);

 return (
   <div>
     <span onClick={goPrev}>{prevBtnText}</span>
     {buffer.map((item, index) => {
       const pageCursor = cursor + index;
       const className = pageCursor === cursor ? "selected" : "normal";
       return pageCursor >= 0 && pageCursor < pagesCount ? (
         <span
           key={`page-${pageCursor}`}
           onClick={() => changePage(pageCursor)}
           className={className}
         >
           {` [${pageCursor}] `}
         </span>
       ) : null;
     })}
     <span onClick={goNext}>{nextBtnText}</span>
   </div>
 );
}

Wait now, this can also go into a custom hook:

useEffect(() => {
   setPrevBtnText(cursor - 1 < 0 ? "N/A" : "< Previous");
   setNextBtnText(cursor + 1 < pagesCount ? "Next >" : "N/A");
 }, [cursor, pagesCount]);

So this is how our useNavigationBtns hook looks like (again, please ignore the horrible naming):

import { useState, useEffect } from "react";

const useNavigationBtns = ({ cursor, pagesCount } = {}) => {
 const [prevBtnText, setPrevBtnText] = useState("");
 const [nextBtnText, setNextBtnText] = useState("");

 useEffect(() => {
   setPrevBtnText(cursor - 1 < 0 ? "N/A" : "< Previous");
   setNextBtnText(cursor + 1 < pagesCount ? "Next >" : "N/A");
 }, [cursor, pagesCount]);

 return [prevBtnText, nextBtnText];
};

export default useNavigationBtns;

And we use it like this which makes our components even more cleaner:

function PrevNextPager(props) {
 const [cursor, pagesCount, goPrev, goNext] = usePager(props);
 const [prevBtnText, nextBtnText] = useNavigationBtns({ cursor, pagesCount });

 return (
   <div>
     <span onClick={goPrev}>{prevBtnText}</span>
     {<span> | </span>}
     <span onClick={goNext}>{nextBtnText}</span>
   </div>
 );
}

Super cool, I must admit :)

Conclusion

So yeah, this is not just theory talking but actually a methodology which can make our components much cleaner, with less code to write and better separation of concerns between the different logic parts which comprise a whole component.

Hope you found this helpful. Be sure to leave a comment if you have any questions or something to bring up!

cheers

Hey! If you liked what you've just read be sure to also visit me on twitter :) Follow @mattibarzeev 🍻

17