PCF Controls in Production: Building Custom UI for Dynamics 365 That Survives the Real Form
A troubleshooting reference for controls that pass in the test harness and break once deployed: lifecycle timing, the output contract, sizing, security, bundles, error handling and versioning.
A PCF control that passes in the test harness and breaks in Dynamics 365 is usually hitting something the harness does not simulate. Check in order: whether it is a field or a dataset control, because datasets page and load asynchronously; whether updateView tolerates firing on every property change, resize and dataset load; whether getOutputs returns manifest property names after notifyOutputChanged; whether layout uses allocatedWidth with trackContainerResize; and whether a context.webAPI 403 is a missing privilege of the user, since the harness enforces no security. Then add a production build, global error handling so the control degrades instead of breaking the form, manifest and solution version bumps, and a smoke-test control. Solzet builds PCF controls in TypeScript and React.
Why does a PCF control pass in the test harness and fail on a real form?
The local harness started with npm start watch is a rendering sandbox, not a small Dynamics 365. It feeds your control sample values, lets you drag a width and height slider, and reloads on save. It has no Dataverse behind it, no user, no security roles, no form around the control and no real view paging. So the harness proves that the control renders and that its layout reacts to size. It proves nothing about data, identity, timing or packaging, which is where production failures come from.
The table maps the difference to the symptom you are most likely to see. The full test plan that closes each gap, including testing as a non-administrator at real volume, is part of our PCF controls development lifecycle and is not repeated here. This page is the catalogue for when that plan was skipped or the control has already shipped.
| Area | In the harness | On a real form | Typical production symptom |
|---|---|---|---|
| Data | Static sample values you type or load | Live column values, nulls, and a dataset delivered one page at a time | Blank control on new records, or a grid that shows only the first page |
| Lifecycle timing | updateView when you change an input | updateView on property changes, resizes, dataset loads, disabled state and more | Flicker, lost typing, or an output loop |
| Output | Output values shown in the harness panel | Values written to a real column that has a type, security and an OnChange event | Field never updates, or OnChange never fires |
| Size | Width and height sliders | Width from the form section, height often unconstrained | Control squashed, clipped, or reporting a size of -1 |
| Security | None | The signed-in user with their roles and column security | 403 from context.webAPI, or a column that reads as empty |
| Bundle | Development build served locally | Whatever build was packaged into the solution, cached by version | Slow first load, or the old behaviour after an import |
Why does it matter whether it is a field control or a dataset control?
A field control is bound to one column through a property with usage bound, receives that column value in context.parameters, and hands a new value back through getOutputs. A dataset control is declared with a data-set element, is bound to a view or subgrid, and receives a dataset object: records, sortedRecordIds, columns, a loading flag and a paging object. They are different contracts, and code written against one tends to fail quietly against the other.
Dataset controls break in production for reasons the harness cannot show. The first updateView often arrives while the dataset is still loading, so a control that renders immediately draws an empty grid and may never redraw correctly. Only one page of records is present, so anything that counts, totals or searches the visible records is wrong on a real view. Columns in the view definition decide what the control receives, so a column that exists in the harness sample but not in the production view arrives as missing. And writes do not flow back through getOutputs the way a field value does: a dataset control usually changes records through the Web API or by opening the record, which brings the user's security into play.
- Field control checks. Handle null and undefined raw values, respect context.mode.isControlDisabled, and read the security flags on the bound property before rendering an editable input.
- Dataset control checks. Return a loading state while dataset.loading is true, read paging.totalResultCount before assuming completeness, and call loadNextPage on a user action rather than in a loop on form load.
- Both. Use context.updatedProperties to tell what changed, rather than rebuilding everything on every call.
When does updateView actually fire?
Far more often than most developers assume, and not only when your data changes. Treat updateView as something the host may call at any time, repeatedly, with the same values it passed last time. The practical rule is that updateView must be cheap and idempotent: compare what you received with what you already rendered, update in place, and never start work that has side effects. The lifecycle code that follows this rule is written out on the PCF development service page, so here is only the list of triggers that catch people out.
- Once straight after init, before the user has done anything.
- Whenever the value of any bound or input property changes, including a change made by a form script, a business rule, or another control on the same form.
- After your own output is accepted: you call notifyOutputChanged, the host reads getOutputs, writes the column, then calls updateView with the value you just produced.
- When the container size changes, but only if the control asked for it by calling context.mode.trackContainerResize(true), which is why sizing bugs appear on a phone or a narrow section and not in the harness.
- When a dataset starts loading, finishes loading, moves to another page, or is sorted, filtered or refreshed.
- When the control is enabled or disabled, for example by a business rule or when a record becomes read only, and in some hosts when full screen mode opens or closes.
- Sometimes for reasons unrelated to your control. context.updatedProperties tells you which inputs the host considers changed, such as a property name or layout for a resize, so check it rather than assuming.
Why does notifyOutputChanged not update the field or fire OnChange?
Because the output contract has more steps than it looks. Calling notifyOutputChanged does not write anything. It tells the host that the control has a new value, and the host then calls getOutputs when it is ready, which may not be immediately. Whatever getOutputs returns at that moment is what gets written. If the internal state was not yet set, was reset by a later updateView, or is keyed wrongly, the host writes nothing or writes the wrong thing, and no error appears.
When the value does land on a model-driven form, the column changes and the column OnChange handlers run as they would for a user edit. When nothing reaches the column, OnChange never fires, which is why "OnChange not firing" is almost always an output contract problem rather than an event registration problem.
- Keys must match the manifest. getOutputs returns an object whose keys are the property names declared with usage bound or output in ControlManifest.Input.xml. A misspelled key or a key from an older manifest is ignored silently. Run refreshTypes after every manifest change so the generated IOutputs type catches it at build time.
- Treat undefined as meaningful. Returning undefined for a key is how a control clears a bound value, so a getOutputs that returns undefined because state was never set can clear the column or drop the update. Only return keys you intend to write, and test the empty case deliberately in your host.
- Match the column type. A Whole.None property expects a number, a DateAndTime property a Date, a two options property a boolean. A string where a number is expected is not written.
- Keep getOutputs a pure, synchronous read of state. No awaits, no Web API calls, no rendering.
- Never call notifyOutputChanged unconditionally from updateView. The host echoes the value back through updateView, which calls notify again, and the form loops or the user loses what they were typing.
- Guard the echo. When updateView delivers the value you just output, or an older value while the user is still editing, do not overwrite the input the user is working in.
- Check the property is really bound. If the control is configured on the form against a different column, or the property is input rather than bound, there is nothing for the output to write to.
Why is the control the wrong size inside a model-driven form?
In the harness you choose the width and height. On a model-driven form the section, the column layout, the device and the form factor choose them, and they change while the form is open. context.mode.allocatedWidth and allocatedHeight only carry useful, updating values when the control called context.mode.trackContainerResize(true) in init. Without that call, or where the host does not constrain a dimension, the value can be -1, and a control that sets its width to -1 pixels, or divides by the reported height, renders as a sliver or not at all.
On model-driven forms height is usually the unconstrained dimension, because a section grows with its content. Design for width, let height follow content, and handle -1 explicitly. Lay out against the allocated width rather than against window dimensions, since the window is the whole app and not your container, and test at the narrow widths a phone, a side pane or a two column section produce. Dataset controls on a subgrid get a different allocation from the same control on a full page view, so test each placement the specification promised.
Why does context.webAPI return 403 in production when it worked for you?
Because the harness never ran it, and the first environment that did ran it as you. The harness has no Dataverse, so Web API calls from a control are first exercised in a development environment, usually by the developer or an administrator, whose roles allow almost everything. In production the call runs as the signed-in user, with that user's security roles, business unit and team memberships. A control cannot elevate itself: there is no system identity behind context.webAPI. So a 403 in production is a privilege problem of the calling user, not a bug in the control, and the error message from Dataverse usually names the privilege that is missing, such as read on a particular table.
Column security behaves differently and is easy to misread. A secured column the user cannot read typically comes back empty rather than failing, so a control shows a blank or zero where it expected a value, while an update to a secured column the user cannot update is refused. The bound property's security flags tell a field control whether its own column is readable and editable before it tries.
- Reproduce as the user. Open the form as a test user holding the exact production role, not as an administrator, and watch the failing request in the browser network tab.
- Read the error body, not only the status. It normally identifies the privilege and the table.
- Decide whether the user should have that access. If yes, fix the role. If no, the control is querying data the user was never meant to see and the design has to change, not the role.
- Declare the feature. context.webAPI is undefined, rather than forbidden, when the manifest does not declare the WebAPI feature, so an undefined object and a 403 are two different faults.
- Fail soft. Catch the rejected promise, show a clear message or the reduced view the user is allowed, and keep the rest of the control working.
Why does production load a different or slower bundle than the one you tested?
Two separate problems look alike here: the wrong build and the wrong version. The build produced by npm run build during development is a development build, unminified and larger. A production build comes from the release configuration of the solution project, or from building with the production build mode, and is what should be packaged. A development bundle imported into production still works, which is why it goes unnoticed, but every user downloads the heavier file the first time a form with the control loads.
Size is the second half. A virtual control that declares React and Fluent as platform libraries borrows them from the host instead of bundling its own copies, which keeps the bundle small and the styling consistent with the app. Bundling a whole charting, mapping, date or icon library for one feature is the usual reason a control is heavy; import only what you use, check what tree shaking actually removed, and look at the size of bundle.js before anyone opens the form. Our guide to integrating third party libraries into a PCF control covers bundling against externalising a large library.
The version problem is simpler and more common. The host caches the control by its version, so a new bundle imported without incrementing the version in ControlManifest.Input.xml, and the solution version with it, can keep serving the previous code. Increment both, publish all customizations, hard refresh, and confirm on the real form. The full release checklist is on the PCF controls development page.
How do you stop one failing control from breaking the whole form?
Assume something will fail in production that never failed in testing: a null in a column nobody expected, a denied read, a slow network, a release wave that changes a detail of the host. The goal is that a failure degrades the control and never blocks the user from completing the record. A control that throws out of init or updateView can leave an empty area or an error where the field should be, and the user cannot tell whether their data is safe.
- Wrap each lifecycle method. A try and catch around the body of init, updateView, getOutputs and destroy, with a logged error and a fallback render, so one exception does not escape to the host.
- Add a React error boundary. Wrap the component tree returned from updateView so a render error shows a fallback instead of unmounting the control. Remember error boundaries do not catch errors in promises or event handlers, so those need their own handling.
- Render a fallback that still works. For a field control, a plain read only view of the value, or a simple input that still writes through getOutputs. For a dataset control, a message and a link to open the standard view.
- Handle every promise. Web API calls and any other async work need a catch that updates the control state, not an unhandled rejection in the console.
- Make errors visible to support. Log with a recognisable prefix and the control version, so a screenshot of the browser console tells the support team which build failed.
- Keep a switch off route. An administrator can set the column or subgrid back to its default control in a solution change, which takes a broken control out of the user path without a developer.
What should a smoke-test control check before you blame the real one?
When a control fails only in one environment, deploy a deliberately tiny control there first. It separates environment problems from code problems in minutes. Keep it in its own solution with its own publisher, build it in production mode, and add it to a text column on a test form that ordinary users can open. It should do nothing clever, only report what the environment gives it.
- Its own version, printed on screen, so you can see that the import and publish actually took effect.
- context.client.getClient() and the form factor, so you know which host rendered it.
- allocatedWidth and allocatedHeight after trackContainerResize(true), updated on resize, so sizing assumptions are visible.
- Whether context.webAPI is defined, and the result of one read, such as retrieving the current user record by context.userSettings.userId, so the Web API path and the user privilege are tested together.
- An input that writes back to the bound column through notifyOutputChanged and getOutputs, with a form OnChange script that logs the change, so the output contract is proven end to end.
- A counter of updateView calls and the latest context.updatedProperties, so you can see what the host is really doing.
- A deliberate error behind a button, caught by the error boundary, so you know the fallback renders in this host.
When is a PCF control the wrong fix for the problem?
Sometimes the production failure is telling you the control should not exist. If the modern grid, the editable grid, a first party control or a small configuration change now covers most of what the control does, retiring it is cheaper than hardening it, and PCF controls: buy, build, or customize sets out how to decide. If the control has to run in a canvas app as well, the hosting rules are different, and using PCF controls in canvas apps covers them. If the control was built by someone who has left and nobody can rebuild it, the question is who owns it next, which hiring PCF developers: team or freelancer works through.
We recommend the right solution - whether that's Microsoft Dynamics 365, Power Platform, or a custom-built CRM. Some businesses need the Microsoft ecosystem. Others need full control without licensing. We deliver both. Where the real constraint is that the interface has outgrown what a form can host, or Microsoft licensing does not fit, a custom-built CRM on React, Node.js, PostgreSQL and .NET is the alternative.
Should custom UI live in a Dynamics 365 form or in a platform you build and own?
Can afford licensing and want the Microsoft ecosystem
Dynamics 365
Microsoft 365, Teams and Outlook integration, a mature partner ecosystem, Copilot, and apps for sales, service and field operations that are configured rather than built.
Need full control and zero licensing
Custom CRM
A CRM built on React, Node.js, PostgreSQL or .NET that you own outright: your data model, your hosting, no per-user subscription, and features shaped exactly to your process.
Not sure which fits
We help you decide
A short discovery weighs licensing budget, process complexity, integrations and long-term ownership, then recommends one path. We deliver both, so the recommendation has no reason to lean.
How does Solzet help when a PCF control fails in production?
Solzet builds PCF controls in TypeScript and React. Our senior consultants and full-stack developers deliver remotely from Yerevan, Armenia, with 8+ years of Dynamics 365 Customer Engagement and Power Platform work, directly for end clients or white-label for Microsoft partners. For a control that fails in production we start with evidence rather than a rewrite: reproduce it as a real user, run a smoke-test control in the affected environment, read the manifest, the output contract, the lifecycle code and the bundle, then give you the smallest change that fixes it or an honest recommendation to replace it.
Where the control is one symptom of a wider problem, the work extends to the rest of the environment. What a full build or takeover engagement covers is on the PCF controls development service page.
What do people ask us?
Why does my PCF control work in the test harness but not in Dynamics 365?
The harness has no Dataverse, no user, no security roles, no form context and no real paging. It proves that a control renders and resizes, not that it handles live data, the signed-in user's privileges, repeated updateView calls, a real output write or the packaged bundle. Test on a real form as a user with the production role, and deploy a small smoke-test control to separate environment problems from code problems.
Why does context.webAPI return a 403 error in production?
Because the call runs as the signed-in user and that user lacks a privilege, usually read or write on a table. The harness never ran the call, and development testing usually ran it as an administrator. A PCF control cannot elevate its permissions. Read the error body, which normally names the missing privilege, then either correct the security role or change the control so it only queries data the user is meant to see.
Why is notifyOutputChanged not updating the field?
notifyOutputChanged only tells the host a value is ready; the host then calls getOutputs and writes whatever it returns. The usual faults are a key that does not match the property name in the manifest, a value of the wrong type for the column, internal state reset by a later updateView before getOutputs ran, or undefined returned by accident, which can clear the column or drop the update. Regenerate types with refreshTypes after manifest changes.
Why does the OnChange event not fire when my PCF control changes a value?
On a model-driven form the column OnChange handlers run when the value the control outputs is actually written to the column. If OnChange never fires, the write never happened, so check the output contract: that notifyOutputChanged is called, that getOutputs returns the bound property name with a correctly typed value, and that the control is bound to the column the handler is registered on.
How often is updateView called in a PCF control?
Often. It runs once after init, whenever any bound or input property changes, after the host writes your own output back, on container resize when trackContainerResize(true) was called, when a dataset loads, pages, sorts or refreshes, and when the disabled state changes. It can also run with nothing relevant changed. Keep it cheap and idempotent, and use context.updatedProperties to see what the host considers changed.
Why do allocatedWidth and allocatedHeight return -1?
The values are only tracked and updated when the control calls context.mode.trackContainerResize(true), usually in init, and even then a dimension the host does not constrain can report -1. On model-driven forms height is typically unconstrained because a section grows with its content. Lay out against the allocated width, let height follow content, and handle -1 explicitly rather than using it as a pixel value.
Why does Dynamics 365 still run the old version of my PCF control after import?
The host caches a control by its version. If the version in ControlManifest.Input.xml was not incremented, and the solution version with it, the previous bundle can keep being served even though the import succeeded. Increment both, import, publish all customizations, hard refresh the browser, and confirm on the real form. A smoke-test control that prints its own version makes this check immediate.
How should a PCF control handle errors so it does not break the form?
Wrap the body of each lifecycle method in try and catch, add a React error boundary around the rendered tree, and catch every promise from Web API calls. When something fails, render a fallback the user can still work with, such as a read only view of the value or a simple input, and log the error with the control version. Administrators can also set the column back to its default control without a developer.
Where should you go next?
PCF controls development
The full lifecycle: planning the manifest, coding against init, updateView, getOutputs and destroy, testing, and the deployment checklist.
PCF controls in canvas apps
How code components run in canvas apps, how properties bind through Power Fx, and which APIs stay model-driven only.
Hire PCF developers: team or freelancer
Who should build and own a control, what the deliverable must include, and the screening questions to ask.
PCF controls: buy, build, or customize?
Whether a control should exist at all, with the three routes costed side by side.
Third party libraries in a PCF control
Bundling against externalising a large library, creating instances once and disposing of them in destroy.
Dynamics 365 security: business units and teams
The security roles, teams and column security that decide whether a Web API call from a control succeeds.
Which solution is right for your business?
Tell us what you need. A senior consultant replies within one business day with a recommendation - Dynamics 365, Power Platform, or a custom-built CRM - not a sales script.