Skip to content

How to Build Your First PCF Control in 2026

Step-by-step guide to building, testing, and deploying a PCF control with modern tooling. From pac pcf init to a working control on a D365 form.

Victoria Pechenizka 14 min read

I remember the first time I tried to build a PCF control. The Microsoft docs got me 60% of the way. The other 40% took two days of Stack Overflow and trial and error.

The official tutorials show you the happy path. Scaffold, build, deploy. What they skip is everything that goes wrong between those steps. The manifest properties that silently break your control. The test harness quirks. The moment you realize updateView fires way more often than you expected.

I’ve shipped PCF controls in production since then. Field Audit History is the public one, with source on GitHub. I’ve made enough mistakes along the way to fill a separate article. But the actual process of building a PCF control is surprisingly approachable once someone walks you through it honestly.

That’s what this is. Not the sanitized version. The real one.

TL;DR

If you know TypeScript and have built anything in React, you can build a PCF control. The pac pcf init command scaffolds everything. The lifecycle has four methods. The test harness lets you iterate locally. The hardest part isn’t the code. It’s understanding how Dataverse hands data to your control and expects it back.

What You Need Before You Start

You don’t need much. But you need the right versions of things, and you need them installed in the right order.

Node.js 20 or later. PCF tooling uses Node under the hood for bundling and the test harness. I recommend the LTS version. Don’t use Node 16. It will appear to work and then fail in confusing ways during packaging.

The Power Platform CLI (pac). Install it as a .NET tool:

dotnet tool install --global Microsoft.PowerApps.CLI.Tool

Run pac --version to confirm. You want 1.32 or later. The older MSI installer still works, but the dotnet tool version gets updates faster.

Visual Studio Code. With the ESLint and Prettier extensions. You’ll be editing TypeScript, XML, and JSON. VS Code handles all of them well.

Basic TypeScript. You don’t need to be an expert. If you can write interfaces, use async/await, and understand generics at a surface level, you’re fine. PCF controls don’t require advanced TypeScript.

Step 1: Scaffold with pac pcf init

Open a terminal, create an empty folder, and run:

mkdir my-first-control
cd my-first-control
pac pcf init --namespace Contoso --name MyFirstControl --template field --run-npm-install

That’s it. You have a working PCF project.

The --template flag is the first real decision you’ll make, and it matters more than you’d think.

Template What It Does When to Use
field Binds to a single column on a form Most controls. Anything that replaces or enhances one field: custom dropdowns, formatted inputs, audit icons, rich text editors.
dataset Binds to a view or subgrid Controls that display lists of records: custom grids, charts, card layouts, kanban boards.

If you’re unsure, start with field. It’s simpler. The manifest is smaller. The data flow is easier to reason about. My first control was a field control and I’m glad I didn’t start with dataset. The dataset template adds paging, sorting, and column definitions that would have overwhelmed me on day one.

After the scaffold completes, your folder structure looks like this:

my-first-control/
  MyFirstControl/
    ControlManifest.Input.xml    ← Defines what your control needs
    index.ts                     ← Your control logic
    generated/
      ManifestTypes.d.ts         ← Auto-generated TypeScript types
  package.json
  tsconfig.json
  pcfconfig.json

The two files that matter most right now are ControlManifest.Input.xml and index.ts. Everything else is plumbing.

Step 2: Understand the Manifest

ControlManifest.Input.xml is where you tell Dataverse what your control needs. It defines inputs, outputs, resources, and feature requirements. If you get this wrong, your control either won’t bind or won’t get the data it needs.

Here’s a minimal manifest for a field control:

<?xml version="1.0" encoding="utf-8" ?>
<manifest>
  <control namespace="Contoso" constructor="MyFirstControl"
           version="0.0.1" display-name-key="MyFirstControl"
           description-key="A custom field control"
           control-type="standard"
           api-version="1.3.0">

    <property name="sampleProperty"
              display-name-key="Sample Property"
              description-key="The field this control is bound to"
              of-type="SingleLine.Text"
              usage="bound"
              required="true" />

    <resources>
      <code path="index.ts" order="1" />
    </resources>

  </control>
