Written by Faris Aziz Nov. 28, 2022
Faris Aziz is one of our professional mentors on MentorCruise and works as Senior Software Engineer - FrontEnd at Paytrix.
Visit ProfileAPIs are usually our primary source of data for driving our website’s views. They are a core part of how our application presents information, such as the weather, user data, lists of transactions, you name it! Given the importance of fetching data and rendering it, we need to make sure we do this in a performant and resilient manner. Server errors, network issues, request timeouts, and other factors could prevent your data from populating your views. Luckily, there are a few tools we can use to optimise our initial requests and continuously update data when it becomes stale.
What's better than showing a well-designed loading spinner while you fetch data? Not needing one at all!
NextJS offers a page-level function called getServerSideProps, which allows us to perform a NodeJS fetch request and pass it as props to the page. This gives us the benefit of having data ready to go on our client side.
Fetching and rendering a list of companies from the Faker API may look like this:
export const fetchCompanies = async () => {const res = await fetch("https://fakerapi.it/api/v1/companies");if (!res.ok) {throw new Error("Something went wrong");}const { data = [] } = await res.json();return data;};
export default function Home({ data }) { return ( <div> <h1>Companies</h1> <ol> {data && data.map(({ id, name, email, vat, phone, website }) => ( <li key={id}> <h2>{name}</h2> <p>{email}</p> <p>{vat}</p> <p>{phone}</p> <p>{website}</p> </li> ))} </ol> </div> ); };
export const getServerSideProps = async () => { const data = await fetchCompanies() return { props: { data }, }; };
Looks great, right? The list renders almost instantly and we don't even need to show a loading state (for now)
If we lived in a perfect world, this may be just about enough to ship to production. However, in a realistic scenario, we have to account for cases of initial requests failing, needing to retry, caching for performance and avoiding hitting the API limit.
React Query is an excellent data fetch library that allows us to make the best use of initial server-side fetches, retries, caching, request timeouts and more!
The first improvement we could make to the above code is to introduce a query hydration and retry mechanism. Hydration is the process of using client-side JavaScript to add application state and interactivity to server-rendered HTML.
To get started, we need to wrap our pages in React Query's providers inside _app.js
import { QueryClient, QueryClientProvider, Hydrate } from "@tanstack/react-query"; // Create a client const queryClient = new QueryClient(); export default function MyApp({ Component, pageProps }) { return ( <QueryClientProvider client={queryClient}> <Hydrate state={pageProps.dehydratedState}> <Component {...pageProps} /> </Hydrate> </QueryClientProvider> ); }
export const fetchCompanies = async () => {const res = await fetch("https://fakerapi.it/api/v1/companies");if (!res.ok) {throw new Error("Something went wrong");}const { data = [] } = await res.json();return data;}; // Remains unchanged
export default function Home() { const { data, error, isLoading } = useQuery({ queryKey: 'companies', queryFn: fetchCompanies, staleTime: 60_000 }); // stale after 1 min if (isLoading){ return <h1>Loading...</h1> } if (error){ return <h1>{error.message}</h1> } return ( <div> <h1>Companies</h1> <ol> {data && data.map(({ id, name, email, vat, phone, website }) => ( <li key={id}> <h2>{name}</h2> <p>{email}</p> <p>{vat}</p> <p>{phone}</p> <p>{website}</p> </li> ))} </ol> </div> ); };
export const getServerSideProps = async () => {}; // QueryClient and dehydrate() come from React Queryconst queryClient = new QueryClient()await queryClient.prefetchQuery(["companies"], fetchCompanies);return {props: {dehydratedState: dehydrate(queryClient),},};
If we look at our preliminary page load, we've still got our rendered list of companies and no sign of any client-side fetch requests. This means we're still fetching server-side initially.
The benefit we have now is that our data will refresh client-side when it goes stale. Data could go state due to our cache expiring, us indicating a timeout for fresh data or performing another request (e.g. creating a new data entry with a POST request), which triggers an invalidation of the current data.
Checking the network tab again, we can see that a client-side fetch is being triggered as soon as the data is considered stale. As a result, React Query will attempt to recover data multiple times if the initial request fails.
That's it! We've made our application performant and reactive towards request failures. Additionally, React Query can be configured to further optimize application state and requests, depending on the type of application you're developing.
Having said that, there is one more thing we can do...
Let's clean up this code and turn it into something much nicer to work with 🧹
We can start by defining an enum for our keys and a function mapping for our fetchers
const QUERY_KEYS = { COMPANIES: "companies", } const queryFunctions = { [QUERY_KEYS.COMPANIES]: fetchCompanies, };
After that, we can take our useQuery hook and create a HOC (higher-order component) with it
export const withQuery = (Component, key) => { return (props) => { const queryResponse = useQuery({ queryKey: [key], queryFn: QUERY_FUNCTIONS[key], staleTime: 50_000 }); return <Component {...{...props, ...queryResponse}} />; }; };This HOC will wrap our page component and spread the query response props (along with page props) for us to use, similar to how we used the data we received directly from getServerSideProps in our first iteration. This gives us the benefit of having a simple interface and abstracting the implementation details.
Our final page component will look like this:
const Home = ({ data, error, isLoading }) => { if (isLoading) { return <h1>Loading...</h1>; } if (error) { return <h1>{error.message}</h1>; } return ( <div> <ReactQueryDevtools initialIsOpen={false} /> <h1>Companies</h1> <ol> {data && data.map(({ id, name, email, vat, phone, website }) => ( <li key={id}> <h2>{name}</h2> <p>{email}</p> <p>{vat}</p> <p>{phone}</p> <p>{website}</p> </li> ))} </ol> </div> ); }; export default withQuery(Home, QUERY_KEYS.COMPANIES);
We could also of course extend this to use multiple queries, pass additional configurations to useQuery, etc... but for now, this will suffice.
I hope this gives you some insight into how to increase the resilience and performance of your API requests inside NextJS
Happy fetching! 🔥
Faris Aziz is one of our professional mentors on MentorCruise and works as Senior Software Engineer - FrontEnd at Paytrix.
Visit Profile71% of Fortune 500 companies can't be wrong – mentorship is crucial to career growth. Our free 'state of mentorship' shows you the facts, stats and studies on this career superpower.
Including 10% discount on your next session!
Change your career, grow into leadership, start a business, get a raise. Boosting your career is easier with some help! Join our value-packed newsletter to learn from the giants of the industry.