All articles

Should You Build a PCF Control?

Power Apps Component Framework lets you build React-based custom controls for model-driven forms and views. Most of the time, you shouldn't. Here's when PCF is the right answer — and what the tooling docs don't warn you about.

Yurii Biriukov · · 12 min read

Every few months a client asks me for a PCF control. Usually, they shouldn’t have one.

Power Apps Component Framework (PCF) lets you write TypeScript and React to replace or extend controls inside model-driven apps and canvas apps. It’s powerful, it’s legitimate, and it’s also the place most Power Platform projects accumulate their worst technical debt.

This is how I decide when to build one — and what to expect when you do.

What PCF Actually Is

A PCF control is a bundled JavaScript/TypeScript component that Dataverse loads inside a form or view, replacing the default rendering of a field (or dataset). You write it, build it with the pac CLI, pack it into a solution, and deploy it like any other component.

There are three flavors:

TypeReplacesTypical use
Field (standard)A single column’s inputA slider instead of a number textbox, a color picker, a star rating
DatasetA subgrid or viewA Kanban board, a calendar view, a custom card list
VirtualA single column, but using React hooksSame as field, but smaller bundle — uses the host’s React

Virtual controls are newer and almost always the right choice over standard field controls when you’re writing React. They share React with the host instead of bundling their own copy.

When to Build One

I build a PCF control when all of these are true:

  1. The built-in controls genuinely can’t express what the business needs
  2. The value will be reused across multiple fields, forms, or environments
  3. Someone will own it for the next three years

Break any one of those and the PCF becomes a liability.

Valid examples from real projects:

  • A rating control used on 40+ fields across a feedback solution. Reusable, bounded scope, simple.
  • A Kanban dataset view replacing a subgrid where users drag records between status columns. Built-in views can’t do this.
  • An address autocomplete field calling a maps API. You can’t do this with a calculated column or business rule.

When Not to Build One

Most PCF requests are disguised UX complaints. Before you write a single line of TypeScript, ask:

  • Can a form script handle this? Xrm.Page with JavaScript is uglier but easier to maintain than PCF. Show/hide fields, set values, validate — do it there first.
  • Is this a form design issue? Sections, tabs, and business rules solve most “the form is confusing” problems.
  • Are we rebuilding a subgrid? Subgrids are flexible. Quick view forms, related sections, and custom views usually work before you reach for a dataset PCF.
  • Is this one-off? If a control will live on exactly one field on one form in one solution, a PCF is almost never worth it.

A PCF is a software project embedded inside a configuration project. Every PCF adds: a Node toolchain, a build step, a bundle to load, a version to maintain, and a fresh pile of npm vulnerabilities that will age faster than your Dataverse schema.

The Toolchain

To build PCF you need:

npm install -g pw
# Actually: Power Platform CLI
winget install Microsoft.PowerPlatformCLI
# Or via dotnet
dotnet tool install --global Microsoft.PowerApps.CLI.Tool

Then:

mkdir RatingControl && cd RatingControl
pac pcf init --namespace MyCompany --name RatingControl --template field
npm install

This scaffolds a project with:

RatingControl/
├── RatingControl/
│   ├── ControlManifest.Input.xml   # the contract with Dataverse
│   ├── index.ts                    # your control
│   ├── css/                        # styles (bundled in)
│   └── generated/                  # autogenerated types (don't edit)
├── node_modules/
├── package.json
└── pcfconfig.json

The manifest is the most important file. It’s the public API Dataverse sees — which properties the control exposes, which data types it binds to, which resources to bundle.

A Minimal Field Control

Here’s a rating control that renders stars for a whole number column.

The manifest

<?xml version="1.0" encoding="utf-8"?>
<manifest>
  <control namespace="MyCompany" 
           constructor="RatingControl" 
           version="1.0.0" 
           display-name-key="Rating" 
           description-key="Star rating input" 
           control-type="standard">
    <property name="value" 
              display-name-key="Value" 
              of-type="Whole.None" 
              usage="bound" 
              required="true" />
    <property name="maxStars" 
              display-name-key="Max Stars" 
              of-type="Whole.None" 
              usage="input" 
              required="false" 
              default-value="5" />
    <resources>
      <code path="index.ts" order="1" />
      <css path="css/RatingControl.css" order="1" />
    </resources>
  </control>
</manifest>

Two properties: value (bound to the Dataverse column — reads and writes) and maxStars (configured per field, read-only from the makers’ perspective).

The control

import { IInputs, IOutputs } from "./generated/ManifestTypes";

export class RatingControl implements ComponentFramework.StandardControl<IInputs, IOutputs> {
    private container: HTMLDivElement;
    private notifyOutputChanged: () => void;
    private currentValue: number = 0;
    private maxStars: number = 5;

    public init(
        context: ComponentFramework.Context<IInputs>,
        notifyOutputChanged: () => void,
        state: ComponentFramework.Dictionary,
        container: HTMLDivElement
    ): void {
        this.container = container;
        this.notifyOutputChanged = notifyOutputChanged;
        this.maxStars = context.parameters.maxStars.raw ?? 5;
        this.currentValue = context.parameters.value.raw ?? 0;
        this.render();
    }

    public updateView(context: ComponentFramework.Context<IInputs>): void {
        this.currentValue = context.parameters.value.raw ?? 0;
        this.render();
    }