</manifest>

The pieces that trip people up:

of-type must match the Dataverse column type you’re binding to. SingleLine.Text, Whole.None, DateAndTime.DateOnly, TwoOptions, and so on. If you bind to the wrong type, the control won’t appear in the form editor dropdown.

usage="bound" vs usage="input". Bound properties connect to a real column on the entity. Input properties are configuration values the maker types in when adding the control. For your first control, use one bound property and zero or one input properties.

api-version. Leave it at whatever the scaffold generates. Bumping it unlocks newer features but can break backward compatibility. Not something to worry about on your first build.

Step 3: Write the Component Logic

Open index.ts. The scaffold gives you four lifecycle methods. This is the entire API surface of a PCF control:

export class MyFirstControl implements ComponentFramework.StandardControl<IInputs, IOutputs> {

  // Called once when the control loads
  init(
    context: ComponentFramework.Context<IInputs>,
    notifyOutputChanged: () => void,
    state: ComponentFramework.Dictionary,
    container: HTMLDivElement
  ): void {
    // Set up your control here
  }

  // Called when data changes (form load, field update, resize)
  updateView(context: ComponentFramework.Context<IInputs>): void {
    // Re-render with new data
  }

  // Called when Dataverse wants to read your control's value
  getOutputs(): IOutputs {
    return {};
  }

  // Called when the control is removed from the DOM
  destroy(): void {
    // Clean up event listeners, timers, subscriptions
  }
}

Here’s what I wish someone had told me about each one.

init runs exactly once. This is where you create your DOM elements, set up event listeners, and store references you’ll need later. The container parameter is the div Dataverse gives you. Everything your control renders goes inside it. Don’t render outside it. Don’t use document.body. Your control shares the page with the entire form.

updateView runs more than you expect. It fires on initial load, when the bound property changes, when the form resizes, when the user switches tabs, and sometimes just because Dataverse felt like it. Your code here needs to be fast and idempotent. Don’t set up event listeners in updateView. Don’t create DOM elements in updateView. Read the data, update the display, get out.

getOutputs must be pure. Return an object with your output values. No side effects. Dataverse calls this when it wants to save the form or when notifyOutputChanged() fires. If your control changes a field value, call notifyOutputChanged() and return the new value from getOutputs.

destroy is your cleanup. Remove event listeners. Clear intervals. Unmount React components. If you skip this, you get memory leaks that only show up when users navigate between records without reloading the page.

Here’s a real (minimal) implementation that shows the bound field value with a character count:

export class MyFirstControl implements ComponentFramework.StandardControl<IInputs, IOutputs> {

  private container: HTMLDivElement;
  private inputElement: HTMLInputElement;
  private countElement: HTMLSpanElement;
  private notifyOutputChanged: () => void;
  private currentValue: string | null;

  init(
    context: ComponentFramework.Context<IInputs>,
    notifyOutputChanged: () => void,
    state: ComponentFramework.Dictionary,
    container: HTMLDivElement
  ): void {
    this.container = container;
    this.notifyOutputChanged = notifyOutputChanged;
    this.currentValue = context.parameters.sampleProperty.raw;

    this.inputElement = document.createElement("input");
    this.inputElement.type = "text";
    this.inputElement.value = this.currentValue ?? "";
    this.inputElement.addEventListener("input", this.onInputChange.bind(this));

    this.countElement = document.createElement("span");
    this.countElement.textContent = `${(this.currentValue ?? "").length} chars`;

    container.appendChild(this.inputElement);
    container.appendChild(this.countElement);
  }

