Skip to content

Commit 4605f5f

Browse files
committed
feat: Add UniversalRouteScreen data fetching with react-query
1 parent 11980c6 commit 4605f5f

File tree

14 files changed

+419
-8
lines changed

14 files changed

+419
-8
lines changed

apps/expo/app/(main)/index.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
1-
import HomeScreen from '@app/core/screens/HomeScreen'
1+
import HomeScreen from '@app/core/routes/index'
22

33
export default HomeScreen

apps/next/app/(main)/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
'use client'
2-
import HomeScreen from '@app/core/screens/HomeScreen'
2+
import HomeScreen from '@app/core/routes/index'
33

44
export default HomeScreen
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
'use client'
2+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
3+
4+
/* --- Constants ------------------------------------------------------------------------------- */
5+
6+
let clientSideQueryClient: QueryClient | undefined = undefined
7+
8+
/** --- makeQueryClient() ---------------------------------------------------------------------- */
9+
/** -i- Build a queryclient to be used either client-side or server-side */
10+
export const makeQueryClient = () => {
11+
const oneMinute = 1000 * 60
12+
const queryClient = new QueryClient({
13+
defaultOptions: {
14+
queries: {
15+
// With SSR, we usually want to set some default staleTime
16+
// above 0 to avoid refetching immediately on the client
17+
staleTime: oneMinute,
18+
},
19+
},
20+
})
21+
return queryClient
22+
}
23+
24+
/** --- getQueryClient() ----------------------------------------------------------------------- */
25+
/** -i- Always makes a new query client on the server, but reuses an existing client if found in browser or mobile */
26+
export const getQueryClient = () => {
27+
// Always create a new query client on the server, so no caching is shared between requests
28+
const isServer = typeof window === 'undefined'
29+
if (isServer) return makeQueryClient()
30+
// On the browser or mobile, make a new client if we don't already have one
31+
// This is important so we don't re-make a new client if React suspends during initial render.
32+
// Might not be needed if we have a suspense boundary below the creation of the query client though.
33+
if (!clientSideQueryClient) clientSideQueryClient = makeQueryClient()
34+
return clientSideQueryClient
35+
}
36+
37+
/** --- <UniversalQueryClientProvider/> ----------------------------------------------------------------- */
38+
/** -i- Provides a universal queryclient to be used either client-side or server-side */
39+
export const UniversalQueryClientProvider = ({ children }: { children: React.ReactNode }) => {
40+
const queryClient = getQueryClient()
41+
return (
42+
<QueryClientProvider client={queryClient}>
43+
{children}
44+
</QueryClientProvider>
45+
)
46+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
'use client'
2+
import type { Query, QueryKey } from '@tanstack/react-query'
3+
import { queryBridge } from '../screens/HomeScreen'
4+
5+
/* --- Types ----------------------------------------------------------------------------------- */
6+
7+
export type QueryFn = (args: Record<string, unknown>) => Promise<Record<string, unknown>>
8+
9+
export type QueryBridgeConfig<Fetcher extends QueryFn> = {
10+
/** -i- Function to turn any route params into the query key for the `routeDataFetcher()` query */
11+
routeParamsToQueryKey: (routeParams: Partial<Parameters<Fetcher>[0]>) => QueryKey
12+
/** -i- Function to turn any route params into the input args for the `routeDataFetcher()` query */
13+
routeParamsToQueryInput: (routeParams: Partial<Parameters<Fetcher>[0]>) => Parameters<Fetcher>[0]
14+
/** -i- Fetcher to prefetch data for the Page and QueryClient during SSR, or fetch it clientside if browser / mobile */
15+
routeDataFetcher: Fetcher
16+
/** -i- Function transform fetcher data into props */
17+
fetcherDataToProps?: (data: Awaited<ReturnType<Fetcher>>) => Record<string, unknown>
18+
/** -i- Initial data provided to the QueryClient */
19+
initialData?: ReturnType<Fetcher>
20+
}
21+
22+
export type UniversalRouteProps<Fetcher extends QueryFn> = {
23+
/** -i- Optional params passed by the Next.js app router, in Expo we get these from `useRouteParams()` */
24+
params?: Partial<Parameters<Fetcher>[0]>
25+
/** -i- Optional search params passed by the Next.js app router, in Expo we get these from `useRouteParams()` */
26+
searchParams?: Partial<Parameters<Fetcher>[0]>
27+
/** -i- Configuration for the query bridge */
28+
queryBridge: QueryBridgeConfig<Fetcher>
29+
/** -i- The screen to render for this route */
30+
routeScreen: React.ComponentType
31+
}
32+
33+
export type HydratedRouteProps<
34+
QueryBridge extends QueryBridgeConfig<QueryFn>
35+
> = ReturnType<QueryBridge['fetcherDataToProps']> & {
36+
/** -i- The route key for the query */
37+
queryKey: QueryKey
38+
/** -i- The input args for the query */
39+
queryInput: Parameters<QueryBridge['routeDataFetcher']>[0]
40+
/** -i- The route params passed by the Next.js app router, in Expo we get these from `useRouteParams()` */
41+
params: Partial<Parameters<QueryBridge['routeDataFetcher']>[0]>
42+
/** -i- The search params passed by the Next.js app router, in Expo we get these from `useRouteParams()` */
43+
searchParams: Partial<Parameters<QueryBridge['routeDataFetcher']>[0]>
44+
}
45+
46+
/** --- createQueryBridge() -------------------------------------------------------------------- */
47+
/** -i- Util to create a typed bridge between a fetcher and a route's props */
48+
export const createQueryBridge = <QueryBridge extends QueryBridgeConfig<QueryFn>>(
49+
queryBridge: QueryBridge
50+
) => {
51+
type FetcherData = Awaited<ReturnType<QueryBridge['routeDataFetcher']>>
52+
type ReturnTypeOfFunction<F, A> = F extends ((args: A) => infer R) ? R : FetcherData
53+
type RoutePropsFromFetcher = ReturnTypeOfFunction<QueryBridge['fetcherDataToProps'], FetcherData>
54+
const fetcherDataToProps = queryBridge.fetcherDataToProps || ((data: FetcherData) => data)
55+
return {
56+
...queryBridge,
57+
fetcherDataToProps: fetcherDataToProps as ((data: FetcherData) => RoutePropsFromFetcher),
58+
}
59+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
'use client'
2+
import { useQuery } from '@tanstack/react-query'
3+
import type { UniversalRouteProps, QueryFn } from './UniversalRouteScreen.helpers'
4+
import { useRouteParams } from './useRouteParams'
5+
6+
/** --- <UniversalRouteScreen/> -------------------------------------------------------------------- */
7+
/** -i- Universal Route Wrapper to provide query data on mobile, the browser and during server rendering */
8+
export const UniversalRouteScreen = <Fetcher extends QueryFn>(props: UniversalRouteProps<Fetcher>) => {
9+
// Props
10+
const { params: routeParams, searchParams, queryBridge, routeScreen: RouteScreen, ...screenProps } = props
11+
const { routeParamsToQueryKey, routeParamsToQueryInput, routeDataFetcher } = queryBridge
12+
const fetcherDataToProps = queryBridge.fetcherDataToProps || ((data: ReturnType<Fetcher>) => data)
13+
14+
// Hooks
15+
const expoRouterParams = useRouteParams(props)
16+
17+
// Vars
18+
const queryParams = { ...routeParams, ...searchParams, ...expoRouterParams }
19+
const queryKey = routeParamsToQueryKey(queryParams)
20+
const queryInput = routeParamsToQueryInput(queryParams)
21+
22+
// -- Query --
23+
24+
const queryConfig = {
25+
queryKey,
26+
queryFn: async () => await routeDataFetcher(queryInput),
27+
initialData: queryBridge.initialData,
28+
}
29+
30+
// -- Mobile --
31+
32+
const { data: fetcherData } = useQuery(queryConfig)
33+
const routeDataProps = fetcherDataToProps(fetcherData) as Record<string, unknown>
34+
35+
return (
36+
<RouteScreen
37+
{...routeDataProps}
38+
queryKey={queryKey}
39+
queryInput={queryInput}
40+
{...screenProps} // @ts-ignore
41+
params={routeParams}
42+
searchParams={searchParams}
43+
/>
44+
)
45+
}
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
'use client'
2+
import { use, useState, useEffect } from 'react'
3+
import { useQueryClient, useQuery, dehydrate, HydrationBoundary } from '@tanstack/react-query'
4+
import type { UniversalRouteProps, QueryFn } from './UniversalRouteScreen.helpers'
5+
import { useRouteParams } from './useRouteParams'
6+
7+
/* --- Helpers --------------------------------------------------------------------------------- */
8+
9+
const getSSRData = () => {
10+
const $ssrData = document.getElementById('ssr-data')
11+
const ssrDataText = $ssrData?.getAttribute('data-ssr')
12+
const ssrData = ssrDataText ? (JSON.parse(ssrDataText) as Record<string, any>) : null
13+
return ssrData
14+
}
15+
16+
const getDehydratedSSRState = () => {
17+
const $ssrHydrationState = document.getElementById('ssr-hydration-state')
18+
const ssrHydrationStateText = $ssrHydrationState?.getAttribute('data-ssr')
19+
const ssrHydrationState = ssrHydrationStateText ? (JSON.parse(ssrHydrationStateText) as Record<string, any>) : null
20+
return ssrHydrationState
21+
}
22+
23+
/** --- <UniversalRouteScreen/> ---------------------------------------------------------------- */
24+
/** -i- Universal Route Wrapper to provide query data on mobile, the browser and during server rendering */
25+
export const UniversalRouteScreen = <Fetcher extends QueryFn>(props: UniversalRouteProps<Fetcher>) => {
26+
// Props
27+
const { params: routeParams, searchParams, queryBridge, routeScreen: RouteScreen, ...screenProps } = props
28+
const { routeParamsToQueryKey, routeParamsToQueryInput, routeDataFetcher } = queryBridge
29+
const fetcherDataToProps = queryBridge.fetcherDataToProps || ((data: ReturnType<Fetcher>) => data)
30+
31+
// Hooks
32+
const nextRouterParams = useRouteParams(props)
33+
34+
// Context
35+
const queryClient = useQueryClient()
36+
37+
// State
38+
const [hydratedData, setHydratedData] = useState<Record<string, any> | null>(null)
39+
const [hydratedQueries, setHydratedQueries] = useState<Record<string, any> | null>(null)
40+
41+
// Vars
42+
const isBrowser = typeof window !== 'undefined'
43+
const queryParams = { ...routeParams, ...searchParams, ...nextRouterParams }
44+
const queryKey = routeParamsToQueryKey(queryParams)
45+
const queryInput = routeParamsToQueryInput(queryParams)
46+
47+
// -- Effects --
48+
49+
useEffect(() => {
50+
const ssrData = getSSRData()
51+
if (ssrData) setHydratedData(ssrData) // Save the SSR data to state, removing the SSR data from the DOM
52+
const hydratedQueyClientState = getDehydratedSSRState()
53+
if (hydratedQueyClientState) setHydratedQueries(hydratedQueyClientState) // Save the hydrated state to state, removing the hydrated state from the DOM
54+
}, [])
55+
56+
// -- Query --
57+
58+
const queryConfig = {
59+
queryKey,
60+
queryFn: async () => await routeDataFetcher(queryInput),
61+
initialData: queryBridge.initialData,
62+
}
63+
64+
// -- Browser --
65+
66+
if (isBrowser) {
67+
const hydrationData = hydratedData || getSSRData()
68+
const hydrationState = hydratedQueries || getDehydratedSSRState()
69+
const renderHydrationData = !!hydrationData && !hydratedData // Only render the hydration data if it's not already in state
70+
71+
const { data: fetcherData } = useQuery({
72+
...queryConfig,
73+
initialData: {
74+
...queryConfig.initialData,
75+
...hydrationData,
76+
},
77+
})
78+
const routeDataProps = fetcherDataToProps(fetcherData as Awaited<ReturnType<Fetcher>>) as Record<string, unknown> // prettier-ignore
79+
80+
return (
81+
<HydrationBoundary state={hydrationState}>
82+
{renderHydrationData && <div id="ssr-data" data-ssr={JSON.stringify(fetcherData)} />}
83+
{renderHydrationData && <div id="ssr-hydration-state" data-ssr={JSON.stringify(hydrationState)} />}
84+
<RouteScreen
85+
{...routeDataProps}
86+
queryKey={queryKey}
87+
queryInput={queryInput}
88+
{...screenProps} // @ts-ignore
89+
params={routeParams}
90+
searchParams={searchParams}
91+
/>
92+
</HydrationBoundary>
93+
)
94+
}
95+
96+
// -- Server --
97+
98+
const fetcherData = use(queryClient.fetchQuery(queryConfig)) as Awaited<ReturnType<Fetcher>>
99+
const routeDataProps = fetcherDataToProps(fetcherData) as Record<string, unknown>
100+
const dehydratedState = dehydrate(queryClient)
101+
102+
return (
103+
<HydrationBoundary state={dehydratedState}>
104+
{!!fetcherData && <div id="ssr-data" data-ssr={JSON.stringify(fetcherData)} />}
105+
{!!dehydratedState && <div id="ssr-hydration-state" data-ssr={JSON.stringify(dehydratedState)} />}
106+
<RouteScreen
107+
{...routeDataProps}
108+
queryKey={queryKey}
109+
queryInput={queryInput}
110+
{...screenProps} // @ts-ignore
111+
params={routeParams}
112+
searchParams={searchParams}
113+
/>
114+
</HydrationBoundary>
115+
)
116+
}

features/app-core/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22
"name": "@app/core",
33
"version": "1.0.0",
44
"private": true,
5-
"dependencies": {},
5+
"dependencies": {
6+
"@tanstack/react-query": "^5.29.2"
7+
},
68
"devDependencies": {
79
"typescript": "5.3.3"
810
},
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import type { HealthCheckArgs, HealthCheckResponse } from './healthCheck'
2+
import { appConfig } from '../appConfig'
3+
4+
/** --- healthCheckFetcher() ------------------------------------------------------------------- */
5+
/** -i- Isomorphic fetcher for our healthCheck() resolver at '/api/health' */
6+
export const healthCheckFetcher = async (args: HealthCheckArgs) => {
7+
const response = await fetch(`${appConfig.backendURL}/api/health?echo=${args.echo}`, {
8+
method: 'GET',
9+
headers: {
10+
'Content-Type': 'application/json',
11+
},
12+
})
13+
const data = await response.json()
14+
return data as HealthCheckResponse
15+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import type { NextRequest, NextResponse } from 'next/server'
2+
import type { HealthCheckArgs, HealthCheckResponse } from './healthCheck'
3+
import { appConfig } from '../appConfig'
4+
5+
/** --- healthCheckFetcher() ------------------------------------------------------------------- */
6+
/** -i- Isomorphic fetcher for our healthCheck() resolver at '/api/health' */
7+
export const healthCheckFetcher = async (args: HealthCheckArgs) => {
8+
// Vars
9+
const isServer = typeof window === 'undefined'
10+
11+
// -- Browser --
12+
13+
if (!isServer) {
14+
const response = await fetch(`${appConfig.backendURL}/api/health?echo=${args.echo}`, {
15+
method: 'GET',
16+
headers: {
17+
'Content-Type': 'application/json',
18+
},
19+
})
20+
const data = await response.json()
21+
return data as HealthCheckResponse
22+
}
23+
24+
// -- Server --
25+
26+
const { healthCheck } = await import('./healthCheck')
27+
const data = await healthCheck({
28+
args,
29+
context: {
30+
req: {} as NextRequest,
31+
res: {} as NextResponse,
32+
},
33+
})
34+
return data as HealthCheckResponse
35+
}

features/app-core/resolvers/healthCheck.ts

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,43 @@ const ALIVE_SINCE = new Date()
99

1010
/* --- Types ----------------------------------------------------------------------------------- */
1111

12-
type HealthCheckArgs = {
12+
export type HealthCheckArgs = {
1313
echo?: string
1414
}
1515

16-
type HealthCheckInputs = {
16+
export type HealthCheckInputs = {
1717
args: HealthCheckArgs,
1818
context: RequestContext
1919
}
2020

21+
export type HealthCheckResponse = {
22+
echo?: string
23+
status: 'OK'
24+
alive: boolean
25+
kicking: boolean
26+
now: string
27+
aliveTime: number
28+
aliveSince: string
29+
serverTimezone: string
30+
requestHost: string
31+
requestProtocol: string
32+
requestURL: string
33+
baseURL: string
34+
backendURL: string
35+
apiURL: string
36+
graphURL: string
37+
port: number | null
38+
debugPort: number | null
39+
nodeVersion: string
40+
v8Version: string
41+
systemArch: string
42+
systemPlatform: string
43+
systemRelease: string
44+
systemFreeMemory: number
45+
systemTotalMemory: number
46+
systemLoadAverage: number[]
47+
}
48+
2149
/** --- healthCheck() -------------------------------------------------------------------------- */
2250
/** -i- Check the health status of the server. Includes relevant urls, server time(zone), versions and more */
2351
export const healthCheck = async ({ args, context }: HealthCheckInputs) => {

0 commit comments

Comments
 (0)