    public getOutputs(): IOutputs {
        return { value: this.currentValue };
    }

    public destroy(): void {
        // Clean up event listeners, timers, subscriptions
    }

    private render(): void {
        this.container.innerHTML = "";
        for (let i = 1; i <= this.maxStars; i++) {
            const star = document.createElement("span");
            star.className = i <= this.currentValue ? "star filled" : "star";
            star.textContent = "★";
            star.onclick = () => {
                this.currentValue = i;
                this.notifyOutputChanged();
                this.render();
            };
            this.container.appendChild(star);
        }
    }
}

Four methods matter: init (runs once), updateView (runs on every context change — external data refresh, form reload), getOutputs (called after you notify), and destroy (cleanup).

The critical contract: whenever the user changes the value, call notifyOutputChanged(). The framework then calls getOutputs() and writes the returned values back to Dataverse. Forget this call and the field silently stops saving.

Testing Locally

The test harness is good enough for most iteration:

npm start watch

This opens a browser with your control rendered against mock data. You can change values, simulate different device sizes, and pass test inputs. Hot reload works. Use this for 90% of development.

It’s not a faithful simulation — the real Dataverse form wraps your control in layout, form events, and security context the harness can’t replicate. Always test on an actual form before considering it done.

Packaging and Deploying

Two paths: a throwaway dev loop, and a proper solution build.

Dev loop (fast, not for production)

pac pcf push --publisher-prefix mycompany

This creates a temporary solution, packages your control, imports it into the environment you’re connected to, and publishes. Fast, good for iterating in a dev environment.

Never use this for prod. It creates a solution you don’t control, named after your current build.

Proper solution build (for ALM)

mkdir Solutions
cd Solutions
pac solution init --publisher-name MyCompany --publisher-prefix mycompany
pac solution add-reference --path ../RatingControl
msbuild /t:build /restore /p:Configuration=Release

This produces a managed (or unmanaged) solution .zip in bin\Release that you import through the Power Platform Admin Center or a pipeline. This is the version that goes into ALM: source-controlled, versioned, built by CI.

Which brings up the one rule that matters: increment the version in ControlManifest.Input.xml every time you change the control. If you don’t, Dataverse caches the old version in users’ browsers and your “fix” never reaches them. The symptom is always the same — “it works for me but not for anyone else” — and the fix is always a version bump and a hard refresh.

Binding to Forms

Once deployed:

  1. Open a form in the maker portal
  2. Select a field (must match the data type in your manifest — Whole.None for the rating example means whole-number columns)
  3. In the properties pane, under Components, click + Component
  4. Pick your control
  5. Configure which clients to show it on: Web, Tablet, Phone
  6. Set any input properties (maxStars in our example)
  7. Save and publish

If a user opens the form on a client you didn’t check, they get the default control. This is often what you want — your fancy control degrades gracefully on mobile, or you force users to web-only because mobile layout breaks.

What Will Bite You

Bundle size. Webpack bundles React, your code, every dependency into one file. A naive React + Fluent UI control easily ships a 2 MB bundle. Every form load pays that cost. Use virtual controls, keep dependencies lean, and check bin\Release\ControlManifest.xml for the bundle size before shipping. If it’s over 500 KB, something is wrong.

context.parameters.value.raw is nullable. Always. Even on required fields. A user can clear the field, open a new row, or hit the control during initial render. Every raw read needs a null check — omitting it is the single most common source of production errors.

updateView fires on every context change, not just data changes. Resize the window, change tabs, update a different field — updateView runs. Do not do expensive work unconditionally in there. Compare context.parameters.value.raw to your stored value before re-rendering.

Form script interaction is limited. PCF controls are sandboxed. They can read and write their bound value, but they can’t easily read sibling fields or trigger form events. If your control needs to know the value of another column, either bind that column as an input property, or rearchitect — form scripts are a better fit for cross-field logic.

Dataset controls are their own universe. Binding to a view instead of a single field changes almost every API. You get paging, sorting, selection, filters — and most of the manifest attributes are different. Treat dataset PCF as a separate skillset. Don’t extrapolate from field control experience.

Per-environment rebuild. The solution .zip includes the compiled bundle, but the bundle references npm packages that drift. Lock your package-lock.json into source control. Without it, a rebuild in six months will pull in new dependency versions, and subtle behavior changes will land in production without a code change.

Auth is not what you think. Your control runs in the user’s browser. If it calls external APIs, it carries the user’s browser auth — not Dataverse’s. For APIs that need Dataverse credentials, proxy through a Power Automate flow or custom API instead of calling directly.

The Honest Tradeoff

PCF is the only way to do certain things in model-driven apps, and when you need it, nothing else works. But every control you add:

  • Takes time to build (a “simple” control is 2-3 days minimum once you include solution packaging, testing, and deployment to a real environment)
  • Needs someone who knows TypeScript and webpack to maintain it
  • Adds load time to every form that uses it
  • Ages with npm, not with Dataverse

Before building, I make myself answer one question: who is going to own this control in two years? If the answer is “I don’t know,” the answer to “should we build it” is almost always no.

When you do build one, keep it small. A single control that does one thing well is easier to maintain than a flexible mega-control with fifteen input properties. Let Dataverse do the heavy lifting. Use PCF for the last mile.

Share this article LinkedIn X / Twitter

Related articles