  updateView(context: ComponentFramework.Context<IInputs>): void {
    const newValue = context.parameters.sampleProperty.raw;
    if (newValue !== this.currentValue) {
      this.currentValue = newValue;
      this.inputElement.value = newValue ?? "";
      this.countElement.textContent = `${(newValue ?? "").length} chars`;
    }
  }

  getOutputs(): IOutputs {
    return { sampleProperty: this.currentValue ?? undefined };
  }

  destroy(): void {
    this.inputElement.removeEventListener("input", this.onInputChange.bind(this));
  }

  private onInputChange(event: Event): void {
    this.currentValue = (event.target as HTMLInputElement).value;
    this.countElement.textContent = `${this.currentValue.length} chars`;
    this.notifyOutputChanged();
  }
}

That’s a complete, working PCF control. It reads a text value, shows a character count, and writes changes back. Simple, but it covers every lifecycle method and the data flow in both directions.

Step 4: Add React for the UI

The example above uses raw DOM manipulation. That’s fine for a five-element control. It’s not fine for anything with state, conditional rendering, or more than a few elements.

I use React for every production control. Here’s why.

Raw DOM manipulation means you’re tracking state in variables, manually updating elements, and writing your own diffing logic. When your control has 15 elements and three conditional states, that code becomes a maintenance problem fast. React gives you declarative rendering, a proper component model, and a massive ecosystem of libraries.

To add React, install it:

npm install react react-dom
npm install -D @types/react @types/react-dom

Update your tsconfig.json to support JSX:

{
  "compilerOptions": {
    "jsx": "react",
    "esModuleInterop": true
  }
}

Then restructure your control. The index.ts becomes a thin wrapper that mounts and unmounts a React component:

import * as React from "react";
import * as ReactDOM from "react-dom";
import { MyControlApp } from "./MyControlApp";

export class MyFirstControl implements ComponentFramework.StandardControl<IInputs, IOutputs> {

  private container: HTMLDivElement;
  private notifyOutputChanged: () => void;
  private currentValue: string | null;

  init(
    context: ComponentFramework.Context<IInputs>,
    notifyOutputChanged: () => void,
    state: ComponentFramework.Dictionary,
    container: HTMLDivElement
  ): void {
    this.container = container;
    this.notifyOutputChanged = notifyOutputChanged;
    this.currentValue = context.parameters.sampleProperty.raw;
    this.renderReact();
  }

  updateView(context: ComponentFramework.Context<IInputs>): void {
    this.currentValue = context.parameters.sampleProperty.raw;
    this.renderReact();
  }

  getOutputs(): IOutputs {
    return { sampleProperty: this.currentValue ?? undefined };
  }

  destroy(): void {
    ReactDOM.unmountComponentAtNode(this.container);
  }

  private renderReact(): void {
    ReactDOM.render(
      React.createElement(MyControlApp, {
        value: this.currentValue,
        onChange: (newValue: string) => {
          this.currentValue = newValue;
          this.notifyOutputChanged();
        },
      }),
      this.container
    );
  }
}

And your React component is just a normal React component:

// MyControlApp.tsx
import * as React from "react";

interface Props {
  value: string | null;
  onChange: (value: string) => void;
}

export const MyControlApp: React.FC<Props> = ({ value, onChange }) => {
  return (
    <div style={{ display: "flex", gap: "8px", alignItems: "center" }}>
      <input
        type="text"
        value={value ?? ""}
        onChange={(e) => onChange(e.target.value)}
      />
      <span>{(value ?? "").length} chars</span>
    </div>
  );
};

Notice the pattern. The index.ts owns the PCF lifecycle and passes data down as props. The React component is pure UI. It doesn’t know about Dataverse. It doesn’t call notifyOutputChanged. It receives data and calls an onChange callback. This separation is critical because it means you can test the React component in isolation without any PCF mocking.

Step 5: Test Locally with the PCF Test Harness

Run this in your project root:

npm start

A browser opens with the PCF test harness. It’s a simple page that simulates the Dataverse runtime. Your control renders in the middle. A property panel on the right lets you change input values and see outputs.

