Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

plugin: React-Query for Data Fetching #6

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/expo/app/(main)/index.tsx
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
2 changes: 1 addition & 1 deletion apps/next/app/(main)/page.tsx
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 features/app-core/context/UniversalQueryClientProvider.tsx
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 features/app-core/navigation/UniversalRouteScreen.helpers.ts
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),
}
}
45 changes: 45 additions & 0 deletions features/app-core/navigation/UniversalRouteScreen.tsx
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 features/app-core/navigation/UniversalRouteScreen.web.tsx
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>
)
}
4 changes: 3 additions & 1 deletion features/app-core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@
"name": "@app/core",
"version": "1.0.0",
"private": true,
"dependencies": {},
"dependencies": {
"@tanstack/react-query": "^5.29.2"
},
"devDependencies": {
"typescript": "5.3.3"
},
Expand Down
15 changes: 15 additions & 0 deletions features/app-core/resolvers/healthCheck.fetcher.ts
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
}
35 changes: 35 additions & 0 deletions features/app-core/resolvers/healthCheck.fetcher.web.ts
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
}
32 changes: 30 additions & 2 deletions features/app-core/resolvers/healthCheck.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,43 @@ const ALIVE_SINCE = new Date()

/* --- Types ----------------------------------------------------------------------------------- */

type HealthCheckArgs = {
export type HealthCheckArgs = {
echo?: string
}

type HealthCheckInputs = {
export type HealthCheckInputs = {
args: HealthCheckArgs,
context: RequestContext
}

export type HealthCheckResponse = {
echo?: string
status: 'OK'
alive: boolean
kicking: boolean
now: string
aliveTime: number
aliveSince: string
serverTimezone: string
requestHost: string
requestProtocol: string
requestURL: string
baseURL: string
backendURL: string
apiURL: string
graphURL: string
port: number | null
debugPort: number | null
nodeVersion: string
v8Version: string
systemArch: string
systemPlatform: string
systemRelease: string
systemFreeMemory: number
systemTotalMemory: number
systemLoadAverage: number[]
}

/** --- healthCheck() -------------------------------------------------------------------------- */
/** -i- Check the health status of the server. Includes relevant urls, server time(zone), versions and more */
export const healthCheck = async ({ args, context }: HealthCheckInputs) => {
Expand Down
Loading