Skip to content

Instantly share code, notes, and snippets.

@jclem
Last active July 14, 2021 21:03
Show Gist options
  • Save jclem/cbfa9ac5b87a53d34b3f88438cf8054e to your computer and use it in GitHub Desktop.
Save jclem/cbfa9ac5b87a53d34b3f88438cf8054e to your computer and use it in GitHub Desktop.
A Next.js getInitialProps wrapper
import localForage from 'localforage'
import {NextPage, NextPageContext} from 'next'
import {getDisplayName} from 'next/dist/next-server/lib/utils'
import {ComponentType} from 'react'
import {
hashQueryKey,
QueryFunction,
QueryKey,
useQuery,
UseQueryResult
} from 'react-query'
import {initQueryClient} from './api'
/**
* Props used internally by {@link withQuery} to wrap a {@link NextPage} and
* return one that accepts initial data and a {@link QueryKey} as props.
*/
interface WithQueryWrapperProps<R, K extends QueryKey = QueryKey> {
queryKey: K
initialData: R
}
/**
* Props accepted by a component being wrapped by {@link withQuery}.
*/
export interface WithQueryProps<R, K extends QueryKey = QueryKey> {
queryKey: K
result: UseQueryResult<R>
}
/**
* Options used to define how {@link withQuery} operates on a given component
* and its query.
*/
export interface WithQueryOpts<K extends QueryKey = QueryKey> {
/**
* A function that receives a {@link NextPageContext} and returns the
* {@link QueryKey} for the wrapped page.
*/
getQueryKey: (ctx: NextPageContext) => K
}
/**
* Wrap a {@link NextPage} component with a query for data with caching.
*
* Pages using this wrapper, when requested on fresh app load (a server request,
* not in-app navigation), will make an un-cached request for their data. When
* navigated to via in-app navigation, they'll check the in-memory cache, then
* an IndexedDB cache, and make a network request only if no cache already
* exists.
*
* Once the page renders, `withQuery` wraps a {@link useQuery} call that will
* then update the cache (and potentially re-render) in the background.
*
* Example:
*
* const MyPage = withQuery<MyQueryResponseData, string>(
* ({result}) => (
* <div>Query Result: {result.data}</div>
* ),
*
* async ({queryKey}) => {
* const response = await fetch(`/api/some/data/${queryKey}`)
* return await response.json()
* },
*
* {
* getQueryKey: (ctx) => ctx.query.id
* }
* )
*
* Note that `withQuery` takes two type parameters:
*
* 1. `R` is the type of the data when the query succeeds.
* 2. `K` is the type of the query key.
*
* @param Component The React component to render, which receives the query key and query result as props
* @param query A function that queries for our data
* @param options An optional {@link WithQueryOpts} object
* @returns A NextPage component
*/
export const withQuery = <R, K extends QueryKey = QueryKey>(
Component: ComponentType<WithQueryProps<R, K>>,
query: QueryFunction<R, K>,
opts: WithQueryOpts<K>
): NextPage<WithQueryWrapperProps<R, K>> => {
const {getQueryKey} = opts
// NOTE: Given we cast the default getter as `K`, we rely on the user to
// ensure their query key matches the pathname and query.
const queryWithLocalCache: QueryFunction<R, K> = async opts => {
const result = await query(opts)
if (result && typeof window !== 'undefined') {
const keyHash = hashQueryKey(opts.queryKey)
await localForage.setItem<R>(keyHash, result)
}
return result
}
// WrappedComponet is the component we return. It receives our initial data
// from `getInitialProps` and feeds it to `useQuery`, the result of which is
// passed as a prop to our user-provided component.
const WrappedComponent: NextPage<WithQueryWrapperProps<R, K>> = ({
queryKey,
initialData
}) => {
const result = useQuery<R, unknown, R, K>(queryKey, queryWithLocalCache, {
initialData
})
return <Component queryKey={queryKey} result={result} />
}
// On the server, make an un-cached request for our data. On the client, check
// in-memory cache, then IndexedDB cache, then make an HTTP request if
// required.
WrappedComponent.getInitialProps = async ctx => {
const queryKey = getQueryKey(ctx)
const keyHash = hashQueryKey(queryKey)
const queryClient = initQueryClient()
const memoryCached = queryClient.getQueryData<R>(queryKey)
let initialData: R | null = null
if (memoryCached) {
initialData = memoryCached
} else if (typeof window !== 'undefined') {
const localCached = await localForage.getItem<R>(keyHash)
if (localCached) {
initialData = localCached
}
}
if (!initialData) {
initialData = await queryClient.fetchQuery<R, unknown, R, K>(
queryKey,
queryWithLocalCache
)
}
return {
queryKey,
initialData
}
}
WrappedComponent.displayName = `withQuery(${getDisplayName(Component)})`
return WrappedComponent
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment