Power Platform

PCF Controls: Building Custom Components for Power Apps

May 27, 2025
CRX Partners Team
14 min read

What is the PowerApps Component Framework?

The PowerApps Component Framework (PCF) enables developers to create custom, reusable components for model-driven apps, canvas apps, and Power Pages. PCF controls provide enhanced user experiences beyond what's possible with out-of-the-box controls, allowing you to build rich, interactive components using modern web technologies like TypeScript and React. official PCF documentation.

Why Build PCF Controls?

  • Enhanced UX: Create intuitive interfaces that standard controls can't provide
  • Reusability: Build once, deploy across multiple apps and environments
  • Modern Development: Use TypeScript, React, and modern tooling
  • Full Control: Complete control over rendering and behavior
  • Integration: Access to Web API for data operations within components

Development Environment Setup

Prerequisites

Install the following tools to start building PCF controls:

  • Node.js: LTS version (16.x or later)
  • Visual Studio Code: Recommended IDE with TypeScript support
  • Power Platform CLI: Command-line tools for PCF development
  • .NET SDK: Required for building solution packages

# Install Power Platform CLI via npm

npm install -g pac

# Or download from Microsoft

https://learn.microsoft.com/en-us/power-platform/developer/cli/introduction

Creating Your First PCF Project

Initialize a new PCF control project using the CLI:

# Create project directory
mkdir MyCustomControl
cd MyCustomControl

# Initialize PCF component
pac pcf init --namespace Contoso --name RatingControl --template field

# Install dependencies
npm install

# Start development server with hot reload
npm start watch

PCF Control Types

Field Controls

Field controls bind to a single field and replace the default field rendering. Ideal for custom input experiences.

  • Star rating controls
  • Color pickers
  • Rich text editors
  • Custom date/time pickers
  • Slider controls

Dataset Controls

Dataset controls display collections of records with custom rendering. Perfect for grid replacements and visualizations.

  • Custom grids with advanced filtering
  • Card-based record displays
  • Timeline visualizations
  • Kanban boards
  • Calendar views

Anatomy of a PCF Control

ControlManifest.Input.xml

The manifest file defines control properties, data bindings, and resources:

<?xml version="1.0" encoding="utf-8"?>
<manifest>
  <control namespace="Contoso" constructor="RatingControl"
           version="1.0.0" display-name-key="Rating Control"
           description-key="A custom star rating control"
           control-type="standard">

    <!-- Bound property (the field value) -->
    <property name="ratingValue" display-name-key="Rating"
              of-type="Whole.None" usage="bound" required="true"/>

    <!-- Configuration properties -->
    <property name="maxStars" display-name-key="Maximum Stars"
              of-type="Whole.None" usage="input" required="false"
              default-value="5"/>

    <property name="starColor" display-name-key="Star Color"
              of-type="SingleLine.Text" usage="input" required="false"
              default-value="#FFD700"/>

    <!-- Resources -->
    <resources>
      <code path="index.ts" order="1"/>
      <css path="css/RatingControl.css" order="1"/>
      <resx path="strings/RatingControl.1033.resx" version="1.0.0"/>
    </resources>
  </control>
</manifest>

Index.ts - Control Implementation

The main TypeScript file implements the control lifecycle:

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

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

    // Called when control is initialized
    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.starColor = context.parameters.starColor.raw || "#FFD700";
        this.currentValue = context.parameters.ratingValue.raw || 0;

        this.renderControl();
    }

    // Called when any bound property changes
    public updateView(context: ComponentFramework.Context<IInputs>): void {
        this.currentValue = context.parameters.ratingValue.raw || 0;
        this.renderControl();
    }

    // Returns the current value to Dataverse
    public getOutputs(): IOutputs {
        return {
            ratingValue: this.currentValue
        };
    }

    // Cleanup when control is removed
    public destroy(): void {
        // Clean up event listeners, timers, etc.
    }

    private renderControl(): void {
        this.container.innerHTML = "";
        const wrapper = document.createElement("div");
        wrapper.className = "rating-container";

        for (let i = 1; i <= this.maxStars; i++) {
            const star = document.createElement("span");
            star.className = i <= this.currentValue ? "star filled" : "star";
            star.innerHTML = "★";
            star.style.color = i <= this.currentValue ? this.starColor : "#ccc";
            star.onclick = () => this.setRating(i);
            wrapper.appendChild(star);
        }

        this.container.appendChild(wrapper);
    }

    private setRating(value: number): void {
        this.currentValue = value;
        this.notifyOutputChanged();
        this.renderControl();
    }
}

Building with React

React Framework Support

PCF supports React for building complex, state-driven components:

# Initialize with React template
pac pcf init --namespace Contoso --name ReactControl --template field --framework react

# This creates a React-ready structure with:
# - React and ReactDOM dependencies
# - HelloWorld.tsx component
# - Proper TypeScript configuration

React Component Example

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

interface IRatingProps {
    value: number;
    maxStars: number;
    starColor: string;
    onChange: (value: number) => void;
    disabled?: boolean;
}

export const RatingComponent: React.FC<IRatingProps> = ({
    value, maxStars, starColor, onChange, disabled
}) => {
    const [hoverValue, setHoverValue] = React.useState<number | null>(null);

    const handleClick = (rating: number) => {
        if (!disabled) {
            onChange(rating);
        }
    };

    return (
        <div className="rating-component">
            {[...Array(maxStars)].map((_, index) => {
                const starValue = index + 1;
                const isFilled = starValue <= (hoverValue ?? value);

                return (
                    <span
                        key={index}
                        className={`star ${isFilled ? 'filled' : ''} ${disabled ? 'disabled' : ''}`}
                        style={{ color: isFilled ? starColor : '#ccc', cursor: disabled ? 'default' : 'pointer' }}
                        onClick={() => handleClick(starValue)}
                        onMouseEnter={() => !disabled && setHoverValue(starValue)}
                        onMouseLeave={() => setHoverValue(null)}
                    >
                        ★
                    </span>
                );
            })}
        </div>
    );
};

