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.

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:
notifyOutputChanged patternThis lesson is pitched at expert level. You should already be comfortable with:
pac commands are used throughoutYou'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.
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.
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:
field or datasetThe 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.
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.
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.
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.
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.
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
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.
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.
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;
}
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.
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.
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.
mkdir ContactCardView && cd ContactCardView
pac pcf init --namespace WickedSmartData --name ContactCardView --template dataset --run-npm-install
<?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.
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, "&").replace(/</g, "<").replace(/>/g, ">");
}
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().
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.
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.
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.
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.
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.
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.
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.
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
}
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.
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.
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.
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:
status or statecode OptionSet column (or any integer column where you'll map values 1→green, 2→amber, 3→red)isAuthoringMode state with a placeholderSteps to follow:
pac pcf init --namespace YourNamespace --name TrafficLightStatus --template fieldstatusValue as Whole.None and add an input property labelText with of-type="SingleLine.Text" for the active status labelnpm start watch and validate behavior in the test harness for values 1, 2, and 3pac solution, and import to your environmentBonus challenge: Use context.utils.getEntityMetadata("your_table", ["statuscode"]) to dynamically fetch the OptionSet labels from Dataverse metadata rather than hardcoding them.
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.
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.
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.
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.
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.
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.
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:
updateView, never cache Dataverse data across rendersgetOutputs() after you signalFrom 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.
Model-Driven Apps & Dataverse