The test harness is good enough for basic development. You can iterate on your UI, test input/output flow, and catch obvious bugs. But it has limits you should know about.

It doesn’t call the real Dataverse API. If your control uses context.webAPI.retrieveMultipleRecords, those calls will fail in the harness. You’ll need to either mock the responses or skip API calls when running locally. I usually check context.mode.allocatedHeight or use a try/catch around API calls during development.

It doesn’t simulate the form context accurately. Things like context.mode.isControlDisabled, context.mode.isVisible, and entity metadata are all simplified. Test these behaviors on a real form.

Hot reload works, mostly. Changes to your TypeScript trigger a rebuild and refresh. Changes to the manifest require stopping and restarting npm start.

For most of the development cycle, I work like this: build the React component with hardcoded props first. Get the UI right. Then wire it into the PCF lifecycle. Then test data flow in the harness. Then deploy to a dev environment for real testing.

Step 6: Add Unit Tests with Jest

You can test PCF controls. Most people don’t. That’s a mistake.

Since we separated the PCF lifecycle wrapper from the React component, testing is straightforward. Test the React component with React Testing Library. Test any utility functions directly. The PCF lifecycle itself is hard to unit test, but the code that matters is in your components and helpers.

npm install -D jest ts-jest @types/jest @testing-library/react @testing-library/jest-dom

A basic test for our component:

import { render, screen, fireEvent } from "@testing-library/react";
import { MyControlApp } from "./MyControlApp";

describe("MyControlApp", () => {
  it("displays the current value", () => {
    render(<MyControlApp value="hello" onChange={() => {}} />);
    expect(screen.getByDisplayValue("hello")).toBeInTheDocument();
  });

  it("shows character count", () => {
    render(<MyControlApp value="hello" onChange={() => {}} />);
    expect(screen.getByText("5 chars")).toBeInTheDocument();
  });

  it("calls onChange when input changes", () => {
    const onChange = jest.fn();
    render(<MyControlApp value="" onChange={onChange} />);
    fireEvent.change(screen.getByRole("textbox"), { target: { value: "test" } });
    expect(onChange).toHaveBeenCalledWith("test");
  });
});

That’s three tests and they cover the core behavior. When your control gets complex, this is what saves you from regressions.

I wrote a full article about using AI to generate unit tests for PCF controls. The short version: describe what your component does, let AI generate the test matrix, review the coverage. I went from zero tests to 90+ tests on Field Audit History in one session.

Step 7: Package and Deploy

You have two deployment paths. Pick the right one for the situation.

Quick deploy for development: pac pcf push

pac auth create --url https://your-org.crm.dynamics.com
pac pcf push --publisher-prefix contoso

This builds your control, packages it into a temporary solution, and imports it to your dev environment. Fast. No solution file to manage. Perfect for iterating.

But don’t use it for production. pac pcf push creates an unmanaged solution with a generated name. You can’t track versions. You can’t move it through environments cleanly. It’s a dev tool.

Production deploy: solution packaging

Create a solution project next to your control project:

# Go up one level from your control folder
cd ..
pac solution init --publisher-name Contoso --publisher-prefix contoso
pac solution add-reference --path ./my-first-control

Then build the solution:

dotnet build
# or for managed solution:
dotnet build /p:configuration=Release

This produces a .zip file in bin/Debug/ (or bin/Release/ for managed). Import that into your target environment through the Power Platform admin center, a pipeline, or pac solution import.

  1. 1

    Build and test locally

    Run npm start, verify the control works in the test harness, run your Jest tests.

  2. 2

    Quick deploy to dev

    Use pac pcf push to get the control on a real form fast. Test with real data.

  3. 3

    Create a solution project

    Use pac solution init to create a proper solution wrapper around your control.

  4. 4

    Build the solution zip

    Run dotnet build to produce an importable solution file.

  5. 5

    Import to your target environment

    Import the managed solution through the admin center or pac solution import.

  6. 6

    Add the control to a form

    Edit the entity form, select the field, choose your custom control, configure the properties, save and publish.

