-
Notifications
You must be signed in to change notification settings - Fork 92
Open
Description
While the team is still investigating lookup usability, we'd like to propose our interim solution. We built a custom hook that allows multiple lookups to be chained and extended on a single query. It leverages TanStack Query for optimized data fetching, works with any Dataverse table, and automatically casts the return type. Suggestions more than welcome!
import { useState, useEffect, useRef, useMemo } from "react"
import { useQueryClient } from "@tanstack/react-query"
/** Options returned by a query factory — passed directly to queryClient.fetchQuery */
export interface LookupQueryOptions<TResult = unknown> {
queryKey: readonly unknown[]
queryFn: () => Promise<TResult | null>
staleTime?: number
}
/**
* Describes a single lookup to resolve on each item in the query data.
* Lookups can be nested: the `lookups` array is resolved against the fetched record.
*
* @template TResult - Shape of the resolved record.
*/
export interface LookupDefinition<TResult = unknown> {
/** Field on each data item that holds the foreign-key ID (e.g. "_dvw_stoel_value"). */
lookupField: string
/**
* Query options factory — receives the foreign-key ID and returns
* { queryKey, queryFn, staleTime? }.
* Export this function from your useXxx hook (e.g. `stoelQueryOptions`).
*
* @example
* { lookupField: "_dvw_stoel_value", query: stoelQueryOptions, mapTo: "stoel" }
*/
query: (id: string) => LookupQueryOptions<TResult>
/**
* Property name to write the resolved record onto the enriched item.
* Defaults to the string value of `lookupField`.
*/
mapTo?: string
// eslint-disable-next-line @typescript-eslint/no-explicit-any
lookups?: LookupDefinition<any>[]
}
type QueryResultLike<TData> = {
data: TData[] | undefined
isLoading: boolean
refetch?: () => void
}
// ---- Helper types that build the auto-typed return value ----
/** Extracts TResult from a query options factory `(id: string) => LookupQueryOptions<TResult>` */
type QueryResultType<Q> = Q extends (id: string) => LookupQueryOptions<infer R> ? R : never
/** The output key for a lookup: `mapTo` when provided, otherwise `lookupField` */
type LookupKey<L> = L extends { mapTo: infer M extends string }
? M
: L extends { lookupField: infer F extends string }
? F
: never
/** Extracts the nested `lookups` array type from a LookupDefinition (if present) */
type NestedLookups<L> = L extends { lookups: infer NL extends readonly LookupDefinition<unknown>[] }
? NL
: never
/** The resolved value of a single lookup, including any nested lookups merged into it */
type ResolvedValue<L> =
[NestedLookups<L>] extends [never]
? QueryResultType<L extends { query: infer Q } ? Q : never> | null
: (QueryResultType<L extends { query: infer Q } ? Q : never> & ResolvedLookups<NestedLookups<L>>) | null
/** Turns a single LookupDefinition into `{ [mapTo]: ResolvedValue }` */
type MappedLookup<L> = {
[K in LookupKey<L>]: ResolvedValue<L>
}
/** Converts a union of objects into their intersection */
type UnionToIntersection<U> = (U extends unknown ? (k: U) => void : never) extends (k: infer I) => void ? I : never
/** Merges all lookup definitions in a tuple into a single typed object */
export type ResolvedLookups<TLookups extends readonly LookupDefinition<unknown>[]> =
UnionToIntersection<MappedLookup<TLookups[number]>>
// ---- Lookup key stability helper ----
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function serializeLookups(lookups: LookupDefinition<any>[]): unknown {
return lookups.map((l) => ({
f: l.lookupField,
m: l.mapTo,
n: l.lookups ? serializeLookups(l.lookups) : undefined,
}))
}
// ---- Core async resolver (recursive) ----
async function resolveItem(
item: Record<string, unknown>,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
lookups: LookupDefinition<any>[],
queryClient: ReturnType<typeof useQueryClient>
): Promise<Record<string, unknown>> {
const enriched = { ...item }
await Promise.all(
lookups.map(async (lookup) => {
const id = enriched[lookup.lookupField] as string | undefined
const outputKey = lookup.mapTo ?? String(lookup.lookupField)
if (!id) {
enriched[outputKey] = null
return
}
const opts = lookup.query(id)
const fetched = await queryClient.fetchQuery({
queryKey: opts.queryKey,
queryFn: opts.queryFn,
staleTime: opts.staleTime ?? 5 * 60 * 1000,
}) as Record<string, unknown> | null
if (fetched && lookup.lookups?.length) {
// Recursively resolve nested lookups against the fetched record
enriched[outputKey] = await resolveItem(fetched, lookup.lookups, queryClient)
} else {
enriched[outputKey] = fetched
}
})
)
return enriched
}
// ---- Hook ----
/**
* Resolves one or more lookups for every item returned by a query.
* Pass the result of a useXxx hook as the first argument.
*
* Lookups can be **nested** — resolved fields on the fetched record are enriched recursively.
* All resolved fields are **fully typed** — no manual `as XxxModel` casts are needed.
*/
export function useLookups<TData extends object, const TLookups extends readonly LookupDefinition<unknown>[]>(
{ data: rawData, isLoading: queryLoading, refetch }: QueryResultLike<TData>,
lookups: TLookups
): { data: (TData & ResolvedLookups<TLookups>)[] | undefined; isLoading: boolean; refetch: (() => void) | undefined } {
const queryClient = useQueryClient()
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const [resolved, setResolved] = useState<any[]>([])
const [isLookupLoading, setIsLookupLoading] = useState(false)
// Stable ref so the effect always has the latest lookups without depending on the array reference
const lookupsRef = useRef(lookups)
useEffect(() => { lookupsRef.current = lookups })
// Only re-run the effect when the lookup *configuration* actually changes (including nested),
// not when the caller passes a new array literal with the same content every render.
const lookupsKey = useMemo(
() => JSON.stringify(serializeLookups([...lookups])),
// eslint-disable-next-line react-hooks/exhaustive-deps
[lookups]
)
const originalData = rawData ?? []
useEffect(() => {
const currentLookups = lookupsRef.current
if (originalData.length === 0 || currentLookups.length === 0) {
setResolved(originalData.map((item) => ({ ...item })))
setIsLookupLoading(false)
return
}
let cancelled = false
setIsLookupLoading(true)
async function resolve() {
const enriched = await Promise.all(
originalData.map((item) =>
resolveItem(item as Record<string, unknown>, [...currentLookups], queryClient)
)
)
if (!cancelled) {
setResolved(enriched)
setIsLookupLoading(false)
}
}
resolve().catch((err) => {
console.error("Error resolving lookups:", err)
if (!cancelled) {
setResolved(originalData.map((item) => ({ ...item })))
setIsLookupLoading(false)
}
})
return () => { cancelled = true }
}, [originalData, lookupsKey])
const data = useMemo(
() => (rawData === undefined ? undefined : resolved),
[rawData, resolved]
)
return { data: data as (TData & ResolvedLookups<TLookups>)[] | undefined, isLoading: queryLoading || isLookupLoading, refetch }
}Usage example on a query:
const { data, isLoading } = useLookups(
useStoeltoewijzingen({ medewerkerid }),
[{
lookupField: "_dvw_stoel_value",
query: stoelQueryOptions,
mapTo: "stoel",
lookups: [{ lookupField: "_dvw_standplaats_value", query: standplaatsQueryOptions, mapTo: "standplaats" }],
}]
)
// toewijzing.stoel?.standplaats?.dvw_standplaats ← fully typed, no castsReactions are currently unavailable
Metadata
Metadata
Assignees
Labels
No labels