Integrating React in index.ts

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

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

    public init(context: ComponentFramework.Context<IInputs>,
                notifyOutputChanged: () => void,
                state: ComponentFramework.Dictionary,
                container: HTMLDivElement): void {
        this.container = container;
        this.notifyOutputChanged = notifyOutputChanged;
        this.renderReactComponent(context);
    }

    public updateView(context: ComponentFramework.Context<IInputs>): void {
        this.renderReactComponent(context);
    }

    private renderReactComponent(context: ComponentFramework.Context<IInputs>): void {
        ReactDOM.render(
            React.createElement(RatingComponent, {
                value: context.parameters.ratingValue.raw || 0,
                maxStars: context.parameters.maxStars.raw || 5,
                starColor: context.parameters.starColor.raw || "#FFD700",
                disabled: context.mode.isControlDisabled,
                onChange: (value: number) => {
                    this.currentValue = value;
                    this.notifyOutputChanged();
                }
            }),
            this.container
        );
    }

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

Dataset Controls

Working with Datasets

Dataset controls receive collections of records to render:

// Manifest for dataset control
<data-set name="dataSet" display-name-key="Records">
  <property-set name="name" display-name-key="Name" of-type="SingleLine.Text"/>
  <property-set name="status" display-name-key="Status" of-type="OptionSet"/>
  <property-set name="dueDate" display-name-key="Due Date" of-type="DateAndTime.DateOnly"/>
</data-set>
// Accessing dataset in TypeScript
public updateView(context: ComponentFramework.Context<IInputs>): void {
    const dataset = context.parameters.dataSet;

    if (!dataset.loading) {
        const records: any[] = [];

        dataset.sortedRecordIds.forEach(recordId => {
            const record = dataset.records[recordId];
            records.push({
                id: recordId,
                name: record.getFormattedValue("name"),
                status: record.getFormattedValue("status"),
                dueDate: record.getValue("dueDate"),
                // Open record in form
                openRecord: () => dataset.openDatasetItem(record.getNamedReference())
            });
        });

        // Render records
        this.renderGrid(records);
    }

    // Handle paging
    if (dataset.paging.hasNextPage) {
        // Show "Load More" button
    }
}

Accessing Platform Features

Web API Access

PCF controls can make Web API calls for additional data operations:

// Retrieve related records
const webApi = context.webAPI;

const result = await webApi.retrieveMultipleRecords(
    "contact",
    "?$select=fullname,emailaddress1&$filter=parentcustomerid eq " + accountId
);

result.entities.forEach(contact => {
    console.log(contact.fullname);
});

// Create a record
const newTask = {
    subject: "Follow up",
    description: "Created from PCF control",
    "regardingobjectid_account@odata.bind": "/accounts(" + accountId + ")"
};

const created = await webApi.createRecord("task", newTask);
console.log("Created task: " + created.id);

Navigation and Dialogs

// Open a form
context.navigation.openForm({
    entityName: "contact",
    entityId: contactId,
    openInNewWindow: false
});

// Show confirmation dialog
const confirmResult = await context.navigation.openConfirmDialog({
    title: "Confirm Delete",
    text: "Are you sure you want to delete this record?",
    confirmButtonLabel: "Delete",
    cancelButtonLabel: "Cancel"
});

if (confirmResult.confirmed) {
    // Proceed with delete
}

// Show alert
await context.navigation.openAlertDialog({
    title: "Success",
    text: "Record saved successfully!"
});

Packaging and Deployment

Building the Solution

Package your control into a Dataverse solution for deployment:

# Build the control
npm run build

# Create solution project
cd ..
pac solution init --publisher-name Contoso --publisher-prefix con

# Add the PCF control to solution
pac solution add-reference --path ./MyCustomControl

# Build the solution
msbuild /t:build /restore

# Output: bin/Debug/YourSolution.zip

Deployment Options

  • Manual Import: Upload solution.zip via make.powerapps.com
  • Power Platform CLI: pac solution import --path solution.zip
  • Azure DevOps: Automated deployment via pipelines
  • Solution Packager: Include in larger solution packages

Best Practices

Performance Optimization

  • Minimize DOM manipulations; use virtual DOM (React) when possible
  • Debounce frequent updates to notifyOutputChanged()
  • Lazy load heavy dependencies
  • Use CSS for animations instead of JavaScript
  • Implement proper cleanup in destroy() method

Accessibility

  • Support keyboard navigation (Tab, Enter, Arrow keys)
  • Include ARIA attributes for screen readers
  • Ensure sufficient color contrast
  • Provide focus indicators
  • Test with accessibility tools

Testing

  • Use the test harness (npm start watch) during development
  • Write unit tests for business logic
  • Test in multiple browsers
  • Test on mobile devices
  • Validate behavior when disabled or read-only

Conclusion

PCF controls unlock tremendous potential for creating rich, tailored user experiences in Power Apps. By leveraging modern web development practices with TypeScript and React, you can build sophisticated components that integrate seamlessly with the Power Platform. Start with simple field controls to learn the fundamentals, then progress to complex dataset controls as you gain experience.

Need Help with Your Dynamics 365 Project?

Our team of experts can help you build custom PCF controls for your Power Apps environment.