-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: Add UniversalRouteScreen data fetching with react-query
- Loading branch information
Showing
14 changed files
with
419 additions
and
8 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,3 +1,3 @@ | ||
import HomeScreen from '@app/core/screens/HomeScreen' | ||
import HomeScreen from '@app/core/routes/index' | ||
|
||
export default HomeScreen |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,4 +1,4 @@ | ||
'use client' | ||
import HomeScreen from '@app/core/screens/HomeScreen' | ||
import HomeScreen from '@app/core/routes/index' | ||
|
||
export default HomeScreen |
46 changes: 46 additions & 0 deletions
46
features/app-core/context/UniversalQueryClientProvider.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,46 @@ | ||
'use client' | ||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query' | ||
|
||
/* --- Constants ------------------------------------------------------------------------------- */ | ||
|
||
let clientSideQueryClient: QueryClient | undefined = undefined | ||
|
||
/** --- makeQueryClient() ---------------------------------------------------------------------- */ | ||
/** -i- Build a queryclient to be used either client-side or server-side */ | ||
export const makeQueryClient = () => { | ||
const oneMinute = 1000 * 60 | ||
const queryClient = new QueryClient({ | ||
defaultOptions: { | ||
queries: { | ||
// With SSR, we usually want to set some default staleTime | ||
// above 0 to avoid refetching immediately on the client | ||
staleTime: oneMinute, | ||
}, | ||
}, | ||
}) | ||
return queryClient | ||
} | ||
|
||
/** --- getQueryClient() ----------------------------------------------------------------------- */ | ||
/** -i- Always makes a new query client on the server, but reuses an existing client if found in browser or mobile */ | ||
export const getQueryClient = () => { | ||
// Always create a new query client on the server, so no caching is shared between requests | ||
const isServer = typeof window === 'undefined' | ||
if (isServer) return makeQueryClient() | ||
// On the browser or mobile, make a new client if we don't already have one | ||
// This is important so we don't re-make a new client if React suspends during initial render. | ||
// Might not be needed if we have a suspense boundary below the creation of the query client though. | ||
if (!clientSideQueryClient) clientSideQueryClient = makeQueryClient() | ||
return clientSideQueryClient | ||
} | ||
|
||
/** --- <UniversalQueryClientProvider/> ----------------------------------------------------------------- */ | ||
/** -i- Provides a universal queryclient to be used either client-side or server-side */ | ||
export const UniversalQueryClientProvider = ({ children }: { children: React.ReactNode }) => { | ||
const queryClient = getQueryClient() | ||
return ( | ||
<QueryClientProvider client={queryClient}> | ||
{children} | ||
</QueryClientProvider> | ||
) | ||
} |
59 changes: 59 additions & 0 deletions
59
features/app-core/navigation/UniversalRouteScreen.helpers.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,59 @@ | ||
'use client' | ||
import type { Query, QueryKey } from '@tanstack/react-query' | ||
import { queryBridge } from '../screens/HomeScreen' | ||
|
||
/* --- Types ----------------------------------------------------------------------------------- */ | ||
|
||
export type QueryFn = (args: Record<string, unknown>) => Promise<Record<string, unknown>> | ||
|
||
export type QueryBridgeConfig<Fetcher extends QueryFn> = { | ||
/** -i- Function to turn any route params into the query key for the `routeDataFetcher()` query */ | ||
routeParamsToQueryKey: (routeParams: Partial<Parameters<Fetcher>[0]>) => QueryKey | ||
/** -i- Function to turn any route params into the input args for the `routeDataFetcher()` query */ | ||
routeParamsToQueryInput: (routeParams: Partial<Parameters<Fetcher>[0]>) => Parameters<Fetcher>[0] | ||
/** -i- Fetcher to prefetch data for the Page and QueryClient during SSR, or fetch it clientside if browser / mobile */ | ||
routeDataFetcher: Fetcher | ||
/** -i- Function transform fetcher data into props */ | ||
fetcherDataToProps?: (data: Awaited<ReturnType<Fetcher>>) => Record<string, unknown> | ||
/** -i- Initial data provided to the QueryClient */ | ||
initialData?: ReturnType<Fetcher> | ||
} | ||
|
||
export type UniversalRouteProps<Fetcher extends QueryFn> = { | ||
/** -i- Optional params passed by the Next.js app router, in Expo we get these from `useRouteParams()` */ | ||
params?: Partial<Parameters<Fetcher>[0]> | ||
/** -i- Optional search params passed by the Next.js app router, in Expo we get these from `useRouteParams()` */ | ||
searchParams?: Partial<Parameters<Fetcher>[0]> | ||
/** -i- Configuration for the query bridge */ | ||
queryBridge: QueryBridgeConfig<Fetcher> | ||
/** -i- The screen to render for this route */ | ||
routeScreen: React.ComponentType | ||
} | ||
|
||
export type HydratedRouteProps< | ||
QueryBridge extends QueryBridgeConfig<QueryFn> | ||
> = ReturnType<QueryBridge['fetcherDataToProps']> & { | ||
/** -i- The route key for the query */ | ||
queryKey: QueryKey | ||
/** -i- The input args for the query */ | ||
queryInput: Parameters<QueryBridge['routeDataFetcher']>[0] | ||
/** -i- The route params passed by the Next.js app router, in Expo we get these from `useRouteParams()` */ | ||
params: Partial<Parameters<QueryBridge['routeDataFetcher']>[0]> | ||
/** -i- The search params passed by the Next.js app router, in Expo we get these from `useRouteParams()` */ | ||
searchParams: Partial<Parameters<QueryBridge['routeDataFetcher']>[0]> | ||
} | ||
|
||
/** --- createQueryBridge() -------------------------------------------------------------------- */ | ||
/** -i- Util to create a typed bridge between a fetcher and a route's props */ | ||
export const createQueryBridge = <QueryBridge extends QueryBridgeConfig<QueryFn>>( | ||
queryBridge: QueryBridge | ||
) => { | ||
type FetcherData = Awaited<ReturnType<QueryBridge['routeDataFetcher']>> | ||
type ReturnTypeOfFunction<F, A> = F extends ((args: A) => infer R) ? R : FetcherData | ||
type RoutePropsFromFetcher = ReturnTypeOfFunction<QueryBridge['fetcherDataToProps'], FetcherData> | ||
const fetcherDataToProps = queryBridge.fetcherDataToProps || ((data: FetcherData) => data) | ||
return { | ||
...queryBridge, | ||
fetcherDataToProps: fetcherDataToProps as ((data: FetcherData) => RoutePropsFromFetcher), | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,45 @@ | ||
'use client' | ||
import { useQuery } from '@tanstack/react-query' | ||
import type { UniversalRouteProps, QueryFn } from './UniversalRouteScreen.helpers' | ||
import { useRouteParams } from './useRouteParams' | ||
|
||
/** --- <UniversalRouteScreen/> -------------------------------------------------------------------- */ | ||
/** -i- Universal Route Wrapper to provide query data on mobile, the browser and during server rendering */ | ||
export const UniversalRouteScreen = <Fetcher extends QueryFn>(props: UniversalRouteProps<Fetcher>) => { | ||
// Props | ||
const { params: routeParams, searchParams, queryBridge, routeScreen: RouteScreen, ...screenProps } = props | ||
const { routeParamsToQueryKey, routeParamsToQueryInput, routeDataFetcher } = queryBridge | ||
const fetcherDataToProps = queryBridge.fetcherDataToProps || ((data: ReturnType<Fetcher>) => data) | ||
|
||
// Hooks | ||
const expoRouterParams = useRouteParams(props) | ||
|
||
// Vars | ||
const queryParams = { ...routeParams, ...searchParams, ...expoRouterParams } | ||
const queryKey = routeParamsToQueryKey(queryParams) | ||
const queryInput = routeParamsToQueryInput(queryParams) | ||
|
||
// -- Query -- | ||
|
||
const queryConfig = { | ||
queryKey, | ||
queryFn: async () => await routeDataFetcher(queryInput), | ||
initialData: queryBridge.initialData, | ||
} | ||
|
||
// -- Mobile -- | ||
|
||
const { data: fetcherData } = useQuery(queryConfig) | ||
const routeDataProps = fetcherDataToProps(fetcherData) as Record<string, unknown> | ||
|
||
return ( | ||
<RouteScreen | ||
{...routeDataProps} | ||
queryKey={queryKey} | ||
queryInput={queryInput} | ||
{...screenProps} // @ts-ignore | ||
params={routeParams} | ||
searchParams={searchParams} | ||
/> | ||
) | ||
} |
116 changes: 116 additions & 0 deletions
116
features/app-core/navigation/UniversalRouteScreen.web.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,116 @@ | ||
'use client' | ||
import { use, useState, useEffect } from 'react' | ||
import { useQueryClient, useQuery, dehydrate, HydrationBoundary } from '@tanstack/react-query' | ||
import type { UniversalRouteProps, QueryFn } from './UniversalRouteScreen.helpers' | ||
import { useRouteParams } from './useRouteParams' | ||
|
||
/* --- Helpers --------------------------------------------------------------------------------- */ | ||
|
||
const getSSRData = () => { | ||
const $ssrData = document.getElementById('ssr-data') | ||
const ssrDataText = $ssrData?.getAttribute('data-ssr') | ||
const ssrData = ssrDataText ? (JSON.parse(ssrDataText) as Record<string, any>) : null | ||
return ssrData | ||
} | ||
|
||
const getDehydratedSSRState = () => { | ||
const $ssrHydrationState = document.getElementById('ssr-hydration-state') | ||
const ssrHydrationStateText = $ssrHydrationState?.getAttribute('data-ssr') | ||
const ssrHydrationState = ssrHydrationStateText ? (JSON.parse(ssrHydrationStateText) as Record<string, any>) : null | ||
return ssrHydrationState | ||
} | ||
|
||
/** --- <UniversalRouteScreen/> ---------------------------------------------------------------- */ | ||
/** -i- Universal Route Wrapper to provide query data on mobile, the browser and during server rendering */ | ||
export const UniversalRouteScreen = <Fetcher extends QueryFn>(props: UniversalRouteProps<Fetcher>) => { | ||
// Props | ||
const { params: routeParams, searchParams, queryBridge, routeScreen: RouteScreen, ...screenProps } = props | ||
const { routeParamsToQueryKey, routeParamsToQueryInput, routeDataFetcher } = queryBridge | ||
const fetcherDataToProps = queryBridge.fetcherDataToProps || ((data: ReturnType<Fetcher>) => data) | ||
|
||
// Hooks | ||
const nextRouterParams = useRouteParams(props) | ||
|
||
// Context | ||
const queryClient = useQueryClient() | ||
|
||
// State | ||
const [hydratedData, setHydratedData] = useState<Record<string, any> | null>(null) | ||
const [hydratedQueries, setHydratedQueries] = useState<Record<string, any> | null>(null) | ||
|
||
// Vars | ||
const isBrowser = typeof window !== 'undefined' | ||
const queryParams = { ...routeParams, ...searchParams, ...nextRouterParams } | ||
const queryKey = routeParamsToQueryKey(queryParams) | ||
const queryInput = routeParamsToQueryInput(queryParams) | ||
|
||
// -- Effects -- | ||
|
||
useEffect(() => { | ||
const ssrData = getSSRData() | ||
if (ssrData) setHydratedData(ssrData) // Save the SSR data to state, removing the SSR data from the DOM | ||
const hydratedQueyClientState = getDehydratedSSRState() | ||
if (hydratedQueyClientState) setHydratedQueries(hydratedQueyClientState) // Save the hydrated state to state, removing the hydrated state from the DOM | ||
}, []) | ||
|
||
// -- Query -- | ||
|
||
const queryConfig = { | ||
queryKey, | ||
queryFn: async () => await routeDataFetcher(queryInput), | ||
initialData: queryBridge.initialData, | ||
} | ||
|
||
// -- Browser -- | ||
|
||
if (isBrowser) { | ||
const hydrationData = hydratedData || getSSRData() | ||
const hydrationState = hydratedQueries || getDehydratedSSRState() | ||
const renderHydrationData = !!hydrationData && !hydratedData // Only render the hydration data if it's not already in state | ||
|
||
const { data: fetcherData } = useQuery({ | ||
...queryConfig, | ||
initialData: { | ||
...queryConfig.initialData, | ||
...hydrationData, | ||
}, | ||
}) | ||
const routeDataProps = fetcherDataToProps(fetcherData as Awaited<ReturnType<Fetcher>>) as Record<string, unknown> // prettier-ignore | ||
|
||
return ( | ||
<HydrationBoundary state={hydrationState}> | ||
{renderHydrationData && <div id="ssr-data" data-ssr={JSON.stringify(fetcherData)} />} | ||
{renderHydrationData && <div id="ssr-hydration-state" data-ssr={JSON.stringify(hydrationState)} />} | ||
<RouteScreen | ||
{...routeDataProps} | ||
queryKey={queryKey} | ||
queryInput={queryInput} | ||
{...screenProps} // @ts-ignore | ||
params={routeParams} | ||
searchParams={searchParams} | ||
/> | ||
</HydrationBoundary> | ||
) | ||
} | ||
|
||
// -- Server -- | ||
|
||
const fetcherData = use(queryClient.fetchQuery(queryConfig)) as Awaited<ReturnType<Fetcher>> | ||
const routeDataProps = fetcherDataToProps(fetcherData) as Record<string, unknown> | ||
const dehydratedState = dehydrate(queryClient) | ||
|
||
return ( | ||
<HydrationBoundary state={dehydratedState}> | ||
{!!fetcherData && <div id="ssr-data" data-ssr={JSON.stringify(fetcherData)} />} | ||
{!!dehydratedState && <div id="ssr-hydration-state" data-ssr={JSON.stringify(dehydratedState)} />} | ||
<RouteScreen | ||
{...routeDataProps} | ||
queryKey={queryKey} | ||
queryInput={queryInput} | ||
{...screenProps} // @ts-ignore | ||
params={routeParams} | ||
searchParams={searchParams} | ||
/> | ||
</HydrationBoundary> | ||
) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,15 @@ | ||
import type { HealthCheckArgs, HealthCheckResponse } from './healthCheck' | ||
import { appConfig } from '../appConfig' | ||
|
||
/** --- healthCheckFetcher() ------------------------------------------------------------------- */ | ||
/** -i- Isomorphic fetcher for our healthCheck() resolver at '/api/health' */ | ||
export const healthCheckFetcher = async (args: HealthCheckArgs) => { | ||
const response = await fetch(`${appConfig.backendURL}/api/health?echo=${args.echo}`, { | ||
method: 'GET', | ||
headers: { | ||
'Content-Type': 'application/json', | ||
}, | ||
}) | ||
const data = await response.json() | ||
return data as HealthCheckResponse | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,35 @@ | ||
import type { NextRequest, NextResponse } from 'next/server' | ||
import type { HealthCheckArgs, HealthCheckResponse } from './healthCheck' | ||
import { appConfig } from '../appConfig' | ||
|
||
/** --- healthCheckFetcher() ------------------------------------------------------------------- */ | ||
/** -i- Isomorphic fetcher for our healthCheck() resolver at '/api/health' */ | ||
export const healthCheckFetcher = async (args: HealthCheckArgs) => { | ||
// Vars | ||
const isServer = typeof window === 'undefined' | ||
|
||
// -- Browser -- | ||
|
||
if (!isServer) { | ||
const response = await fetch(`${appConfig.backendURL}/api/health?echo=${args.echo}`, { | ||
method: 'GET', | ||
headers: { | ||
'Content-Type': 'application/json', | ||
}, | ||
}) | ||
const data = await response.json() | ||
return data as HealthCheckResponse | ||
} | ||
|
||
// -- Server -- | ||
|
||
const { healthCheck } = await import('./healthCheck') | ||
const data = await healthCheck({ | ||
args, | ||
context: { | ||
req: {} as NextRequest, | ||
res: {} as NextResponse, | ||
}, | ||
}) | ||
return data as HealthCheckResponse | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.