Common Mistakes I Made (So You Don’t Have To)

Mistake 1: Heavy work in updateView. I put an API call inside updateView in my first control. It fired on every tab switch. The form crawled. Solution: do expensive work in init or behind a user action (click, hover). Use updateView only for reading changed property values and re-rendering.

Mistake 2: Forgetting to call notifyOutputChanged. I spent an hour wondering why the form wasn’t saving my control’s value. The control looked correct on screen. But I never called notifyOutputChanged(), so Dataverse never asked for the value via getOutputs. No notify, no save.

Mistake 3: Not handling null values. Dataverse fields can be null. The raw property on a bound parameter will be null for empty fields. If your code assumes a string and gets null, everything breaks. Always use context.parameters.yourProp.raw ?? "" or handle null explicitly.

Mistake 4: Hardcoding entity-specific logic. My first version of Field Audit History had the entity name hardcoded. Then someone wanted it on a different entity. I rewrote the entire data layer to be config-driven. If I’d done it right the first time, that would have been a one-hour config file instead of a two-day refactor. I wrote about this pattern in detail in Config-Driven PCF Components.

Mistake 5: Skipping destroy. I didn’t clean up event listeners in destroy because “it works fine.” It did, until users started navigating between records. Memory usage climbed. Event handlers fired on unmounted components. React threw warnings. Always clean up.

Mistake 6: Not versioning the manifest. The version attribute in the manifest controls whether Dataverse recognizes an update. If you deploy version 0.0.1 twice, Dataverse might not pick up changes. Bump the version every time you deploy. I use 0.0.x during dev and proper semver for releases.

What to Build Next

Your first control should be simple. A character counter, a formatted phone number input, a color-coded status badge. Something with one bound property, no API calls, and a clear visual result.

Once that works, the progression looks like this:

  1. Add an input property for configuration. A label, a max length, a color. This teaches you the difference between bound and input properties.

  2. Make an API call. Use context.webAPI to fetch related data. This is where PCF controls get powerful. You’re not limited to the bound field. You can query any table the user has access to.

  3. Build a dataset control. Bind to a view instead of a field. Render records as cards, a timeline, or a chart. Dataset controls are harder but they solve problems that standard subgrids can’t.

  4. Externalize your configuration. Move hardcoded values into a JSON web resource config pattern. This is the point where your control becomes reusable across entities and organizations.

  5. Add comprehensive tests. Build a test suite that covers every state your control can be in. This is what separates a demo from a product.

Each step builds on the previous one. Don’t skip ahead. The fundamentals of the lifecycle, the data flow, the manifest, these matter at every level of complexity.

PCF Is the Most Underrated Skill in Power Platform

Every Power Platform developer I talk to knows about model-driven apps, canvas apps, Power Automate, plugins. Ask about PCF and most of them say “I’ve heard of it” or “I keep meaning to learn it.”

That’s a massive opportunity.

PCF controls solve problems that no other Power Platform feature can. When the out-of-the-box controls don’t do what the business needs, the options are: tell the client no, build a hacky JavaScript web resource, or build a PCF control that’s supported, testable, and deployable through proper ALM.

I’ve been in this space for over 8 years. The developers who learn PCF become the ones who get called when “it can’t be done in Power Platform” comes up in a project. Because usually it can. You just need a control for it.

The tooling in 2026 is better than it’s ever been. pac pcf init gives you a working scaffold in seconds. React integration is straightforward. The test harness works. Jest testing is possible. The documentation has improved. The community has real production examples to learn from.

You have everything you need. The only thing missing is your first pac pcf init.

Go build something.

Stay in the loop

Get new posts delivered to your inbox. No spam, unsubscribe anytime.

Related articles