Learn how to write and register JavaScript event handlers for model-driven app forms in Dataverse. This hands-on lesson walks through OnLoad, OnSave, and OnChange events with realistic business logic, the formContext API, and step-by-step registration in the form designer.

Picture this: your sales team uses a model-driven app to manage service contracts. When a rep selects "Government" as the account type, several fields — compliance certification number, procurement reference, and contract review date — suddenly need to become visible and required. When they choose "Commercial," those fields should disappear entirely. And when they save a record with a contract value above $500,000, the form should warn them that executive approval is required before submission.
You could handle some of this with business rules in Dataverse, which offer a no-code approach to field visibility and requirements. But business rules have limits — they can't make async callouts, they can't compute complex multi-field logic, and they can't interact with the full form context the way JavaScript can. When your form behavior needs to get genuinely dynamic, JavaScript form event handlers are the tool you reach for.
By the end of this lesson, you'll be able to write and register JavaScript event handlers for the three most important model-driven form events: OnLoad, OnSave, and OnChange. You'll understand what the form context API gives you, how to register your code against specific events, and how to avoid the mistakes that send developers chasing phantom bugs for hours.
What you'll learn:
formContext API to read and manipulate form dataOnLoad, OnSave, and OnChange event handlers with realistic business logicYou should be comfortable navigating Power Apps and working with model-driven apps at a basic level. If you're new to model-driven apps, work through Building Your First Model-Driven App: Site Map, Tables, Forms, and Views first. You should also understand what columns and tables are in Dataverse — if those terms are fuzzy, start with Dataverse Fundamentals: Tables, Columns, and Rows Explained for Power Apps Makers.
You don't need to be a JavaScript expert, but you should be comfortable reading and writing basic functions, variables, and if/else logic.
A form event is a trigger that fires when something happens on a model-driven app form. The platform watches what users do — opening a record, changing a field value, clicking Save — and when those actions occur, it fires the corresponding event. If you've registered a JavaScript function against that event, your code runs.
Think of it like a relay race. The platform runs its leg of the race (rendering the form, processing the save), then it hands the baton to your JavaScript function, which runs its leg before handing control back. You're not replacing the platform's behavior — you're extending it.
There are three events you'll work with constantly:
Each event gives your function a special object called the execution context (executionContext), which is the gateway to everything on the form. From it, you get the form context (formContext), which lets you read column values, show or hide fields, set field requirements, display notifications, and much more.
Note
In older Dataverse JavaScript code you may encounter, you'll see Xrm.Page used directly. This approach is deprecated and shouldn't be used in new code. Always work through executionContext.getFormContext() instead.
Before you can register an event handler, you need a place to put your code. In model-driven apps, JavaScript lives in web resources — files stored inside your Dataverse environment that the platform can serve to the browser.
Here's how to create one:
publisher_EntityName_FormName — for example, contoso_Contract_MainForm.Tip
Name your web resource and all your JavaScript functions with a consistent namespace prefix that matches your solution publisher (e.g., contoso). This prevents naming collisions when multiple customizations exist in the same environment. A function called handleOnLoad will eventually collide with someone else's function called handleOnLoad. A function called contoso_Contract_handleOnLoad almost certainly won't.
Your JavaScript file will contain a namespace object that holds all your form functions. This is the standard pattern:
// Web resource: contoso_Contract_MainForm.js
var contoso = contoso || {};
contoso.Contract = contoso.Contract || {};
contoso.Contract.handleOnLoad = function (executionContext) {
var formContext = executionContext.getFormContext();
// OnLoad logic goes here
};
contoso.Contract.handleOnSave = function (executionContext) {
var formContext = executionContext.getFormContext();
// OnSave logic goes here
};
contoso.Contract.accountType_handleOnChange = function (executionContext) {
var formContext = executionContext.getFormContext();
// OnChange logic goes here
};
Notice the pattern: every handler accepts executionContext as its first argument, then immediately calls executionContext.getFormContext() to get the form context. Everything else flows from formContext.
The OnLoad event fires once, right after the form renders. This is where you set the form's initial state based on existing data — useful for configuring visibility and field requirements when someone opens an existing record.
Here's a realistic scenario: when a contract form opens, you want to check the account type and immediately show or hide the government-specific fields.
contoso.Contract.handleOnLoad = function (executionContext) {
var formContext = executionContext.getFormContext();
// Read the current value of the "Account Type" choice column
var accountTypeAttr = formContext.getAttribute("contoso_accounttype");
if (!accountTypeAttr) return; // Column not on this form — exit safely
var accountTypeValue = accountTypeAttr.getValue();
// Apply field visibility based on the current value
contoso.Contract.applyAccountTypeVisibility(formContext, accountTypeValue);
};
contoso.Contract.applyAccountTypeVisibility = function (formContext, accountTypeValue) {
// 100000000 = "Government" option value in the choice column
var isGovernment = (accountTypeValue === 100000000);
// Show or hide government-specific fields
formContext.getControl("contoso_compliancecertification").setVisible(isGovernment);
formContext.getControl("contoso_procurementreference").setVisible(isGovernment);
formContext.getControl("contoso_contractreviewdate").setVisible(isGovernment);
// Set required level accordingly
var requiredLevel = isGovernment ? "required" : "none";
formContext.getAttribute("contoso_compliancecertification").setRequiredLevel(requiredLevel);
formContext.getAttribute("contoso_procurementreference").setRequiredLevel(requiredLevel);
formContext.getAttribute("contoso_contractreviewdate").setRequiredLevel(requiredLevel);
};
A few things worth noting here. We extracted the shared visibility logic into a separate function (applyAccountTypeVisibility) so both the OnLoad handler and the OnChange handler can call the same code without duplication. We also added a null-check (if (!accountTypeAttr) return) — if this web resource is ever registered on a form that doesn't include that column, the code won't crash; it just exits quietly.
Warning
formContext.getControl() and formContext.getAttribute() both use the logical column name (e.g., contoso_accounttype), not the display label. Logical names are lowercase and include the publisher prefix. You can find them in the column properties within the form editor, or in the table definition in Power Apps.
The OnChange event is column-specific — you register it against a particular column, and it fires each time that column's value changes. This is how you react in real time as a user fills out a form.
Building on the scenario above, we want the government fields to appear or disappear the moment someone changes the Account Type dropdown:
contoso.Contract.accountType_handleOnChange = function (executionContext) {
var formContext = executionContext.getFormContext();
var accountTypeAttr = formContext.getAttribute("contoso_accounttype");
var accountTypeValue = accountTypeAttr ? accountTypeAttr.getValue() : null;
contoso.Contract.applyAccountTypeVisibility(formContext, accountTypeValue);
};
Because we factored the logic into applyAccountTypeVisibility, the OnChange handler is just a few lines. It reads the new value and delegates. Clean and maintainable.
Here's a second OnChange example that's independent — auto-calculating a contract end date when a start date and duration are filled in:
contoso.Contract.duration_handleOnChange = function (executionContext) {
var formContext = executionContext.getFormContext();
var startDateAttr = formContext.getAttribute("contoso_startdate");
var durationAttr = formContext.getAttribute("contoso_durationmonths");
var startDate = startDateAttr ? startDateAttr.getValue() : null;
var durationMonths = durationAttr ? durationAttr.getValue() : null;
if (!startDate || !durationMonths) return; // Can't calculate without both values
// Calculate end date
var endDate = new Date(startDate);
endDate.setMonth(endDate.getMonth() + durationMonths);
formContext.getAttribute("contoso_enddate").setValue(endDate);
};
This fires whenever the duration field changes. If the start date is already set, it computes the end date automatically — a small quality-of-life improvement that prevents data entry errors.
Key insight
OnChange fires when the field value changes and the user moves focus away from the field. It does not fire on every keystroke. This is by design — you get the committed value, not intermediate typing states.
The OnSave event fires when the user initiates a save — whether they clicked the Save button, used the keyboard shortcut, or autosave triggered. Critically, it fires before the record is written to the database, which means you can cancel the save if validation fails.
Here's a handler that checks contract value and warns users when executive approval is required:
contoso.Contract.handleOnSave = function (executionContext) {
var formContext = executionContext.getFormContext();
var saveEvent = executionContext.getEventArgs();
var contractValueAttr = formContext.getAttribute("contoso_contractvalue");
var approvalStatusAttr = formContext.getAttribute("contoso_approvalstatus");
if (!contractValueAttr || !approvalStatusAttr) return;
var contractValue = contractValueAttr.getValue() || 0;
var approvalStatus = approvalStatusAttr.getValue();
// 100000001 = "Approved" in the Approval Status choice column
var isApproved = (approvalStatus === 100000001);
if (contractValue >= 500000 && !isApproved) {
// Cancel the save
saveEvent.preventDefault();
// Show a form-level notification
formContext.ui.setFormNotification(
"Contracts valued at $500,000 or more require executive approval before saving. " +
"Set Approval Status to Approved and resubmit, or contact your manager.",
"ERROR",
"approval_required_notification"
);
} else {
// Clear the notification if the condition is no longer true
formContext.ui.clearFormNotification("approval_required_notification");
}
};
Two key APIs are at work here. First, executionContext.getEventArgs() gives you the save event arguments object. Calling saveEvent.preventDefault() on it tells the platform: don't write this record to the database. The form stays open with unsaved changes.
Second, formContext.ui.setFormNotification() displays a banner at the top of the form. It takes three arguments: the message text, the notification type ("ERROR", "WARNING", or "INFO"), and a unique ID you assign. Using a consistent ID lets you later clear that specific notification with clearFormNotification().
Tip
Always clear form notifications when the condition that created them is resolved. A stale error banner that persists after the user fixes the problem will confuse and frustrate them.
Writing the JavaScript is only half the job. You need to tell the form editor which functions to call for which events. This is done through the form properties and column event configuration in the modern form designer.
contoso_Contract_MainForm). If it doesn't appear, click + Add library and search for it.contoso.Contract.handleOnLoad.Repeat the same steps for the On Save event, pointing to contoso.Contract.handleOnSave.
OnChange handlers are registered on the column itself, not the form:
contoso.Contract.accountType_handleOnChange.Repeat for the Duration field with its corresponding handler.
After registering all your events, click Save and then Publish in the form designer. Changes won't appear in the live app until you publish.
Warning
The "Pass execution context as first parameter" checkbox must be checked for every event handler. If you forget it, your function will be called with no arguments, executionContext will be undefined, and the very first line of your handler will throw an error. This is one of the most common mistakes beginners make.
The formContext object is rich. Here's a practical reference for the methods you'll use most often:
Reading and writing column values:
// Get a value
var value = formContext.getAttribute("column_name").getValue();
// Set a value
formContext.getAttribute("column_name").setValue(newValue);
Controlling field visibility:
formContext.getControl("column_name").setVisible(true); // show
formContext.getControl("column_name").setVisible(false); // hide
Setting field requirement level:
formContext.getAttribute("column_name").setRequiredLevel("required"); // mandatory
formContext.getAttribute("column_name").setRequiredLevel("recommended"); // soft hint
formContext.getAttribute("column_name").setRequiredLevel("none"); // not required
Disabling and enabling fields:
formContext.getControl("column_name").setDisabled(true); // read-only
formContext.getControl("column_name").setDisabled(false); // editable
Displaying notifications:
// Form-level banner
formContext.ui.setFormNotification("Message text", "ERROR", "unique_id");
formContext.ui.clearFormNotification("unique_id");
// Field-level error indicator
formContext.getControl("column_name").setNotification("Message", "unique_id");
formContext.getControl("column_name").clearNotification("unique_id");
Getting the form type (new vs. existing record):
var formType = formContext.ui.getFormType();
// 1 = Create, 2 = Update, 3 = Read Only, 4 = Disabled
This last one is handy in OnLoad handlers — you often want to behave differently when a new record is being created versus when an existing one is being opened.
For comparison, keep in mind that business rules in Dataverse cover many of these same scenarios (show/hide fields, set required levels) without any code. Reach for JavaScript when you need logic that business rules genuinely can't express — async operations, complex computed values, or integration with external context.
Let's put it all together. You'll build a JavaScript event handler set for a Support Case form with the following behavior:
Scenario: Your company tracks support cases in Dataverse. The form has these columns:
contoso_casetype — Choice column: "Internal" (100000000) or "External" (100000001)contoso_customername — Text column (only relevant for External cases)contoso_customercontact — Lookup column (only relevant for External cases)contoso_priority — Choice column: "Critical" (100000002)contoso_escalationapproved — Two-option (Yes/No) columnRequirements:
Step 1: Create a JavaScript web resource named contoso_SupportCase_MainForm with this content:
var contoso = contoso || {};
contoso.SupportCase = contoso.SupportCase || {};
// Shared logic for case type field visibility
contoso.SupportCase.applyCaseTypeVisibility = function (formContext, caseTypeValue) {
var isExternal = (caseTypeValue === 100000001);
formContext.getControl("contoso_customername").setVisible(isExternal);
formContext.getControl("contoso_customercontact").setVisible(isExternal);
formContext.getAttribute("contoso_customername").setRequiredLevel(isExternal ? "required" : "none");
formContext.getAttribute("contoso_customercontact").setRequiredLevel(isExternal ? "required" : "none");
};
// OnLoad handler
contoso.SupportCase.handleOnLoad = function (executionContext) {
var formContext = executionContext.getFormContext();
var caseTypeAttr = formContext.getAttribute("contoso_casetype");
var caseTypeValue = caseTypeAttr ? caseTypeAttr.getValue() : null;
contoso.SupportCase.applyCaseTypeVisibility(formContext, caseTypeValue);
};
// OnChange handler for Case Type
contoso.SupportCase.caseType_handleOnChange = function (executionContext) {
var formContext = executionContext.getFormContext();
var caseTypeAttr = formContext.getAttribute("contoso_casetype");
var caseTypeValue = caseTypeAttr ? caseTypeAttr.getValue() : null;
contoso.SupportCase.applyCaseTypeVisibility(formContext, caseTypeValue);
};
// OnSave handler
contoso.SupportCase.handleOnSave = function (executionContext) {
var formContext = executionContext.getFormContext();
var saveEvent = executionContext.getEventArgs();
var priorityAttr = formContext.getAttribute("contoso_priority");
var escalationAttr = formContext.getAttribute("contoso_escalationapproved");
var priority = priorityAttr ? priorityAttr.getValue() : null;
var escalationApproved = escalationAttr ? escalationAttr.getValue() : false;
if (priority === 100000002 && !escalationApproved) {
saveEvent.preventDefault();
formContext.ui.setFormNotification(
"Critical priority cases require escalation approval before saving.",
"ERROR",
"critical_escalation_check"
);
} else {
formContext.ui.clearFormNotification("critical_escalation_check");
}
};
Step 2: Register all four handlers (OnLoad, OnSave, and two OnChange registrations — one for case type) on the Support Case main form using the steps described earlier.
Step 3: Save and publish, then test by opening a new case, switching case type between Internal and External, and trying to save a Critical case without escalation approval.
"My handler doesn't seem to fire at all." The first thing to check is whether "Pass execution context as first parameter" is checked in the event registration dialog. The second is whether the web resource is actually added to the form's library list. The third is whether you published the form after saving changes. All three are extremely common causes of silent failures.
"I get a console error: Cannot read properties of undefined (reading 'getFormContext')"
This is the "pass execution context" checkbox problem. Your function is being called but executionContext is undefined. Fix the event registration.
"My setVisible call isn't hiding the field."
Verify that you're using formContext.getControl() (not getAttribute()). Visibility is a property of the control (the UI element), not the attribute (the data). Also confirm the logical name of the column matches exactly what's on the form.
"The field I'm trying to hide reappears."
If you're designing forms with sections and tabs, check whether the column appears on the form more than once. getControl() returns the first instance by default; if a second instance exists, it won't be hidden by your call.
"My OnSave preventDefault isn't blocking the save."
Ensure you're calling saveEvent.preventDefault() synchronously within the handler — not inside a callback or after an await. The event can only be cancelled during synchronous execution.
Key insight
Use your browser's developer tools (F12 → Console) liberally during development. JavaScript errors from web resources appear there. You can also add console.log() statements throughout your handlers to trace execution and inspect values without affecting users.
For more complex server-side logic that should be enforced regardless of which interface a user uses, consider the complementary approach of configuring Dataverse table event plugins and real-time workflows. JavaScript form handlers are client-side only — a savvy user could bypass them with the API. Critical business rules belong at the data layer too.
You now know how to write and register JavaScript event handlers for the three core model-driven form events. You can use OnLoad to configure a form's initial state when it opens, OnChange to react dynamically as users fill in fields, and OnSave to validate data and block invalid submissions before they reach the database. You understand how to structure your code with a namespace pattern that scales cleanly, how to use the formContext API to read values and manipulate the UI, and how to register everything through the form designer.
The key habits to take forward: always pass execution context, always use logical column names, always null-check attributes before reading them, and always clear notifications when the condition that triggered them is resolved.
From here, you have several natural directions to explore. If you want to go deeper on the form design that hosts your event handlers, study Designing Model-Driven Forms: Sections, Tabs, Subgrids, and Quick View Forms. If you need truly sophisticated UI components that go beyond what JavaScript can manipulate, Extending Model-Driven Apps with PCF Controls is your next stop. And if you want to package your web resources professionally for deployment across environments, get familiar with Solutions for Model-Driven Apps.