Creating API driven PDFs on the fly in the browser using React, react-pdf and TypeScript
Introduction — the challenge
Built on a complex JAMStack, and required to maintain regulatory compliance, our web application needed a way of rendering live-data into a PDF format, without the use of server-side functionality and without adding any significant overheads to performance or load to our API services.
Furthermore, with a research page count alone running into the 20, 000s, we quickly concluded that this essential (but not necessarily oft-used) PDF could be neither statically generated in Gatsby (due to build time), nor should be generated on page load (performance). We therefore needed a way to generate relatively complex, data-focussed PDF documents on the fly, using data from our API micro-service, all done on the client side, on demand.
Considering UX
User experience considerations for this feature revolved around the issue that users would have to go through a two-step process to access the file they wanted. To help with this process we added a loading indicator and a change of messaging to allow the user to see the steps in progress. Therefore, the local state of the component would change before the API call, on completion of data and during PDF generation. We would also need to consider error handling, in case any of these steps encountered a problem.
On-the-fly-pdf — where to start?
Initial research into solving this problem resulted in quite a few options, but on further inspection we saw that the many good PDF libraries relied on a page first being rendered in HTML, and then turned into an image file or <canvas>
element before being converted into a pdf. Needing a more streamlined, “under-the-hood” approach (ie generating the document without showing it on screen) we looked for an alternative.
Happily, we came across react-pdf. This library enabled us to combine the best of a JavaScript PDF generation library (in this case pdfkit) and the ability to use reusable React components, with props generated from data retrieved from our API.
Structuring the feature
To build this feature we needed to go through three processes:
- Call the API to retrieve data to populate the PDF
- Pass the data to a
react-pdf
component (or multiple components combined) - Pass this component to a
react-pdf
provider to enable a PDF blob to be created.
The example embedded towards the end of this article mirrors the way we achieved this, albeit in a simplified way, by using this component structure:
<App>
— the main JS App (renders the component in the example case).<PdfLink>
— renders the link in its request states and deals with API call.<TestDocument>
— includes react-pdf components to render the PDF. Accepts data from the API call as a prop.<PdfDocument>
— creates the final link and passes the PDF blob data to it. Accepts a JSX Element (in this case<TestDocument>
) as a prop.
Getting the data — the API Request
For the purposes of this demo, I will replicate how we implemented this feature. To avoid the complexity caused by calling our own API, I’m going to use the Random User Generator api to display some simple user details on a PDF. It’s worth noting that these examples also use TypeScript to develop our web app, and this example follows suit.
Using React.useState
we set initial states for requesting
, error
, attempts
and data
to allow for our stepped approach outlined above. Note that our initial state for data is necessary as TypeScript is not able to infer the type from an empty or undefined state, as it can with the other boolean types.
We then do a simple API call to fetch the data we need for the document. Once we have the data, we can pass it as props to the <TestDocument />
component, which itself is passed as a prop to the <PDFDocument />
component.
At the different steps of the process we use the react-fontawesome
package for some nice icons and a spinner to allow the user to understand that the app is processing the request.
To handle unforeseen API errors, we allow multiple attempts to retry the API. This is set in the local state as attempts
and conditionally renders a retry link.
import * as React from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faSpinner, faFile } from "@fortawesome/free-solid-svg-icons";import PdfDocument from "./PdfDocument";
import TestDocument from "./TestDocument";import { ApiResponse, ApiData } from "./interfaces";const PDFLink: React.FC = () => {
const { useState } = React;
const initialData: Array<ApiData> | undefined = undefined;
const [error, setError] = useState(false);
const [requesting, setRequesting] = useState(false);
const [data, setData] = useState(initialData);
const [attempts, setAttempts] = useState(0);const requestDataUrl = "https://randomuser.me/api/?results=1&inc=name,email";
const fetchData = () => {
setRequesting(true);
fetch(requestDataUrl)
.then(res => res.json())
.then((resData: ApiResponse) => {
setData(resData.results);
setRequesting(false);
})
.catch(e => {
setError(true);
setRequesting(false);
setAttempts(attempts + 1);
console.error(e);
});
};return (
<p>
{!requesting && !data && !error && (
<span className="clickable" onClick={() => fetchData()}>
- Request this document <FontAwesomeIcon icon={faFile} /.
</span>
)} {requesting && (
<span><FontAwesomeIcon icon={faSpinner} spin /> retrieving document...</span>
)} {data && !requesting && !error && (
<PdfDocument
title="Cost Disclosure Document"
document={<TestDocument data={data} />}
/>
)} {!requesting && error && (
<>
<span>There has been an error. </span>
{attempts < 3 ? (
<span
className="clickable"
onClick={() => fetchData()}
>
Please try again.
</span>
) : (
<span>Please try again later.</span>
)}
</>
)}
</p>
);
};export default PDFLink;
Putting the PDF together
On ii.co.uk, our API call populated a PDF with tables of varying layout and complexity. With JSX/HTML this would be possible, if occasionally tricky. With react-pdf
this created a further challenge, due to the library’s following of the React primitives specification. For those familiar with React Native, this will make sense, but it essentially means that the PDF needs to be structured around primitive components such as <Document />
, <View />
, <Text />
and <Link />
, rather than the HTML elements we are used to in React for web.
To further complicate matters, when styling elements for PDF (print), pixels are no longer a valid unit. Instead we can choose between pt
, mm
, in
, %
, or viewport (page) height and width (vh
vw
).
Building tables in with this approach is a challenge! However, the advantage of using react-pdf
is that reusable components can be created, such as <TableRow>
and <TableCell>
. This allows more complex table layouts to be achieved, controlled by props or, in some cases, even by the props derived from the API response.
For example, a <TableCell>
might look like this:
import * as React from "react";
import { StyleSheet, Text } from "@react-pdf/renderer";// This would probably abstracted out to a bigger styles file and imported in:const styles = StyleSheet.create({
table: {
cell: {
margin: "auto",
width: "100%",
fontSize: 9,
}
}
);const TableCell = (props: { value: string | number }) => {
const { table } = styles; return <Text style={table.cell}>{props.value}</Text>;
};export default TableCell;
And further up the tree, a container component might look like this (I have not included all stying):
import * as React from "react";import StandardTableHeader from "./StandardTableHeader";
import TableRow from "./TableRow";import { StandardTableProps } from "../interfaces/props";
import { styles } from "../index";const SummaryTable: React.FC<StandardTableProps> = ({data}) => {
const { table } = styles;
return (
<View style={table}>
<StandardTableHeader />
<TableRow
title="Our costs"
data={data}
costType="TOTAL_II_COST"
lookUpType="summary"
/>
<TableRow
title="Product costs"
data={data}
costType="TOTAL_PRODUCT_COST"
lookUpType="summary"
/>
<TableRow
title="Total"
data={data}
costType="TOTAL_COST"
lookUpType="summary"
/>
</View>
);};
In our simplified example, I have only used <Document>
, <Page>
, <View>
and <Text>
, but if you have complex PDF templates to create, I would encourage you to create a system of components to allow flexibility, reusability and ease.
A note on fonts, also: currently, react-pdf
supports fonts through it’s Font.register()
method. However, be aware that you will need a link to a static font asset (eg https://fonts.gstatic.com/s/opensans/v16/...etc
for Google Fonts) and the Font.register()
method only supports .ttf
files at this time. Therefore, it might be better to store fonts in the public folder of your app if the .ttf
Google font is not available.
Viewing/downloading the PDF
Now that we have our API data and have set up our PDF using react-pdf
components, we can now pass the document component to a provider to output in the browser.
React-pdf gives us a couple of options for doing this, both of which accept a JSX Element as a document
prop:
<DownloadLink>
— returns a callback which gives us access to the blob data, a download url, a loading state and an error state. However, it only renders a simple link which allows the user to download the PDF without viewing it.<BlobProvider>
— returns a callback to also give us access to the blob data, a download url, a loading state and an error state. In our case, this was more useful as it gave us granular control over the opening the PDF in browser windows, dependent on browser capabilities and behaviour.
For the example below, whilst I am effectively creating a download link (displaying a PDF in code sandbox didn’t seem ideal!) I have used <BlobProvider>
to best mirror our work on ii.co.uk.
NOTE: at the time of writing, there is an issue in react-pdf
which is yet to be resolved (https://github.com/diegomura/react-pdf/issues/420) apparently due to multiple renders. It seems that pushing the render of the component to the back of the event queue via a setTimeout
in a one-time useEffect()
hook to set a ready
state allows us to control the render, fixing the problem. This is one to keep an eye on as it is a slightly hacky fix…
So here is our example <PdfDocument>
component:
import * as React from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { BlobProvider } from "@react-pdf/renderer";
import { faSpinner, faFile } from "@fortawesome/free-solid-svg-icons";export interface PdfDocumentProps {
title: string;
document: JSX.Element;
}const PdfDocument: React.FC<PdfDocumentProps> = ({ title, document }) => {
const { useState, useEffect } = React;
const [ready, setReady] = useState(false); // this is hacky but helps set the render to the back of event queue https://github.com/diegomura/react-pdf/issues/420
useEffect(() => {
setTimeout(() => {
setReady(true);
}, 0);
}, []);
// end of hacky stuff if (!ready) {
return null;
} else {
return (
<BlobProvider document={document}>
{({ url, loading, error }) => {
if (loading) {
return (
<span>
<FontAwesomeIcon icon={faSpinner} spin />
generating document...
</span>
);
}
if (!loading && url) {
return (
<a href={url} download>
- Download '{title}' (PDF) <FontAwesomeIcon icon={faFile} />
</a>
);
} if (error) {
console.error(error);
return <p>An error occurred</p>;
} return null;
}}
</BlobProvider>
);
}
};export default PdfDocument;
A working example
Below is a working code sandbox example, combining the implementation described above. Feel free to fork or use for the basis of your own projects. Constructive comments for improvements are also always appreciated.
The benefits
Whilst we could have produced PDFs offline, or created them on the server side, using React and the react-pdf
library in this way gave brought us a number of benefits for our application:
- We want our API service to deliver data, not files. Where we have links to PDFs in other places, these are either managed through our CMS, or provided directly from a third-party service. Using on-the-fly PDF generation means our API can stick to providing detailed data about our instruments and the React app can do the rest.
- Whist crucial for compliance, these PDFs were not predicted to be high traffic, and whilst the data used to produce them is dynamic, it would be stable enough to generate daily at the most. This would mean generating a vast number of PDFs on the server daily, increasing costs and server load. Keeping this off the server-side/API therefore makes sense.
- Although our research pages are generated statically (using Gatsby), a large amount of their timely data such as prices and chart data are delivered on rehydrate from our API services. Having PDFs generate on-demand allows us to keep those API calls to a minimum on page load, bringing associated performance benefit.
- We now have a useful and comprehensive set of components for generating other PDFs in this way, and displaying them within the application if necessary. Whilst we are a digital company, we often deliver complex and detailed data to our users, which they might prefer to save. We now have a method of generating custom printable data documents for the future.
Further reading:
- React-Pdf Docs: https://react-pdf.org/
- PDFKit on GitHub: https://github.com/foliojs/pdfkit
- An ii research page with a Cost Disclosure Document generator: https://www.ii.co.uk/etfs/vanguard-sp-500-ucits-etf-gbp/LSE:VUSA