DEV Community

Dan Hammer
Dan Hammer

Posted on

Creating A Pdf Saver And Print Previewer in React using useImperativeHandle and jspdf

I recently had need to create documents in a React app with a scrolling previewer and the ability to print or save to PDF. Creating a component that could hold and display multi page documents and make them printable and able to be captured as a pdf. This came with some interesting opportunities to make smart components that can handle some work themselves.

Image Of A PDF previewer and Printer Page written in React

I'll go into further detail in the following sections, but for a quick breakdown:

  1. App creates an array of documents with a title and an array of pages made up of react components and a ref for each document.
  2. PrintDocumentScroller creates a scrolling view for all documents and renders a PrintDocument for each and passes the ref down.
  3. PrintDocument creates a PrintArea for each page and exposes a function to generate a PDF of the entire document. This is referenced in App using the ref that was passed down and useImperativeHandle.
  4. PrintArea renders the content in a page-like view so that the preview, print, and pdf all look the same.

Background

refs and useRef

useRef returns a mutable ref object whose .current property is initialized to the passed argument (initialValue). The returned object will persist for the full lifetime of the component

You might be familiar with refs primarily as a way to access the DOM. If you pass a ref object to React with

, React will set its .current property to the corresponding DOM node whenever that node changes.

refs are very useful to maintain a stable reference to any value (but especially DOM nodes or components) for the entire life of a component.

For this project, we will use refs to give access to functions on child components in order to render a canvas of each component.

useImperativeHandle

What is useImperativeHandle?

useImperativeHandle customizes the instance value that is exposed to parent components when using ref. As always, imperative code using refs should be avoided in most cases. useImperativeHandle should be used with forwardRef:

function FancyInput(props, ref) {
  const inputRef = useRef();
  useImperativeHandle(ref, () => ({
    focus: () => {
      inputRef.current.focus();
    }
  }));
  return <input ref={inputRef} ... />;
}
FancyInput = forwardRef(FancyInput);```


Make components do some work!

PrintArea

const PrintArea = forwardRef(({ children, pageIndicator }, ref) => {
  const useStyles = makeStyles(() => ({
    ...
  }));

  const classes = useStyles();

  const pageRef = useRef();

  useImperativeHandle(ref, () => ({
    captureCanvas: () => html2canvas(pageRef.current, { scale: 2 })
  }));

  return (
    <Box className={classes.printArea} ref={pageRef}>
      {children}
      <Box className={classes.pageIndicator}>{pageIndicator}</Box>
    </Box>
  );
});
Enter fullscreen mode Exit fullscreen mode

Above, we create a PrintArea component that will hold each individual page. It applies some styles to show an 11" x 8.5" box with a page number indicator in the bottom right. This component is fairly simple, but it provides us with a function, captureCanvas, to get the canvas just for that specific page.

Each PrintArea component is passed a ref. forwardRef allows us to take the assigned ref and use it inside the component.

useImperativeHandle allows us to assign a series of functions to any ref. In this case, the ref passed down through forward ref. We create captureCanvas, a function to digest the page into a canvas directly. This can be called by any parent component with access to the ref with ref.current.captureCanvas(). This is what we'll take advantage of to gather all of our canvases.

PrintDocument

Each PrintArea is a single page. PrintDocument represents an entire document and all of its pages.

const PrintDocument = forwardRef(({ pages, title }, ref) => {
  const numPages = pages.length;
  const printAreaRefs = useRef([...Array(numPages)].map(() => createRef()));

  useImperativeHandle(ref, () => ({
    generatePDF: () =>
      ...
      })
  }));

  return (
      <div>
        {pages.map((content, index) => (
          <PrintArea
            key={`${title}-${index}`}
            pageIndicator={`${title} - ${index + 1}/${numPages}`}
            ref={printAreaRefs.current[index]}
          >
            {content}
          </PrintArea>
        ))}
      </div>
  );
});
Enter fullscreen mode Exit fullscreen mode

PrintDocument creates a ref for each page and then renders the content within PrintAreas that are passed the correct ref.

PrintDocument also employs useImperativeRef to give its parent access to generate a PDF.

  useImperativeHandle(ref, () => ({
    generatePDF: () =>
      Promise.all(
        printAreaRefs.current.map((ref) => ref.current.captureCanvas())
      ).then((canvases) => {
        const pdf = new jsPDF(`portrait`, `in`, `letter`, true);

        const height = LETTER_PAPER.INCHES.height;
        const width = LETTER_PAPER.INCHES.width;

        // Loop over the canvases and add them as new numPages
        canvases.map((canvas, index) => {
          if (index > 0) {
            pdf.addPage();
          }

          const imgData = canvas.toDataURL(`image/png`);
          pdf.addImage(imgData, `PNG`, 0, 0, width, height, undefined, `FAST`);
        });

        return { pdf, title };
      })
  }));
Enter fullscreen mode Exit fullscreen mode

Because it assigns captureCanvas to each ref passed to a PrintArea, it is able to get the canvas for each page and pass it onto jspdf. Then, it returns the generated pdf and title to a parent component.

savePDFs

const savePDFs = (refs) =>
  Promise.all(
    refs.current.map((ref) => ref.current.generatePDF())
  ).then((pdfs) => pdfs.map(({ title, pdf }) => pdf.save(`${title}.pdf`)));
Enter fullscreen mode Exit fullscreen mode

savePDFs is passed the array of document refs and is able to call generatePDF() on each document and then save it.

In my use case, I gather all of the pdfs and upload them each to S3, which I may cover in a future post.

And now, a warning

A gif of Meryl Streep in Death Becomes Her saying `Now, a warning?!`

From the React docs: As always, imperative code using refs should be avoided in most cases.

It is of course possible to approach this without using refs and useImperativeRef.

We can assign an id to every page and programmatically grab it

documents = [
  {
    title: `Document1`,
    pages: [
      <div id="page-1-1">stuff</div>
      ...
    ]
  },
]

...

pages.map((_, index) =>
  html2canvas(
    document.body.appendChild(
      document.getElementById(
        `page-${documentIndex}-${pageIndex}`
      )
    )
  )
)
...
Enter fullscreen mode Exit fullscreen mode

We can even make this work with some of the styling. I am not a fan of this approach as it makes it slightly more difficult to generate an arbitrary number of pages and is honestly not very readable, but it is completely valid and will work. I chose not to do this in favor of a more readable and adaptable solution.

Top comments (0)