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
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions