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.
~/.claude/skills/dataverse-lookupsDataverse 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 sequentiallycancelledflag — 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
- Add the related table (if not already):
pac code add-data-source -a dataverse -t <table> - Add the read field to
selectin your hook:select: [...existing, '_newlookupfield_value'] - Extend
ResolvedLookupsinterface with a new string property - Add fetch logic in
useLookupResolverinsidePromise.all([...]) - Display it in the component using
resolvedLookups.newField - For writable lookups: add
@odata.bindinbuildPayload()
Common Pitfalls
| Do NOT | Do instead |
|---|---|
getAll() to find one related record | get(guid, { select: [...] }) |
$expand to eagerly load related records | Select _field_value GUID, resolve on-demand |
Include _field_value fields in PATCH payload | Use @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 limit | Add 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)
