react-redux-query
Version:
React hooks and functions for SWR-style data fetching, backed by Redux
326 lines (289 loc) • 13 kB
text/typescript
import { createContext, useContext, useEffect, useRef, useState } from 'react'
import { batch, shallowEqual, useDispatch, useSelector } from 'react-redux'
import { Dispatch } from 'redux'
import { update, updateQueryState } from './actions'
const fetchStateByKey: {
[key: string]: { fetchMonoMs: number; inFlight: { id: string; fetchMonoMs: number }[] } | undefined
} = {}
export const ConfigContext = createContext<{
branchName?: string
saveStaleResponse?: boolean
dedupe?: boolean
dedupeMs?: number
catchError?: boolean
compare?: (prev: QueryState<any>, next: QueryState<any>) => boolean
intervalRedefineFetcher?: boolean
}>({})
export interface QueryBranch<D extends {} = any> {
[key: string]: QueryState<D> | undefined
}
export type PartialQueryState<K extends StateKey[], D extends {}> = Pick<QueryState<D>, 'data' | 'dataMs' | K[number]>
export type QueryState<D extends {} = any> = {
data?: D
dataMs?: number
error?: {}
errorMs?: number
fetchMs?: number
goodFetchMonoMs?: number
inFlight?: { id: string; fetchMonoMs: number }[]
}
export type StateKey = Exclude<keyof QueryState, 'data' | 'dataMs'>
export type QueryResponse<D extends {} = any> = D | { queryData: D | null | undefined } | null | undefined
export interface QueryOptions<D> {
updater?: (data: D | undefined, newData: D) => D | null | undefined
dedupe?: boolean
dedupeMs?: number
catchError?: boolean
saveStaleResponse?: boolean
}
export interface QueryStateOptions<K extends StateKey[], D extends {}> {
stateKeys?: K
compare?: (prev: PartialQueryState<K, D>, next: PartialQueryState<K, D>) => boolean
}
/**
* Calls fetcher and awaits response. Saves data to query branch at key and returns response. What is saved to Redux
* depends on the value of response.queryData:
*
* - If response.queryData isn't set, save response
* - If response.queryData isn't set, and response is null or undefined, don't save anything
* - If response.queryData is set, save queryData
* - If response.queryData is set but is null or undefined, don't save anything
*
* @param key - Key in query branch at which to store response
* @param fetcher - Function that returns response with optional queryData property
* @param options - Options object
* @param options.dispatch - Dispatch function to send data to store (required)
* @param options.updater - If passed, this function takes data currently at key, plus data in response, and returns
* updated data to be saved at key
* @param options.saveStaleResponse - If true, save response even if it's "stale" (false by default)
* @param options.dedupe - If true, don't call fetcher if another request was recently sent for key
* @param options.dedupeMs - If dedupe is true, dedupe behavior active for this many ms (2000 by default)
* @param options.catchError - If true, any error thrown by fetcher is caught and assigned to queryState.error property
* (true by default)
*
* @returns Response, or undefined if fetcher call gets deduped, or undefined if fetcher throws error
*/
export async function query<R extends QueryResponse<{}>>(
key: string,
fetcher: () => Promise<R>,
options: QueryOptions<R extends { queryData: null | undefined | infer D } ? D : NonNullable<R>> & {
dispatch: Dispatch
},
) {
const { dispatch, updater, dedupe = false, dedupeMs = 2000, catchError = true, saveStaleResponse = false } = options
const fetchMs = Date.now()
const fetchMonoMs = Math.round(performance.now())
const fetchStateBefore = fetchStateByKey[key]
// Bail out if dedupe is true and another request was recently sent for key
if (dedupe && fetchStateBefore && fetchMonoMs - fetchStateBefore.fetchMonoMs <= dedupeMs) return
// Create shallow copy of inFlight array so === comparison returns false
const inFlightBefore = [...(fetchStateBefore?.inFlight || [])]
// Create unique id for in-flight request, and add it to inFlight array
let counter = 0
let requestId = ''
while (true) {
const id = `${fetchMonoMs}-${counter}`
if (!inFlightBefore.find((data) => data.id === id)) {
inFlightBefore.push({ id, fetchMonoMs })
requestId = id
break
}
counter += 1
}
// Notify client that fetcher will be called
fetchStateByKey[key] = { fetchMonoMs, inFlight: inFlightBefore }
dispatch(updateQueryState({ key, state: { fetchMs, inFlight: inFlightBefore } }))
// Call fetcher
let response = undefined as R
let error: undefined | {}
try {
response = await fetcher()
} catch (e) {
error = e || {}
}
// Remove request from inFlight array
const afterMs = Date.now()
const fetchState = fetchStateByKey[key]
// Call filter to remove completed request; filter also ensures === comparison returns false with old inFlight array
const inFlight = (fetchState?.inFlight || []).filter((data) => data.id !== requestId)
fetchStateByKey[key] = { fetchMonoMs: fetchState?.fetchMonoMs || fetchMonoMs, inFlight }
// If error was thrown, notify client and bail out
if (error) {
dispatch(updateQueryState({ key, state: { error, errorMs: afterMs, inFlight } }))
if (catchError) return
throw error
}
const saveData = (data: {}) => {
if (updater) {
// Results in only one rerender, not two: https://react-redux.js.org/api/batch#batch
batch(() => {
dispatch(
updateQueryState({
key,
state: { dataMs: afterMs, goodFetchMonoMs: fetchMonoMs, inFlight },
}),
)
// @ts-ignore; newData property only for internal use, including it in Update interface would just be confusing
dispatch(update({ key, updater, newData: data }))
})
} else {
dispatch(
updateQueryState({
key,
state: { data: { ...data }, dataMs: afterMs, goodFetchMonoMs: fetchMonoMs, inFlight },
options: { saveStaleResponse },
}),
)
}
}
if (response?.hasOwnProperty('queryData')) {
const { queryData } = response as { queryData?: {} | null }
if (queryData !== null && queryData !== undefined) {
// If response.queryData is set and is neither null nor undefined, save response.queryData
saveData(queryData)
} else {
// If response.queryData is set but is null or undefined, save response as error
dispatch(
updateQueryState({
key,
state: { error: { ...response } as {}, errorMs: afterMs, inFlight },
}),
)
}
} else if (response !== null && response !== undefined) {
// If saveData.queryData isn't set, only save response if it's neither null nor undefined
saveData(response as {})
}
return response
}
/**
* Hook calls fetcher and saves data to query branch at key. Immediately returns query state (including data, dataMs,
* dataMonoMs) at key, and subscribes to changes in this query state.
*
* Data is only refetched if key, intervalMs, or refetchKey changes; passing in a new fetcher function alone doesn't
* refetch data.
*
* @param key - Key in query branch at which to store data; if null/undefined, fetcher not called
* @param fetcher - Function that returns response with optional queryData property; if null/undefined, fetcher not
* called
* @param options - Options object
* @param options.intervalMs - Interval between end of fetcher call and next fetcher call
* @param options.intervalRedefineFetcher - If true, fetcher is redefined each time it's called on interval, by forcing
* component to rerender (false by default)
* @param options.noRefetch - If true, don't refetch if there's already data at key
* @param options.noRefetchMs - If noRefetch is true, noRefetch behavior active for this many ms (forever by default)
* @param options.refetchKey - Pass in new value to force refetch without changing key
* @param options.updater - If passed, this function takes data currently at key, plus data in response, and returns
* updated data to be saved at key
* @param options.saveStaleResponse - If true, save response even if it's "stale" (false by default)
* @param options.dedupe - If true, don't call fetcher if another request was recently sent for key
* @param options.dedupeMs - If dedupe is true, dedupe behavior active for this many ms (2000 by default)
* @param options.catchError - If true, any error thrown by fetcher is caught and assigned to queryState.error property
* (true by default)
* @param options.stateKeys - Additional keys in query state to include in return value (only data and dataMs included
* by default)
* @param options.compare - Equality function compares previous query state with next query state; if it returns false,
* component rerenders, else it doesn't; uses shallowEqual by default
*
* @returns Query state at key, with subset of properties specified by stateKeys
*/
export function useQuery<K extends StateKey[] = [], D extends {} = any>(
key: string | null | undefined,
fetcher: (() => Promise<QueryResponse<D>>) | null | undefined,
options: QueryOptions<D> &
QueryStateOptions<K, D> & {
intervalMs?: number
intervalRedefineFetcher?: boolean
noRefetch?: boolean
noRefetchMs?: number
refetchKey?: any
} = {},
) {
const {
stateKeys,
compare,
intervalMs = 0,
intervalRedefineFetcher,
noRefetch = false,
noRefetchMs = 0,
refetchKey,
updater,
...rest
} = options
const config = useContext(ConfigContext)
const dispatch = useDispatch()
const [intervalId, setIntervalId] = useState(0)
const intervalTimeoutIdRef = useRef<number>()
const redefineFetcher = intervalRedefineFetcher ?? config.intervalRedefineFetcher ?? false
const queryState = useQueryState<K, D>(key, { stateKeys, compare })
useEffect(() => {
// If we have pending interval call to query, clear it; we're about to query again anyway
clearTimeout(intervalTimeoutIdRef.current)
// Should we return early?
if (queryState.data && noRefetch) {
// Defensive code; can't be sure dataMs is a number (user could use their own reducer)
if (noRefetchMs <= 0 || typeof queryState.dataMs !== 'number') return
// User specified a positive value for noRefetchMs; determine if we should we refetch or not
if (Date.now() - queryState.dataMs <= noRefetchMs) return
}
if (key === null || key === undefined || !fetcher) return
const doQuery = async () => {
await query(key, fetcher, {
...config,
...rest,
updater: updater as QueryOptions<any>['updater'],
dispatch,
})
if (intervalMs <= 0) return
// Force this effect to run again after intervalMs; "pseudo-recursive" call means call stack doesn't grow
intervalTimeoutIdRef.current = window.setTimeout(() => {
if (redefineFetcher) setIntervalId((id) => id + 1)
else doQuery()
}, intervalMs)
}
doQuery()
}, [key, intervalMs, redefineFetcher, refetchKey, intervalId]) // eslint-disable-line
// Also clear interval when component unmounts
useEffect(() => {
return () => {
clearTimeout(intervalTimeoutIdRef.current)
}
}, [])
return queryState
}
/**
* Hook retrieves query state for key from from Redux, and subscribes to changes in query state. State object includes
* only data and dataMs properties by default, and subscribes to changes in these properties only, unless additional
* stateKeys passed.
*
* @param key - Key in query branch
* @param options - Options object
* @param options.stateKeys - Additional keys in query state to include in return value (only data and dataMs included
* by default)
* @param options.compare - Equality function compares previous query state with next query state; if it returns false,
* component rerenders, else it doesn't; uses shallowEqual by default
*
* @returns Query state at key, with subset of properties specified by stateKeys
*/
export function useQueryState<K extends StateKey[] = [], D extends {} = any>(
key: string | null | undefined,
options: QueryStateOptions<K, D> = {},
) {
// K before D in useQueryState signature, because K can be inferred, while D can't
const { branchName = 'query', compare: configCompare } = useContext(ConfigContext)
return useSelector((state: { query: QueryBranch<D> }) => {
const stateKeys = (options.stateKeys || []) as K
// Return type picks QueryState properties specified in options.stateKeys, in addition to data and dataMs
const partialQueryState = {} as PartialQueryState<K, D>
if (!key) return partialQueryState
const queryState = state[branchName as 'query'][key]
if (!queryState) return partialQueryState
partialQueryState.data = queryState.data
partialQueryState.dataMs = queryState.dataMs
for (const stateKey of stateKeys) {
// @ts-ignore
partialQueryState[stateKey] = queryState[stateKey]
}
return partialQueryState
}, options.compare || (configCompare as QueryStateOptions<K, D>['compare']) || shallowEqual)
}