Retour au catalogue
Power PlatformPerso

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.

DataverseReactCRUD
Chemin
~/.claude/skills/dataverse-crud-react
Modifié
18 avril 2026 à 20:44

Dataverse 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.tsx is 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:

  • top maximum is 5,000 records (hard limit)
  • Default page size is 5,000 (standard tables) or 500 (elastic tables)
  • No $skip support — use @odata.nextLink from 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

RuleWhy
Always use selectAvoids fetching all columns — major payload reduction
Use topPrevents loading unbounded result sets
Use orderByServer-side sort is faster than client-side
Use filterLimit 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

  1. pac code add-data-source -a dataverse -t <table-logical-name>
  2. Create src/hooks/use<Entities>.ts from the hook template above
  3. Create src/components/<Entity>List.tsx, <Entity>Card.tsx, <Entity>Form.tsx
  4. Compose in App.tsx
  5. For lookup fields between tables → see dataverse-lookups skill

References