Skip to content

[Proposal] Lookup Implementation #219

@TimVanOnckelen

Description

@TimVanOnckelen

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 casts

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions