Retour au catalogue
Power PlatformPerso

dataverse-lookups

Handle Dataverse lookup fields — reading GUIDs, writing with @odata.bind, and resolving GUIDs to display names on-demand. Use when reading or writing any relationship between Dataverse tables, or building a useLookupResolver hook. Naming conventions and patterns are fixed by Dataverse — only table/field names vary.

DataverseLookupsOData
Chemin
~/.claude/skills/dataverse-lookups
Modifié
5 avril 2026 à 11:53

Dataverse Lookup Fields

Lookup fields store a GUID reference to a record in another table. The naming conventions and OData syntax are fixed by Dataverse — only the field and table names change per project.


Naming Conventions

Dataverse uses different field names for reading vs. writing.

Reading (GET responses)

Lookup fields come back as _<schemaname>_value — a GUID string:

_createdby_value              → GUID of the SystemUser who created the record
_ownerid_value                → GUID of the owning user or team
_parentcustomerid_value       → GUID of the related Account/Contact
_msa_managingpartnerid_value  → GUID of the managing Account (custom field example)
_transactioncurrencyid_value  → GUID of the currency record

Include these in your select array:

await EntityService.getAll({
  select: ['entityid', 'name', '_relatedtable_value'],
});

Writing (POST / PATCH)

Use OData bind syntax with the navigation property name (no underscore prefix, no _value suffix):

// Set a lookup — format: "<NavigationProperty>@odata.bind": "/<entitysetname>(<guid>)"
payload['parentcustomerid_account@odata.bind'] = `/accounts(${accountId})`;
payload['TransactionCurrencyId@odata.bind'] = `/transactioncurrencies(${currencyId})`;
payload['msa_managingpartnerid@odata.bind'] = `/accounts(${partnerId})`;

// Clear a lookup (set to null)
payload['parentcustomerid_account@odata.bind'] = null;

Key difference: read name is _<field>_value, write name is <field>@odata.bind (no underscores, no _value).


On-Demand Resolution Pattern

To display a related record's name (e.g. account name, user fullname), fetch only the specific record — never load the entire related table.

Wrong approach — do NOT do this

// Bad: loads thousands of records to find one
const allAccounts = await AccountsService.getAll();
const account = allAccounts.value.find(a => a.accountid === contact._parentcustomerid_value);

Correct approach

// Good: fetch only the one record needed
const result = await AccountsService.get(contact._parentcustomerid_value, {
  select: ['accountid', 'name'],
});
const accountName = result.value?.name ?? '';

useLookupResolver Hook (Generic Template)

Replace Entity, ResolvedLookups, and the lookup fields with your actual fields.

// src/hooks/useLookupResolver.ts
import { useState, useEffect } from 'react';
import { AccountsService } from '../generated/services/AccountsService';
import { SystemusersService } from '../generated/services/SystemusersService';
// import other services as needed

export interface ResolvedLookups {
  // One string per lookup field you want to display
  accountName: string;
  createdByName: string;
  // add more as needed
}

const EMPTY: ResolvedLookups = {
  accountName: '',
  createdByName: '',
};

export function useLookupResolver(entity: YourEntity | null) {
  const [resolvedLookups, setResolvedLookups] = useState<ResolvedLookups>(EMPTY);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    if (!entity) {
      setResolvedLookups(EMPTY);
      return;
    }

    let cancelled = false;

    async function resolve() {
      setLoading(true);
      const resolved: ResolvedLookups = { ...EMPTY };

      // Run all lookups in parallel for performance
      await Promise.all([
        entity._parentcustomerid_value &&
          AccountsService.get(entity._parentcustomerid_value, { select: ['name'] })
            .then(r => { if (!cancelled) resolved.accountName = r.value?.name ?? ''; })
            .catch(() => {}),

        entity._createdby_value &&
          SystemusersService.get(entity._createdby_value, { select: ['fullname'] })
            .then(r => { if (!cancelled) resolved.createdByName = r.value?.fullname ?? ''; })
            .catch(() => {}),

        // Add more lookups here following the same pattern
      ]);

      if (!cancelled) {
        setResolvedLookups(resolved);
        setLoading(false);
      }
    }

    resolve();
    return () => { cancelled = true; };
  }, [entity]);

  return { resolvedLookups, loading };
}

Key points:

  • Promise.all — all lookups execute in parallel, not sequentially
  • cancelled flag — prevents state updates after unmount
  • Individual .catch(() => {}) — a single lookup failure doesn't block the others

Dropdown for Writable Lookups (e.g. account selector)

Load the related table upfront only for small, bounded lists used in a form dropdown (e.g. account list for a select input). This is distinct from display resolution.

// src/hooks/useAccounts.ts — for dropdown data only
export function useAccounts() {
  const [accounts, setAccounts] = useState<Account[]>([]);

  useEffect(() => {
    AccountsService.getAll({
      select: ['accountid', 'name'],
      orderBy: ['name asc'],
      top: 200, // reasonable cap for a dropdown
    }).then(r => setAccounts(r.value ?? []));
  }, []);

  return { accounts };
}

In the form:

// Write with @odata.bind when submitting
payload['parentcustomerid_account@odata.bind'] = selectedAccountId
  ? `/accounts(${selectedAccountId})`
  : null;

Adding a New Lookup Field — Checklist

  1. Add the related table (if not already): pac code add-data-source -a dataverse -t <table>
  2. Add the read field to select in your hook:
    select: [...existing, '_newlookupfield_value']
    
  3. Extend ResolvedLookups interface with a new string property
  4. Add fetch logic in useLookupResolver inside Promise.all([...])
  5. Display it in the component using resolvedLookups.newField
  6. For writable lookups: add @odata.bind in buildPayload()

Common Pitfalls

Do NOTDo instead
getAll() to find one related recordget(guid, { select: [...] })
$expand to eagerly load related recordsSelect _field_value GUID, resolve on-demand
Include _field_value fields in PATCH payloadUse @odata.bind syntax for writes
Forget select when calling .get()Always specify the minimum display field
Load full table for a dropdown with no size limitAdd top: 200 or filter

Navigation Property Names

The write-side navigation property name (used in @odata.bind) comes from the Dataverse schema — it is not simply the read-side name with underscores removed. To find it:

  • Check the table's relationship definition in make.powerapps.com → Tables → Relationships
  • Or look at what PAC CLI generates in the service file's type definitions
  • Common pattern: _<schemaname>_value (read) ↔ <SchemaName>@odata.bind (write)

References