Wicked Smart Data
LearnInsightsAboutContact
Sign InLet's Build
LearnInsightsAboutContact
Sign InLet's Build
Wicked Smart Data

Intelligence, automation, and expert execution — plus an elite library of free knowledge. We turn complexity into competitive advantage.

Start a conversation

Platform

  • Learning Paths
  • Insights
  • RSS Feed

Company

  • About
  • Contact
  • Work With Us

Legal

  • Privacy Policy
  • Terms of Service

© 2026 Wicked Smart Data. All rights reserved.

Intelligence · Automation · Advantage

All Insights
Power Apps

Extending Model-Driven Apps with PCF Controls: Building and Deploying Custom Field and Dataset Components for Dataverse Forms and Views

Standard model-driven form controls hit a wall when you need genuinely custom UX — star ratings, card layouts, traffic lights, and more. This expert lesson walks you through building, testing, and deploying real PCF field and dataset controls from scratch, with full TypeScript implementations and production deployment patterns.

🔥 Expert27 min readSep 22, 2026Updated Sep 23, 2026
Extending Model-Driven Apps with PCF Controls: Building and Deploying Custom Field and Dataset Components for Dataverse Forms and Views
On this page
  • Introduction
  • Prerequisites
  • Understanding the PCF Architecture
  • The Control Manifest and Type System
  • The Control Lifecycle
  • Field Controls vs. Dataset Controls
  • Setting Up the PCF Toolchain
  • Installing the Power Platform CLI
  • Authenticating to Your Environment
  • Scaffolding a Field Control
  • Building a Field Control: Star Rating Widget
  • Designing the Manifest
  • Creating the CSS
  • Implementing the Control
  • Testing in the Harness
  • Building a Dataset Control: Card-Layout View
  • Scaffolding the Dataset Control
  • The Dataset Manifest
  • The Dataset Control Implementation
  • Dataset Paging Architecture
  • Packaging and Deploying PCF Controls
  • Building for Production
  • Creating a Solution Package
  • Importing via CLI
  • Adding a Field Control to a Form
  • Adding a Dataset Control to a Subgrid
  • Advanced Patterns and Production Considerations
  • Using the Formatting Service
  • Handling Security and Disabled States
  • Performance: Avoiding Unnecessary Re-Renders
  • Using External Libraries
  • The Virtual Control Manifest Mode
  • Hands-On Exercise
  • Common Mistakes & Troubleshooting
  • "Control never appears on the form"
  • "Value changes in the control but doesn't save to Dataverse"
  • "updateView fires in a loop"
  • "Dataset control shows loading spinner indefinitely"
  • "Control works in test harness but breaks on the form"
  • "Deploying an update breaks the existing form configuration"
  • Summary & Next Steps
  • Extending Model-Driven Apps with PCF Controls: Building and Deploying Custom Field and Dataset Components for Dataverse Forms and Views

    Introduction

    Picture this: you're building a model-driven app for a field service team, and you need to display a technician's skill ratings as a visual star rating widget rather than a plain number field. Or you need to render a dataset view where rows are color-coded by SLA breach status, with inline sparkline charts. Out of the box, model-driven apps are deliberately structured — the standard form controls and views work beautifully for data entry and browsing, but they hit a wall the moment you need a genuinely custom user experience.

    That wall is where the Power Apps Component Framework (PCF) lives. PCF is the official extensibility mechanism for both canvas and model-driven apps, but it earns its most impressive results inside Dataverse forms and views. Unlike adding custom pages to model-driven apps, which wraps entire screens in a canvas experience, PCF controls let you surgically replace individual fields or entire grids with custom TypeScript-driven components — without breaking the surrounding app's security, navigation, or data binding.

    By the end of this lesson, you'll know how to scaffold, build, and deploy both field controls and dataset controls. You'll understand the PCF lifecycle in enough depth to debug edge cases, and you'll walk away with patterns for handling Dataverse data types, managing state, and packaging components for production use inside solutions.

    What you'll learn:

    • The architecture and lifecycle of PCF controls, including how they integrate with the Dataverse form pipeline
    • How to scaffold, implement, and test a field control (a star rating widget bound to a numeric column)
    • How to build a dataset control that replaces a view grid with a custom card layout
    • How to handle the PCF context object, formatting utilities, and the notifyOutputChanged pattern
    • How to package controls into solutions, deploy to environments, and configure them on forms and views without breaking managed solution layering

    Prerequisites

    This lesson is pitched at expert level. You should already be comfortable with:

    • TypeScript and modern JavaScript — we'll write real component code, not scaffolded stubs
    • Model-driven app fundamentals — forms, views, columns, and Dataverse tables. If you need a refresher, start with Building Your First Model-Driven App: Site Map, Tables, Forms, and Views
    • Node.js and npm — the PCF toolchain runs on these
    • Power Platform CLI — pac commands are used throughout
    • Basic solutions awareness — you should understand managed vs. unmanaged layers, covered in Solutions for Model-Driven Apps: Publishers, Managed vs Unmanaged, and Solution Layering

    You'll need: Node.js 18+, Power Platform CLI (pac), Visual Studio Code, and access to a Dataverse environment with System Customizer or System Administrator privileges.


    Understanding the PCF Architecture

    Before you write a single line of TypeScript, you need to understand what PCF actually is — and isn't — because the mental model shapes every decision you make during development.

    The Control Manifest and Type System

    Every PCF control is described by a ControlManifest.Input.xml file. This XML document is not a configuration file you tweak after the fact — it is a contract. It tells the Power Platform runtime exactly what your control needs from Dataverse, what it returns, and how it behaves. The manifest defines:

    • Control type: field or dataset
    • Properties: typed inputs and outputs your control reads and writes
    • Resources: TypeScript bundles, CSS files, and locale resx files

    The type system in the manifest is mapped directly to Dataverse column types. When you declare a property as type Whole.None, the runtime passes you the numeric value of a Dataverse whole-number column. When your control outputs a changed value via notifyOutputChanged, the runtime validates it against that declared type before writing it to the field — and before any business rules on the form execute, which is an important architectural nuance.

    The Control Lifecycle

    PCF controls implement the ComponentFramework.StandardControl<TInputs, TOutputs> interface. There are four lifecycle methods you must understand deeply:

    init(context, notifyOutputChanged, state, container) Called once when the control first loads. This is where you create your DOM structure, set up event listeners, and cache the context. The container parameter is a real HTMLElement — your entire control lives inside it. The state parameter is a serialized object from the previous render session, useful for persisting things like scroll position or filter selections across form saves.

    updateView(context) Called every time the bound column value changes, when form mode changes (read vs. edit), or when dependent properties update. This is your primary re-render method. It receives a fresh context object. A critical mistake beginners make is assuming this is called only when the user changes the field — it fires on any form event, including unrelated field changes if they affect the control's context.

    getOutputs() Called by the runtime immediately after notifyOutputChanged() is invoked by your code. You return an object whose shape matches your manifest's output properties. This is how a field control writes a changed value back to Dataverse — you don't push the value; you declare what it should be, and the runtime pulls it.

    destroy() Called when the control is removed from the DOM. Clean up event listeners, timers, and any third-party library instances here. Memory leaks in PCF controls affect the entire form.

    Key insight

    PCF field controls are two-way data bound. The form passes the current value in via updateView, and your control passes a changed value out via notifyOutputChanged + getOutputs. If you confuse the direction of this flow, your control will appear to work during testing but will intermittently fail to persist values — one of the most frustrating bugs to diagnose.

    Field Controls vs. Dataset Controls

    A field control replaces a single column's rendered input on a form. It binds to a property that maps to one Dataverse column. It can read and write that value, change its formatting, and access sibling columns on the form through the context's parameters without declaring them as output properties (they'd be read-only).

    A dataset control replaces an entire view grid — a subgrid on a form, or a view in the main grid area. It receives a DataSet object that encapsulates paged records, columns, sorting, filtering, and selection state. Dataset controls don't write individual column values back; instead, they manage user interaction (row selection, navigation to records, triggering commands) through the dataset API.

    The architectural split matters because dataset controls must handle asynchronous paging, which field controls never touch.


    Setting Up the PCF Toolchain

    Installing the Power Platform CLI

    If you don't have pac installed:

    # Install via npm (cross-platform)
    npm install -g @microsoft/powerplatform-cli
    
    # Verify
    pac --version
    

    You should see a version like 1.x.x. The CLI wraps the PCF toolchain, solution packaging, and environment authentication.

    Authenticating to Your Environment

    pac auth create --url https://yourorg.crm.dynamics.com
    

    This opens a browser for OAuth. After completion, run pac auth list to confirm your connection profile is active.

    Scaffolding a Field Control

    Create a working directory and scaffold:

    mkdir StarRatingControl && cd StarRatingControl
    pac pcf init --namespace WickedSmartData --name StarRating --template field --run-npm-install
    

    The --namespace is your publisher prefix — it should match your solution publisher to avoid naming collisions. The scaffold creates:

    StarRatingControl/
    ├── StarRating/
    │   ├── ControlManifest.Input.xml
    │   ├── index.ts
    │   ├── generated/
    │   │   └── ManifestTypes.d.ts  # auto-generated from manifest
    │   ├── css/
    │   └── strings/
    ├── package.json
    └── tsconfig.json
    

    Building a Field Control: Star Rating Widget

    We'll build a star rating control that binds to a whole-number column (1–5 range) and renders interactive star icons. This maps to a realistic scenario: rating a contact's engagement score, a product's quality level, or a support ticket's severity.

    Designing the Manifest

    Open StarRating/ControlManifest.Input.xml and replace the default content:

    <?xml version="1.0" encoding="utf-8" ?>
    <manifest>
      <control namespace="WickedSmartData" constructor="StarRating" 
               version="1.0.0" display-name-key="StarRating_Display_Key"
               description-key="StarRating_Desc_Key" control-type="standard">
        
        <property name="ratingValue" display-name-key="Rating_Display_Key"
                  description-key="Rating_Desc_Key"
                  of-type="Whole.None"
                  usage="bound"
                  required="true" />
        
        <property name="maxStars" display-name-key="MaxStars_Display_Key"
                  description-key="MaxStars_Desc_Key"
                  of-type="Whole.None"
                  usage="input"
                  required="false"
                  default-value="5" />
    
        <resources>
          <code path="index.ts" order="1" />
          <css path="css/StarRating.css" order="1" />
        </resources>
      </control>
    </manifest>
    

    Notice the distinction between usage="bound" and usage="input". The ratingValue property is bound — it maps to a real Dataverse column and supports two-way data flow. The maxStars property is an input-only configuration value set by the app maker at design time, not tied to a column. This pattern lets you ship configurable controls without hardcoding behavior.

    After changing the manifest, run:

    npm run refreshTypes
    

    This regenerates generated/ManifestTypes.d.ts so TypeScript can type-check your property access.

    Creating the CSS

    Create StarRating/css/StarRating.css:

    .wsd-star-rating {
      display: flex;
      gap: 4px;
      align-items: center;
      padding: 4px 0;
    }
    
    .wsd-star {
      font-size: 24px;
      cursor: pointer;
      color: #d1d1d1;
      transition: color 0.15s ease, transform 0.1s ease;
      user-select: none;
      line-height: 1;
    }
    
    .wsd-star.filled {
      color: #f4b400;
    }
    
    .wsd-star.hovered {
      color: #f4b400;
      transform: scale(1.15);
    }
    
    .wsd-star-rating.readonly .wsd-star {
      cursor: default;
    }
    
    .wsd-star-rating.readonly .wsd-star:hover {
      transform: none;
    }
    

    Implementing the Control

    Now open StarRating/index.ts and replace its contents:

    import { IInputs, IOutputs } from "./generated/ManifestTypes";
    
    export class StarRating implements ComponentFramework.StandardControl<IInputs, IOutputs> {
      
      private _container: HTMLElement;
      private _ratingContainer: HTMLElement;
      private _notifyOutputChanged: () => void;
      private _currentRating: number;
      private _maxStars: number;
      private _isReadOnly: boolean;
      private _stars: HTMLElement[] = [];
    
      public init(
        context: ComponentFramework.Context<IInputs>,
        notifyOutputChanged: () => void,
        state: ComponentFramework.Dictionary,
        container: HTMLElement
      ): void {
        this._container = container;
        this._notifyOutputChanged = notifyOutputChanged;
        this._currentRating = context.parameters.ratingValue.raw ?? 0;
        this._maxStars = context.parameters.maxStars.raw ?? 5;
        this._isReadOnly = context.mode.isControlDisabled;
    
        this._ratingContainer = document.createElement("div");
        this._ratingContainer.className = "wsd-star-rating";
        
        if (this._isReadOnly) {
          this._ratingContainer.classList.add("readonly");
        }
    
        this._buildStars();
        this._container.appendChild(this._ratingContainer);
      }
    
      private _buildStars(): void {
        this._stars = [];
        this._ratingContainer.innerHTML = "";
    
        for (let i = 1; i <= this._maxStars; i++) {
          const star = document.createElement("span");
          star.className = "wsd-star";
          star.textContent = "★";
          star.dataset.index = String(i);
    
          if (i <= this._currentRating) {
            star.classList.add("filled");
          }
    
          if (!this._isReadOnly) {
            star.addEventListener("click", this._onStarClick.bind(this));
            star.addEventListener("mouseenter", this._onStarHover.bind(this));
            star.addEventListener("mouseleave", this._onStarLeave.bind(this));
          }
    
          this._ratingContainer.appendChild(star);
          this._stars.push(star);
        }
      }
    
      private _onStarClick(event: MouseEvent): void {
        const target = event.currentTarget as HTMLElement;
        const newRating = Number(target.dataset.index);
        
        // Allow clicking the same star to clear the rating
        this._currentRating = newRating === this._currentRating ? 0 : newRating;
        this._updateStarDisplay(this._currentRating);
        this._notifyOutputChanged();
      }
    
      private _onStarHover(event: MouseEvent): void {
        const target = event.currentTarget as HTMLElement;
        const hoverIndex = Number(target.dataset.index);
        
        this._stars.forEach((star, idx) => {
          if (idx < hoverIndex) {
            star.classList.add("hovered");
            star.classList.remove("filled");
          } else {
            star.classList.remove("hovered");
            star.classList.remove("filled");
          }
        });
      }
    
      private _onStarLeave(): void {
        this._updateStarDisplay(this._currentRating);
      }
    
      private _updateStarDisplay(rating: number): void {
        this._stars.forEach((star, idx) => {
          star.classList.remove("hovered");
          if (idx < rating) {
            star.classList.add("filled");
          } else {
            star.classList.remove("filled");
          }
        });
      }
    
      public updateView(context: ComponentFramework.Context<IInputs>): void {
        const incomingRating = context.parameters.ratingValue.raw ?? 0;
        const isNowReadOnly = context.mode.isControlDisabled;
    
        // Only re-render if something meaningful changed
        const ratingChanged = incomingRating !== this._currentRating;
        const modeChanged = isNowReadOnly !== this._isReadOnly;
    
        if (ratingChanged) {
          this._currentRating = incomingRating;
        }
    
        if (modeChanged) {
          this._isReadOnly = isNowReadOnly;
          if (this._isReadOnly) {
            this._ratingContainer.classList.add("readonly");
          } else {
            this._ratingContainer.classList.remove("readonly");
            // Rebuild to reattach event listeners
            this._buildStars();
            return;
          }
        }
    
        if (ratingChanged) {
          this._updateStarDisplay(this._currentRating);
        }
      }
    
      public getOutputs(): IOutputs {
        return {
          ratingValue: this._currentRating
        };
      }
    
      public destroy(): void {
        this._stars.forEach(star => {
          star.removeEventListener("click", this._onStarClick.bind(this));
          star.removeEventListener("mouseenter", this._onStarHover.bind(this));
          star.removeEventListener("mouseleave", this._onStarLeave.bind(this));
        });
        this._stars = [];
      }
    }
    

    Warning

    The destroy() method above uses bind(this) when removing listeners, which creates new function references and won't actually remove the originals. In production code, store bound references as class properties (this._boundStarClick = this._onStarClick.bind(this)) and use those stored references in both addEventListener and removeEventListener. We've kept this simplified for readability — don't copy the cleanup pattern verbatim.

    Testing in the Harness

    PCF provides a local test harness so you don't have to deploy to see your control in action:

    npm start watch
    

    This builds your TypeScript, launches a local web server, and opens the test harness at http://localhost:8181. The harness lets you set property values (simulating what Dataverse passes in) and observe your control's output. Set ratingValue to 3 and maxStars to 5 — you should see three filled stars. Click a star, check that the output updates in the harness panel.

    The harness is excellent for rapid iteration, but it doesn't simulate form mode changes (read-only vs. edit mode) perfectly. You'll need a real environment for full integration testing.


    Building a Dataset Control: Card-Layout View

    Field controls are satisfying to build, but dataset controls are where PCF truly differentiates itself from everything else you can do with model-driven apps. When you replace a view grid with a dataset control, you have total rendering freedom — cards, Kanban columns, calendar layouts, anything.

    We'll build a contact card layout that replaces the default grid on a contacts subgrid, showing each contact as a card with their name, company, email, and a status badge.

    Scaffolding the Dataset Control

    mkdir ContactCardView && cd ContactCardView
    pac pcf init --namespace WickedSmartData --name ContactCardView --template dataset --run-npm-install
    

    The Dataset Manifest

    <?xml version="1.0" encoding="utf-8" ?>
    <manifest>
      <control namespace="WickedSmartData" constructor="ContactCardView"
               version="1.0.0" display-name-key="ContactCardView_Display_Key"
               description-key="ContactCardView_Desc_Key" control-type="dataset">
    
        <data-set name="contactDataset" display-name-key="Dataset_Display_Key">
          <property-set name="fullName" display-name-key="FullName_Display_Key"
                        of-type="SingleLine.Text" usage="bound" required="true" />
          <property-set name="companyName" display-name-key="Company_Display_Key"
                        of-type="SingleLine.Text" usage="bound" required="false" />
          <property-set name="emailAddress" display-name-key="Email_Display_Key"
                        of-type="SingleLine.Email" usage="bound" required="false" />
          <property-set name="statusCode" display-name-key="Status_Display_Key"
                        of-type="OptionSet" usage="bound" required="false" />
        </data-set>
    
        <resources>
          <code path="index.ts" order="1" />
          <css path="css/ContactCardView.css" order="1" />
        </resources>
      </control>
    </manifest>
    

    The data-set element with property-set children is the dataset control's core pattern. Property sets declare which columns from the view you want to access by alias in your code, regardless of the actual column schema name. This is important: your control accesses columns by the alias (fullName, companyName, etc.), and the app maker maps those aliases to real columns when configuring the control on a subgrid. This makes your control reusable across different tables.

    Note

    The property-set aliases are metadata hints — the full dataset still exposes all columns returned by the view. You're not restricting data; you're declaring which columns have semantic meaning to your component's rendering logic. The app maker experience prompts them to map these aliases at configuration time.

    The Dataset Control Implementation

    import { IInputs, IOutputs } from "./generated/ManifestTypes";
    
    interface ContactRecord {
      id: string;
      fullName: string;
      companyName: string;
      emailAddress: string;
      statusLabel: string;
      statusValue: number | null;
    }
    
    export class ContactCardView implements ComponentFramework.StandardControl<IInputs, IOutputs> {
      
      private _container: HTMLElement;
      private _cardGrid: HTMLElement;
      private _context: ComponentFramework.Context<IInputs>;
      private _notifyOutputChanged: () => void;
      private _selectedIds: Set<string> = new Set();
    
      public init(
        context: ComponentFramework.Context<IInputs>,
        notifyOutputChanged: () => void,
        state: ComponentFramework.Dictionary,
        container: HTMLElement
      ): void {
        this._container = container;
        this._notifyOutputChanged = notifyOutputChanged;
        this._context = context;
    
        this._cardGrid = document.createElement("div");
        this._cardGrid.className = "wsd-card-grid";
        this._container.appendChild(this._cardGrid);
    
        // Dataset controls must signal they want paging support
        context.parameters.contactDataset.paging.setPageSize(12);
      }
    
      public updateView(context: ComponentFramework.Context<IInputs>): void {
        this._context = context;
        const dataset = context.parameters.contactDataset;
    
        // If the dataset is still loading, show a placeholder
        if (dataset.loading) {
          this._renderLoadingState();
          return;
        }
    
        // If we need more data loaded (first page after an async fetch)
        if (dataset.paging.hasNextPage && this._cardGrid.children.length === 0) {
          dataset.paging.loadNextPage();
          return;
        }
    
        const records = this._extractRecords(dataset);
        this._renderCards(records, dataset);
      }
    
      private _extractRecords(dataset: ComponentFramework.PropertyTypes.DataSet): ContactRecord[] {
        return dataset.sortedRecordIds.map(id => {
          const record = dataset.records[id];
          
          // Access columns by property-set alias using getFormattedValue for display
          const fullName = record.getFormattedValue("fullName") || "Unknown Contact";
          const companyName = record.getFormattedValue("companyName") || "";
          const emailAddress = record.getFormattedValue("emailAddress") || "";
          
          // For OptionSets, raw value is the numeric code; formatted is the label
          const statusLabel = record.getFormattedValue("statusCode") || "Unknown";
          const statusRaw = record.getValue("statusCode");
          const statusValue = statusRaw ? (statusRaw as ComponentFramework.LookupValue).id 
            ? null 
            : Number(statusRaw) : null;
    
          return { id, fullName, companyName, emailAddress, statusLabel, statusValue };
        });
      }
    
      private _renderCards(records: ContactRecord[], dataset: ComponentFramework.PropertyTypes.DataSet): void {
        this._cardGrid.innerHTML = "";
    
        records.forEach(record => {
          const card = this._createCard(record, dataset);
          this._cardGrid.appendChild(card);
        });
    
        // Render paging controls if needed
        if (dataset.paging.hasPreviousPage || dataset.paging.hasNextPage) {
          this._renderPagingControls(dataset);
        }
      }
    
      private _createCard(record: ContactRecord, dataset: ComponentFramework.PropertyTypes.DataSet): HTMLElement {
        const card = document.createElement("div");
        card.className = "wsd-contact-card";
        card.dataset.recordId = record.id;
    
        if (this._selectedIds.has(record.id)) {
          card.classList.add("selected");
        }
    
        const statusClass = this._getStatusClass(record.statusValue);
    
        card.innerHTML = `
          <div class="wsd-card-header">
            <div class="wsd-avatar">${this._getInitials(record.fullName)}</div>
            <span class="wsd-status-badge ${statusClass}">${this._escapeHtml(record.statusLabel)}</span>
          </div>
          <div class="wsd-card-body">
            <h3 class="wsd-contact-name">${this._escapeHtml(record.fullName)}</h3>
            <p class="wsd-company">${this._escapeHtml(record.companyName)}</p>
            <a class="wsd-email" href="mailto:${this._escapeHtml(record.emailAddress)}">
              ${this._escapeHtml(record.emailAddress)}
            </a>
          </div>
        `;
    
        // Single click: select the record in the dataset
        card.addEventListener("click", (e) => {
          e.stopPropagation();
          this._handleCardSelection(record.id, dataset);
        });
    
        // Double click: open the record form
        card.addEventListener("dblclick", () => {
          dataset.openDatasetItem(dataset.records[record.id].getNamedReference());
        });
    
        return card;
      }
    
      private _handleCardSelection(recordId: string, dataset: ComponentFramework.PropertyTypes.DataSet): void {
        if (this._selectedIds.has(recordId)) {
          this._selectedIds.delete(recordId);
        } else {
          this._selectedIds.add(recordId);
        }
    
        // Inform the dataset of selected records so the command bar can respond
        dataset.setSelectedRecordIds(Array.from(this._selectedIds));
        
        // Update visual selection state without a full re-render
        this._cardGrid.querySelectorAll(".wsd-contact-card").forEach(card => {
          const el = card as HTMLElement;
          if (this._selectedIds.has(el.dataset.recordId!)) {
            el.classList.add("selected");
          } else {
            el.classList.remove("selected");
          }
        });
      }
    
      private _renderPagingControls(dataset: ComponentFramework.PropertyTypes.DataSet): void {
        const pagingBar = document.createElement("div");
        pagingBar.className = "wsd-paging-bar";
    
        if (dataset.paging.hasPreviousPage) {
          const prevBtn = document.createElement("button");
          prevBtn.textContent = "← Previous";
          prevBtn.className = "wsd-paging-btn";
          prevBtn.addEventListener("click", () => dataset.paging.loadPreviousPage());
          pagingBar.appendChild(prevBtn);
        }
    
        const pageInfo = document.createElement("span");
        pageInfo.className = "wsd-page-info";
        pageInfo.textContent = `Page ${dataset.paging.pageNumber} · ${dataset.sortedRecordIds.length} records`;
        pagingBar.appendChild(pageInfo);
    
        if (dataset.paging.hasNextPage) {
          const nextBtn = document.createElement("button");
          nextBtn.textContent = "Next →";
          nextBtn.className = "wsd-paging-btn";
          nextBtn.addEventListener("click", () => dataset.paging.loadNextPage());
          pagingBar.appendChild(nextBtn);
        }
    
        this._cardGrid.appendChild(pagingBar);
      }
    
      private _renderLoadingState(): void {
        this._cardGrid.innerHTML = `<div class="wsd-loading">Loading contacts...</div>`;
      }
    
      private _getInitials(name: string): string {
        return name.split(" ").slice(0, 2).map(n => n[0]).join("").toUpperCase();
      }
    
      private _getStatusClass(statusValue: number | null): string {
        const map: Record<number, string> = {
          1: "status-active",
          2: "status-inactive",
          3: "status-prospect"
        };
        return statusValue !== null ? (map[statusValue] ?? "status-default") : "status-default";
      }
    
      private _escapeHtml(str: string): string {
        return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
      }
    
      public getOutputs(): IOutputs {
        return {};
      }
    
      public destroy(): void {
        this._cardGrid.innerHTML = "";
        this._selectedIds.clear();
      }
    }
    

    Tip

    Notice the dataset.openDatasetItem() call on double-click. This is the correct way to open the record's main form from a dataset control. Don't try to construct a URL manually — openDatasetItem uses the context's navigation service and respects the app's site map and security model. It accepts a EntityReference returned by record.getNamedReference().

    Dataset Paging Architecture

    The paging system in PCF dataset controls deserves special attention. The DataSet object doesn't give you all records at once — it gives you a page, controlled by paging.setPageSize(). When you call loadNextPage() or loadPreviousPage(), the runtime fetches the next batch from Dataverse and calls updateView() again with fresh data.

    This means your updateView implementation must be stateless with respect to which page is shown. Don't cache record arrays across calls to updateView — always read from dataset.sortedRecordIds and dataset.records fresh. The runtime is your source of truth.

    A subtle point: paging.setPageSize() can only be called from init(), not updateView(). If you try to change the page size dynamically, it's silently ignored.


    Packaging and Deploying PCF Controls

    Building for Production

    npm run build
    

    This compiles TypeScript to JavaScript and bundles everything. Check the out/ directory — you'll find the bundled control code there. But you don't deploy from out/ directly; you package into a solution.

    Creating a Solution Package

    From the parent directory of your control:

    mkdir StarRatingSolution && cd StarRatingSolution
    pac solution init --publisher-name WickedSmartData --publisher-prefix wsd
    pac solution add-reference --path ../StarRatingControl
    

    The add-reference command links your control project to the solution. Now build the solution:

    pac solution build
    

    Or, to produce a .zip you can import manually:

    dotnet build  # requires .NET SDK
    

    Warning

    The pac solution build and dotnet build commands create unmanaged solutions suitable for development environments. For production deployment, always build a managed solution. Add --configuration Release to dotnet build, and the toolchain adds the managed="true" /> attribute to the solution's manifest automatically. Deploying unmanaged PCF controls to production creates customization debt that's extremely difficult to clean up — you can't delete an unmanaged component if it's referenced elsewhere.

    Importing via CLI

    pac solution import --path ./bin/Debug/WickedSmartData_1_0_0_1.zip
    

    Or navigate to make.powerapps.com → Solutions → Import → Upload your zip. After import, the control appears under your solution's PCF Controls node.

    Adding a Field Control to a Form

    After import, navigate to your target table in make.powerapps.com. Open the form editor for the form you want to customize. Find the column you want to replace — for our star rating, that's a Whole Number column. Click the column on the form canvas to select it, then in the right panel select "Components" → "Get more components." Browse to your solution, find WickedSmartData.StarRating, and add it.

    Back on the column properties, you'll see your control listed under "Components" for this field. Select it. If you declared any input-only properties (like maxStars), you'll see configuration fields here — enter 5 for max stars. Save and publish the form.

    Key insight

    When you add a PCF control to a column on a form, you're not removing the native control — you're layering on top of it. The runtime will still use the native control as a fallback if your PCF fails to load, and in certain accessibility scenarios. This is why the column must still be on the form; PCF controls can't exist without their backing column.

    Adding a Dataset Control to a Subgrid

    Dataset controls replace subgrid components. On a form, add a subgrid component, configure it to show a related table (e.g., Contacts related to the current Account). Then, in the subgrid's properties panel, under "Components," add your WickedSmartData.ContactCardView control. Map the property-set aliases to the actual contact columns — fullName → fullname, companyName → parentcustomerid, etc.

    You can also add dataset controls to a main view (not just subgrids) by setting the control on the view itself via the classic solution explorer. This is less discoverable in the modern UI but fully supported.


    Advanced Patterns and Production Considerations

    Using the Formatting Service

    The context.formatting service is underused but essential for building controls that respect the user's locale and Dataverse field formatting settings. Rather than calling toLocaleString() directly on dates or numbers, delegate to:

    // Format a currency value according to Dataverse column metadata
    const displayValue = context.formatting.formatCurrency(amount, currencyPrecision, currencySymbol);
    
    // Format a date using the org's date format settings
    const dateDisplay = context.formatting.formatDateShort(dateValue);
    

    This matters especially when your model-driven app is used internationally. Fields configured with formula columns or rollup columns may return values already formatted on the server side — in that case, getFormattedValue() on the record returns the server-formatted string, and you should use that directly rather than re-formatting.

    Handling Security and Disabled States

    Your control must respect the form's security context. The context.mode.isControlDisabled flag tells you whether the field is locked. This can happen because of role-based field permissions, form-level locking, or column-level security policies. Your updateView must check this flag every time it's called — it can change during a session without the full control being destroyed and re-initialized.

    Similarly, context.mode.isAuthoringMode is true when your control is rendering inside the form designer. Render a simplified, non-interactive placeholder in that state rather than your full interactive component, or the designer UI becomes confusing.

    public updateView(context: ComponentFramework.Context<IInputs>): void {
      if (context.mode.isAuthoringMode) {
        this._container.innerHTML = `<div class="wsd-authoring-placeholder">⭐ Star Rating (Preview)</div>`;
        return;
      }
      // ... normal rendering
    }
    

    Performance: Avoiding Unnecessary Re-Renders

    updateView fires frequently. In a form with many fields, any field change can trigger updateView on every PCF control on the form. If your rendering is expensive — say, you're rendering SVG charts or using a third-party library — you need a dirty-checking strategy:

    private _lastRenderedValue: number = -1;
    
    public updateView(context: ComponentFramework.Context<IInputs>): void {
      const incoming = context.parameters.ratingValue.raw ?? 0;
      const modeChanged = context.mode.isControlDisabled !== this._isReadOnly;
      
      if (incoming === this._lastRenderedValue && !modeChanged) {
        return; // Nothing to do
      }
      
      this._lastRenderedValue = incoming;
      this._isReadOnly = context.mode.isControlDisabled;
      // ... render
    }
    

    This simple guard can reduce actual DOM operations by 80%+ in forms with 20+ fields.

    Using External Libraries

    You can bundle third-party npm packages into your PCF control. Install them and import normally:

    npm install chart.js
    
    import { Chart } from "chart.js";
    

    The webpack bundler (configured for you by the PCF toolchain) handles tree-shaking and bundling. Be mindful of bundle size — the entire bundle is downloaded by the browser when the form loads. Anything over 500KB starts to measurably affect form load time. Use npm run build and check the output file size in out/. For heavy libraries, consider dynamic import() syntax to lazy-load only when the control initializes.

    The Virtual Control Manifest Mode

    PCF supports a mode="virtual" attribute on the <control> element. Virtual controls don't receive a DOM container — instead, they return a virtual DOM tree using React.createElement (React is available in the PCF runtime without importing it). Virtual controls are more performant because the PCF runtime can batch DOM updates:

    <control namespace="WickedSmartData" constructor="StarRating" 
             version="1.0.0" display-name-key="StarRating_Display_Key"
             control-type="standard" mode="virtual">
    

    Virtual mode requires a React-style render method instead of imperative DOM manipulation. If you're comfortable with React, this is the preferred approach for field controls in 2024+. The tradeoff is that you're coupling your control to the PCF runtime's internal React version — which Microsoft updates without notice — rather than managing your own DOM.


    Hands-On Exercise

    Build a Traffic Light Status Control — a field control that displays a Dataverse status reason OptionSet as a colored traffic light indicator (red/amber/green) rather than a dropdown.

    Requirements:

    1. Bind to a status or statecode OptionSet column (or any integer column where you'll map values 1→green, 2→amber, 3→red)
    2. In edit mode, render three clickable circle indicators that set the value when clicked
    3. In read-only mode, render a single illuminated circle with an adjacent label
    4. Handle the isAuthoringMode state with a placeholder
    5. Package it into a solution and deploy to your environment

    Steps to follow:

    1. Scaffold with pac pcf init --namespace YourNamespace --name TrafficLightStatus --template field
    2. Modify the manifest to bind statusValue as Whole.None and add an input property labelText with of-type="SingleLine.Text" for the active status label
    3. Implement the four lifecycle methods with DOM manipulation
    4. Run npm start watch and validate behavior in the test harness for values 1, 2, and 3
    5. Build, package via pac solution, and import to your environment
    6. Add it to a form column and test round-trip value persistence — click a light, save, reopen the record, confirm the value persisted

    Bonus challenge: Use context.utils.getEntityMetadata("your_table", ["statuscode"]) to dynamically fetch the OptionSet labels from Dataverse metadata rather than hardcoding them.


    Common Mistakes & Troubleshooting

    "Control never appears on the form"

    Cause: The control was added to the form but the column it's bound to isn't included in the form's sections. PCF controls require their backing column to be on the form.

    Fix: Add the backing column to the form first, then add the PCF control to that column. Confirm the column is visible (not just present on the form with hidden set to true at the section level) and that the user's security role grants read access to it.

    "Value changes in the control but doesn't save to Dataverse"

    Cause: notifyOutputChanged() is called but getOutputs() returns the old value, or returns undefined for the property.

    Fix: Verify that your internal state variable is updated before you call notifyOutputChanged(). Check that getOutputs() returns an object with the exact property name matching your manifest — TypeScript types help here, but the generated types only update after npm run refreshTypes.

    "updateView fires in a loop"

    Cause: Your control calls notifyOutputChanged() inside updateView() — perhaps because you're interpreting incoming value changes as user edits.

    Fix: Only call notifyOutputChanged() from actual user interactions (click, change, input events). Never call it from within updateView(). Add the dirty-check pattern described above to confirm the incoming value differs from your last emitted value before acting.

    "Dataset control shows loading spinner indefinitely"

    Cause: The dataset's initial load is asynchronous. If you check dataset.loading in updateView and return early, but never trigger a re-render once loading completes, you're stuck.

    Fix: The runtime automatically calls updateView again when loading completes. Don't re-trigger it yourself. If you're stuck, confirm that your init() doesn't accidentally throw an error before the paging.setPageSize() call — exceptions in init() can prevent the runtime from scheduling future updateView calls.

    "Control works in test harness but breaks on the form"

    Cause: Common culprits are (a) accessing browser APIs unavailable in the Dataverse web client, (b) timing issues where you query the DOM before your init() container is attached, or (c) version mismatches between the PCF runtime's bundled React and your bundled React.

    Fix: Never use document.getElementById() or document.querySelector() from PCF — always work within the container element passed to init(). For React version conflicts, use virtual mode or avoid bundling React entirely and use the ambient React global.

    Tip

    The browser developer tools in model-driven apps are your best debugging friend. PCF controls run in the main page context (not an iframe), so you can set breakpoints directly on your control's bundled JavaScript in the Sources panel. Open the sources, search for your constructor name, and step through init and updateView in a live session.

    "Deploying an update breaks the existing form configuration"

    Cause: Changing the manifest's property names in a new version breaks the saved configuration on forms that reference the old property names.

    Fix: Property names in the manifest are forever — treat them like a public API. Never rename a property in an existing manifest version; instead, add new properties and deprecate old ones across a version increment. When updating a deployed PCF control, bump the version attribute in the manifest and rebuild. Forms will pick up the new version automatically after the solution import, and existing property mappings are preserved as long as the property names didn't change.


    Summary & Next Steps

    You've now covered the full arc of PCF development: understanding the architecture and lifecycle, building a field control with two-way data binding, constructing a dataset control with paging and record selection, handling production concerns like performance and security modes, and deploying through solutions.

    The key patterns to internalize:

    • Manifest-first design — your control's contract with the runtime is declared in XML before you write TypeScript
    • Context is your data source — always read from the context in updateView, never cache Dataverse data across renders
    • notifyOutputChanged is a signal, not a write — the runtime pulls your value via getOutputs() after you signal
    • Respect the mode — disabled state, authoring mode, and read-only rendering are not optional edge cases; they're core to building a control that works inside a real app
    • Solutions wrap everything — PCF controls belong in managed solutions for production deployment

    From here, consider exploring how PCF integrates with the broader app ecosystem. The way your dataset control can interact with the command bar connects naturally to how you might customize the command bar with Power Fx — PCF selection events and command bar visibility rules operate on the same record selection state. If your control's purpose involves deep form layout changes, you'll also want to read about designing model-driven forms: sections, tabs, subgrids, and quick view forms to understand how PCF controls compose with the broader form structure.

    For teams managing multiple PCF controls across environments, invest in the pac pcf push command for rapid dev-test cycles, and explore the GitHub Actions Power Platform build tools for automating PCF solution builds in CI/CD pipelines. Well-built PCF controls are some of the most reusable assets your Power Platform practice can have — once deployed, they can be dropped onto any form in any app in the environment without additional development work.

    Work With Us

    From insight to implementation

    Reading is the start. When you're ready to build the data, automation, or AI systems behind it, our team turns strategy into shipped results.

    Let's Build

    Model-Driven Apps & Dataverse

    Previous

    Configuring Dataverse Table Relationships in Model-Driven Apps: One-to-Many, Many-to-Many, and Cascade Behaviors Explained

    Next

    Configuring Model-Driven App Column Properties: Data Types, Required Fields, and Display Settings for Dataverse Tables

    Related Insights

    Power AppsExpert

    Canvas App Multi-Step Wizard Pattern: Building Guided Data Entry Flows with Screen Sequences, Progress Indicators, and Conditional Branching

    27 min
    Power AppsPractitioner

    Configuring Dataverse Webhook Endpoints and Service Endpoint Bindings: Triggering External System Notifications on Table Events in Model-Driven Apps

    23 min
    Power AppsPractitioner

    Canvas App UX Patterns: Loading Spinners, Empty States, and Skeleton Screens for Async Data

    23 min

    On this page

    • Introduction
    • Prerequisites
    • Understanding the PCF Architecture
    • The Control Manifest and Type System
    • The Control Lifecycle
    • Field Controls vs. Dataset Controls
    • Setting Up the PCF Toolchain
    • Installing the Power Platform CLI
    • Authenticating to Your Environment
    • Scaffolding a Field Control
    • Building a Field Control: Star Rating Widget
    • Designing the Manifest
    • Creating the CSS
    • Implementing the Control
    • Testing in the Harness
    • Building a Dataset Control: Card-Layout View
    • Scaffolding the Dataset Control
    • The Dataset Manifest
    • The Dataset Control Implementation
    • Dataset Paging Architecture
    • Packaging and Deploying PCF Controls
    • Building for Production
    • Creating a Solution Package
    • Importing via CLI
    • Adding a Field Control to a Form
    • Adding a Dataset Control to a Subgrid
    • Advanced Patterns and Production Considerations
    • Using the Formatting Service
    • Handling Security and Disabled States
    • Performance: Avoiding Unnecessary Re-Renders
    • Using External Libraries
    • The Virtual Control Manifest Mode
    • Hands-On Exercise
    • Common Mistakes & Troubleshooting
    • "Control never appears on the form"
    • "Value changes in the control but doesn't save to Dataverse"
    • "updateView fires in a loop"
    • "Dataset control shows loading spinner indefinitely"
    • "Control works in test harness but breaks on the form"
    • "Deploying an update breaks the existing form configuration"
    • Summary & Next Steps