dataverse-crud-react
Implement CRUD operations on Dataverse tables in a Power Apps Code App using the three-layer architecture (Components → Hooks → Generated Services). Use when scaffolding hooks, components, or forms for any Dataverse entity. Table-agnostic — replace entity names as needed.
~/.claude/skills/dataverse-crud-reactDataverse CRUD — React Three-Layer Architecture
Architecture Overview
┌──────────────────────────────────────┐
│ Presentation Layer (Components) │ UI only — props in, callbacks out
├──────────────────────────────────────┤
│ Business Logic Layer (Hooks) │ State, async ops, CRUD, error handling
├──────────────────────────────────────┤
│ Data Layer (Generated Services) │ Auto-generated by PAC CLI — do not edit
└──────────────────────────────────────┘
Rules:
- Components never call services directly
- Hooks are the only place that imports generated services
App.tsxis composition-only — no logic, no state
Generated Service API
After pac code add-data-source -a dataverse -t <table>, a service is generated at src/generated/services/<Table>Service.ts.
Methods
All methods resolve with an IOperationResult<T> — they do not throw on Dataverse errors. A failed HTTP call (400/403/500, validation error, unknown column, etc.) resolves with { success: false, error }. A try/catch around an await will not catch these. Always check result.success before using result.data.
interface IOperationResult<TResponse> {
success: boolean
data: TResponse // only valid when success === true
error?: Error | PowerDataRuntimeHttpError
}
// Read all records
EntityService.getAll(options?: QueryOptions): Promise<IOperationResult<Entity[]>>
// Read one record by ID
EntityService.get(id: string, options?: QueryOptions): Promise<IOperationResult<Entity>>
// Create a record — data contains the full created row (incl. generated GUID)
EntityService.create(data: Partial<Entity>): Promise<IOperationResult<Entity>>
// Update a record (partial update — only changed fields)
EntityService.update(id: string, data: Partial<Entity>): Promise<IOperationResult<Entity>>
// Delete a record
EntityService.delete(id: string): Promise<IOperationResult<void>>
Canonical call pattern (applies everywhere — read, write, delete):
const result = await EntityService.create(payload)
if (!result.success) {
throw result.error ?? new Error("create failed silently")
}
const newId = result.data.entityid
The ?? fallback matters: when Dataverse rejects the payload, error is usually populated — but the SDK technically allows success: false with no error. Never assume truthiness.
QueryOptions
interface QueryOptions {
select?: string[]; // Fields to return (always use — minimize payload)
filter?: string; // OData filter expression
orderBy?: string[]; // e.g. ['createdon desc', 'name asc']
top?: number; // Max records to return
}
Always specify select — never fetch all fields.
Filter Syntax Reference
The filter option uses OData syntax passed directly to the Web API.
// Comparison operators: eq ne gt ge lt le
filter: 'statecode eq 0'
filter: 'revenue gt 100000 and revenue lt 500000'
// Logical operators: and or not
filter: 'statecode eq 0 and emailaddress1 ne null'
// String functions
filter: "contains(name,'Contoso')"
filter: "startswith(firstname,'J')"
filter: "endswith(emailaddress1,'@microsoft.com')"
// Combined example
filter: "statecode eq 0 and contains(name,'Partner')"
Limits:
topmaximum is 5,000 records (hard limit)- Default page size is 5,000 (standard tables) or 500 (elastic tables)
- No
$skipsupport — use@odata.nextLinkfrom response for pagination
Hook Pattern (Generic Template)
Replace Entity, entity, EntityService, entityid with your table names.
// src/hooks/useEntities.ts
import { useState, useEffect, useCallback } from 'react';
import { EntityService } from '../generated/services/EntityService';
import type { Entity } from '../generated/models/EntityModel';
interface UseEntitiesReturn {
entities: Entity[];
loading: boolean;
error: string | null;
selectedEntity: Entity | null;
isCreating: boolean;
startCreate: () => void;
startEdit: (entity: Entity) => void;
cancelForm: () => void;
handleFormSubmit: (data: EntityFormData) => Promise<void>;
deleteEntity: (id: string) => Promise<void>;
}
export function useEntities(): UseEntitiesReturn {
const [entities, setEntities] = useState<Entity[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedEntity, setSelectedEntity] = useState<Entity | null>(null);
const [isCreating, setIsCreating] = useState(false);
const loadEntities = useCallback(async () => {
try {
setLoading(true);
setError(null);
const result = await EntityService.getAll({
select: ['entityid', 'name', /* ... relevant fields */],
orderBy: ['createdon desc'],
top: 50,
});
if (!result.success) throw result.error ?? new Error('Failed to load records');
setEntities(result.data ?? []);
} catch (err) {
setError(`Failed to load records: ${err instanceof Error ? err.message : String(err)}`);
console.error('Load error:', err);
} finally {
setLoading(false);
}
}, []);
useEffect(() => { loadEntities(); }, [loadEntities]);
const startCreate = () => { setIsCreating(true); setSelectedEntity(null); };
const startEdit = (entity: Entity) => { setSelectedEntity(entity); setIsCreating(false); };
const cancelForm = () => { setSelectedEntity(null); setIsCreating(false); };
const handleFormSubmit = async (data: EntityFormData) => {
try {
setError(null);
const payload = buildPayload(data); // see below
const result = selectedEntity
? await EntityService.update(selectedEntity.entityid!, payload)
: await EntityService.create(payload);
if (!result.success) throw result.error ?? new Error('Save failed');
cancelForm();
await loadEntities();
} catch (err) {
setError(`Failed to save: ${err instanceof Error ? err.message : String(err)}`);
console.error('Save error:', err);
}
};
const deleteEntity = async (id: string) => {
if (!confirm('Delete this record?')) return;
try {
setError(null);
const result = await EntityService.delete(id);
if (!result.success) throw result.error ?? new Error('Delete failed');
if (selectedEntity?.entityid === id) cancelForm();
await loadEntities();
} catch (err) {
setError(`Failed to delete: ${err instanceof Error ? err.message : String(err)}`);
console.error('Delete error:', err);
}
};
return { entities, loading, error, selectedEntity, isCreating,
startCreate, startEdit, cancelForm, handleFormSubmit, deleteEntity };
}
Building the Payload (create/update)
function buildPayload(data: EntityFormData): Partial<Entity> {
const payload: Partial<Entity> = {};
// Simple scalar fields
if (data.name !== undefined) payload.name = data.name;
if (data.email !== undefined) payload.emailaddress1 = data.email;
// Lookup field — see dataverse-lookups skill
if (data.relatedId !== undefined) {
if (data.relatedId) {
(payload as any)['relatedfield@odata.bind'] = `/relatedentities(${data.relatedId})`;
} else {
(payload as any)['relatedfield@odata.bind'] = null;
}
}
return payload;
}
Non-obvious wire formats
The generated TS models under src/generated/models/ do not always match what the Dataverse OData endpoint accepts on write. Use the schema JSON at .power/schemas/dataverse/<table>.Schema.json as the source of truth (check x-ms-dataverse-type).
DecimalType / MoneyType → send numbers, not strings.
The TS type is string, but the server rejects string-encoded decimals with 0x80048d19 ("Cannot convert a value to target type 'Edm.Decimal' because of conflict between input format string/number and parameter 'IEEE754Compatible' false/true") — the SDK does not set the IEEE754Compatible=true Prefer header, so the server expects a JSON number on the wire. Convert with Number(...) and cast through unknown:
const toDecimal = (s: string) => Number(s) as unknown as string;
const payload: Partial<Entity> = {
gg_amount: toDecimal(data.amount), // DecimalType column
gg_price: toDecimal(data.price), // MoneyType column
};
Same treatment likely applies to DoubleType / FloatType — verify against the schema.
Component Pattern
Components are pure — data in via props, events out via callbacks.
// src/components/EntityList.tsx
interface EntityListProps {
entities: Entity[];
loading: boolean;
onEdit: (entity: Entity) => void;
onDelete: (id: string) => void;
onCreateNew: () => void;
}
export function EntityList({ entities, loading, onEdit, onDelete, onCreateNew }: EntityListProps) {
if (loading) return <div>Loading...</div>;
return (
<div>
<button onClick={onCreateNew}>New Record</button>
{entities.map(e => (
<EntityCard key={e.entityid} entity={e} onEdit={onEdit} onDelete={onDelete} />
))}
</div>
);
}
App.tsx Composition Pattern
// src/App.tsx — composition only, no logic
export default function App() {
const { entities, loading, error, selectedEntity, isCreating,
startCreate, startEdit, cancelForm, handleFormSubmit, deleteEntity } = useEntities();
return (
<>
{error && <ErrorMessage error={error} />}
<EntityList
entities={entities}
loading={loading}
onEdit={startEdit}
onDelete={deleteEntity}
onCreateNew={startCreate}
/>
{(isCreating || selectedEntity) && (
<EntityForm
entity={selectedEntity}
onSubmit={handleFormSubmit}
onCancel={cancelForm}
/>
)}
</>
);
}
Error Handling Strategy
Three levels — each has one job:
Generated Service → returns { success, data, error } — DOES NOT throw on API errors
↓
Hook → check result.success → throw result.error (or set error state)
then try-catch → setError(user-friendly message) + console.error
↓
Component → <ErrorMessage error={error} /> renders the string
Never swallow errors silently. The #1 bug in this layer is omitting the result.success check — a rejected create/update/delete then "succeeds" from the UI's point of view while nothing persisted. Always console.error(result.error) when success is false so the raw Dataverse payload stays inspectable in DevTools.
Query Optimization Rules
| Rule | Why |
|---|---|
Always use select | Avoids fetching all columns — major payload reduction |
Use top | Prevents loading unbounded result sets |
Use orderBy | Server-side sort is faster than client-side |
Use filter | Limit rows at the API level, not in Array.filter() |
// Good
const result = await EntityService.getAll({
select: ['entityid', 'name', 'statecode'],
filter: 'statecode eq 0',
orderBy: ['createdon desc'],
top: 50,
});
if (!result.success) throw result.error ?? new Error('Fetch failed');
const entities = result.data;
Adding a New Table — Checklist
pac code add-data-source -a dataverse -t <table-logical-name>- Create
src/hooks/use<Entities>.tsfrom the hook template above - Create
src/components/<Entity>List.tsx,<Entity>Card.tsx,<Entity>Form.tsx - Compose in
App.tsx - For lookup fields between tables → see dataverse-lookups skill
