JavaScript in Model-Driven Apps: Patterns That Actually Matter
A senior D365 developer's guide to JavaScript in model-driven apps — form events, Xrm.WebApi, field manipulation, async OnSave, debugging, and the patterns you'll actually use in production.
I’ve been writing JavaScript for model-driven apps since CRM 2011. Back then, Xrm.Page was the only API, Internet Explorer was the target browser, and nobody had heard of async/await. A lot has changed. A lot of bad habits haven’t.
This article covers the JavaScript patterns I use on real projects. Not theory. Not the full API reference. The stuff that matters when you’re building a form that needs to work reliably for 200 users in production.
When JavaScript Is the Right Choice
Before you write a single line of code, ask yourself: does this actually need JavaScript?
| Scenario | Best tool |
|---|---|
| Show/hide a field based on another field’s value | Business rule |
| Lock a field when a status changes | Business rule |
| Set a default value on create | Business rule |
| Validate a field against data in another table | JavaScript |
| Call an external API and show the result on the form | JavaScript |
| Control tab visibility based on complex conditions | JavaScript |
| Run custom logic on save with async calls | JavaScript |
| Set field values based on a lookup’s related records | JavaScript |
| Block save with a custom validation message | JavaScript |
| Complex calculations involving multiple fields and conditions | JavaScript (or plugin if server-side is needed) |
My rule: if a business rule can do it, use a business rule. They’re visible to non-developers, they don’t require deployments, and they’re harder to break. JavaScript is for the things business rules can’t reach — web API calls, complex conditional logic, tab/section control, and custom notifications.
Don’t use JavaScript when a plugin is the right answer either. If the logic must run server-side (security validation, cross-entity updates inside a transaction), write a plugin. JavaScript runs in the browser. Users can disable it, bypass it, or just use the API directly. It’s a UI concern, not a data integrity concern.
Form Context: Stop Using Xrm.Page
Let me be blunt: Xrm.Page is dead. Microsoft deprecated it years ago. It still works — for now — but every new feature goes through the formContext API, and one day Xrm.Page will stop working entirely.
If your codebase still has Xrm.Page.getAttribute("name").getValue() everywhere, you have tech debt. Start migrating.
Here’s the difference:
// OLD — deprecated, do not use
var name = Xrm.Page.getAttribute("name").getValue();
Xrm.Page.getAttribute("name").setValue("New Name");
// CURRENT — use formContext
function onLoad(executionContext) {
var formContext = executionContext.getFormContext();
var name = formContext.getAttribute("name").getValue();
formContext.getAttribute("name").setValue("New Name");
}
The key change: your functions receive an executionContext parameter. You extract formContext from it. That’s it. The rest of the API is almost identical.
When you register an event handler in the form designer, check the Pass execution context as first parameter checkbox. Every. Single. Time. If you forget, executionContext will be undefined and you’ll waste 20 minutes figuring out why.
Form Events: OnLoad, OnSave, OnChange
These three events cover 90% of what you’ll do with JavaScript on forms.
OnLoad
Fires when the form opens. This is where you set up initial visibility, lock fields, show/hide tabs, and register dynamic event handlers.
function formOnLoad(executionContext) {
var formContext = executionContext.getFormContext();
// Set initial field visibility based on a status
var status = formContext.getAttribute("statuscode").getValue();
if (status === 100000001) { // Approved
formContext.getControl("new_approvaldate").setVisible(true);
formContext.getControl("new_approvedby").setVisible(true);
} else {
formContext.getControl("new_approvaldate").setVisible(false);
formContext.getControl("new_approvedby").setVisible(false);
}
// Lock fields on a closed record
var statecode = formContext.getAttribute("statecode").getValue();
if (statecode === 1) { // Inactive
formContext.ui.controls.forEach(function (control) {
if (control.setDisabled) {
control.setDisabled(true);
}
});
}
}
OnChange
Fires when a field’s value changes. Register it on specific attributes, not on the form.
function onCategoryChange(executionContext) {
var formContext = executionContext.getFormContext();
var category = formContext.getAttribute("new_category").getValue();
// Show the "Priority" tab only for category "Urgent"
var priorityTab = formContext.ui.tabs.get("tab_priority");
if (priorityTab) {
priorityTab.setVisible(category === 100000002); // Urgent
}
// Make a field required based on category
if (category === 100000002) {
formContext.getAttribute("new_priorityreason").setRequiredLevel("required");
} else {
formContext.getAttribute("new_priorityreason").setRequiredLevel("none");
}
}
OnSave
Fires when the user saves the record. You can prevent save, run validation, or execute async operations.
function formOnSave(executionContext) {
var formContext = executionContext.getFormContext();
// Basic validation: block save if a required condition isn't met
var amount = formContext.getAttribute("new_amount").getValue();
var approver = formContext.getAttribute("new_approverid").getValue();
if (amount > 10000 && approver === null) {
executionContext.getEventArgs().preventDefault();
formContext.ui.setFormNotification(
"Amounts over 10,000 require an approver.",
"ERROR",
"amount_validation"
);
return;
}
// Clear the notification if validation passes
formContext.ui.clearFormNotification("amount_validation");
}
Field Manipulation: The Everyday Patterns
These are the operations you’ll write the most. Bookmark this section.
Get and Set Values
// String
var name = formContext.getAttribute("name").getValue();
formContext.getAttribute("name").setValue("Contoso Ltd");
// Option set (choice)
var status = formContext.getAttribute("new_status").getValue(); // returns integer
formContext.getAttribute("new_status").setValue(100000001);
// Lookup
var customer = formContext.getAttribute("customerid").getValue();
if (customer !== null) {
var customerId = customer[0].id; // GUID
var customerName = customer[0].name; // Display name
var entityType = customer[0].entityType; // "account" or "contact"
}
// Set a lookup value
formContext.getAttribute("new_managerid").setValue([{
id: "00000000-0000-0000-0000-000000000001",
name: "Jane Smith",
entityType: "systemuser"
}]);
// Date
var dueDate = formContext.getAttribute("new_duedate").getValue(); // returns Date object
formContext.getAttribute("new_duedate").setValue(new Date()); // set to now
// Currency / Decimal
var revenue = formContext.getAttribute("revenue").getValue(); // returns number
formContext.getAttribute("revenue").setValue(50000.00);
// Boolean (Two Options)
var isVIP = formContext.getAttribute("new_isvip").getValue(); // returns true/false
formContext.getAttribute("new_isvip").setValue(true);
Visibility, Disabled State, Required Level
These operate on the control, not the attribute. A single attribute can have multiple controls on different tabs, so you target the control.
// Show or hide
formContext.getControl("new_discountreason").setVisible(true);
formContext.getControl("new_discountreason").setVisible(false);
// Enable or disable (lock/unlock)
formContext.getControl("new_approvedby").setDisabled(true); // locked
formContext.getControl("new_approvedby").setDisabled(false); // editable
// Required level: "none", "required", "recommended"
formContext.getAttribute("new_approvedby").setRequiredLevel("required");
formContext.getAttribute("new_approvedby").setRequiredLevel("recommended"); // blue asterisk
formContext.getAttribute("new_approvedby").setRequiredLevel("none");
Common mistake: calling setVisible or setDisabled on the attribute instead of the control. The attribute doesn’t have those methods. You’ll get an error, and it won’t be obvious why.
Filtering Lookups
This comes up constantly. You want to filter a lookup field based on another field’s value.
function filterContactByAccount(executionContext) {
var formContext = executionContext.getFormContext();
var account = formContext.getAttribute("new_accountid").getValue();
if (account === null) return;
var accountId = account[0].id.replace("{", "").replace("}", "");
formContext.getControl("new_contactid").addPreSearch(function () {
var filter = "<filter type='and'>" +
"<condition attribute='parentcustomerid' operator='eq' value='" + accountId + "' />" +
"</filter>";
formContext.getControl("new_contactid").addCustomFilter(filter, "contact");
});
}
Register this on the account field’s OnChange event and also on form OnLoad (for existing records).
Important: addPreSearch adds a handler that fires every time the lookup opens. If you call it in OnChange without removing the previous handler, you’ll stack up duplicate filters. A cleaner approach is to register addPreSearch once in OnLoad and read the filter value dynamically inside the handler.
function formOnLoad(executionContext) {
var formContext = executionContext.getFormContext();
formContext.getControl("new_contactid").addPreSearch(function () {
var account = formContext.getAttribute("new_accountid").getValue();
if (account === null) return;
var accountId = account[0].id.replace("{", "").replace("}", "");
var filter = "<filter type='and'>" +
"<condition attribute='parentcustomerid' operator='eq' value='" + accountId + "' />" +
"</filter>";
formContext.getControl("new_contactid").addCustomFilter(filter, "contact");
});
}
Tab and Section Control
Tabs and sections are how you organize the form. Controlling their visibility with JavaScript is one of the most common patterns.
// Show/hide a tab
var detailsTab = formContext.ui.tabs.get("tab_details");
if (detailsTab) {
detailsTab.setVisible(false);
}
// Show/hide a section within a tab
var tab = formContext.ui.tabs.get("tab_general");
if (tab) {
var section = tab.sections.get("section_financials");
if (section) {
section.setVisible(false);
}
}
// Expand or collapse a tab
var notesTab = formContext.ui.tabs.get("tab_notes");
if (notesTab) {
notesTab.setDisplayState("expanded"); // or "collapsed"
}
Tip: Always check if the tab or section exists before calling methods on it. If someone removes the tab from the form and your code doesn’t have a null check, the entire script fails silently (or not so silently). Defensive coding saves you from support tickets.
The names you pass to tabs.get() and sections.get() are the Name property in the form designer, not the label. These are easy to confuse. The Name is set when the tab is first created and doesn’t change if you rename the label.
Notifications: Tell the User What’s Happening
Model-driven apps have two notification APIs: form-level and field-level.
Form-Level Notifications
// Show a form notification
formContext.ui.setFormNotification(
"This record is pending approval. Some fields are locked.",
"INFO", // "INFO", "WARNING", or "ERROR"
"approval_notice" // unique ID — used to clear it later
);
// Clear a specific notification
formContext.ui.clearFormNotification("approval_notice");
Form notifications appear as a banner at the top of the form. Use them for:
- Warnings about the record’s state
- Error messages during validation (with
preventDefaulton save) - Info messages after an async operation completes
Field-Level Notifications
// Set a notification on a specific field
formContext.getControl("emailaddress1").setNotification(
"This email domain is on the blocked list.",
"email_blocked" // unique ID
);
// Clear it
formContext.getControl("emailaddress1").clearNotification("email_blocked");
Field-level notifications show a red icon next to the field. They also block save by default. This is useful for inline validation.
Pattern I use often: validate a field in OnChange, set a field notification if invalid, clear it if valid.
function onEmailChange(executionContext) {
var formContext = executionContext.getFormContext();
var email = formContext.getAttribute("emailaddress1").getValue();
if (email && email.endsWith("@competitor.com")) {
formContext.getControl("emailaddress1").setNotification(
"Cannot use a competitor's email domain.",
"email_domain_check"
);
} else {
formContext.getControl("emailaddress1").clearNotification("email_domain_check");
}
}
Xrm.WebApi: CRUD Operations from the Form
Xrm.WebApi is the supported way to make data calls from client-side JavaScript. It wraps the Dataverse Web API and returns Promises.
Retrieve a Single Record
Xrm.WebApi.retrieveRecord("account", accountId, "?$select=name,revenue,telephone1").then(
function (result) {
console.log("Account name: " + result.name);
console.log("Revenue: " + result.revenue);
},
function (error) {
console.error("Error: " + error.message);
}
);
Retrieve Multiple Records
Xrm.WebApi.retrieveMultipleRecords(
"contact",
"?$filter=parentcustomerid_account/accountid eq " + accountId +
"&$select=fullname,emailaddress1" +
"&$top=10"
).then(
function (results) {
for (var i = 0; i < results.entities.length; i++) {
console.log(results.entities[i].fullname);
}
},
function (error) {
console.error("Error: " + error.message);
}
);
Create a Record
var data = {
"name": "New Account from JS",
"telephone1": "555-0100",
"revenue": 1000000,
// Set a lookup: use @odata.bind
"primarycontactid@odata.bind": "/contacts(00000000-0000-0000-0000-000000000001)"
};
Xrm.WebApi.createRecord("account", data).then(
function (result) {
console.log("Created account with ID: " + result.id);
},
function (error) {
console.error("Error: " + error.message);
}
);
Update a Record
var data = {
"telephone1": "555-0200",
"revenue": 2000000
};
Xrm.WebApi.updateRecord("account", accountId, data).then(
function () {
console.log("Account updated.");
},
function (error) {
console.error("Error: " + error.message);
}
);
Delete a Record
Xrm.WebApi.deleteRecord("account", accountId).then(
function () {
console.log("Account deleted.");
},
function (error) {
console.error("Error: " + error.message);
}
);
Performance warning: Every Xrm.WebApi call is an HTTP request to the server. In an OnLoad handler, if you fire five retrieval calls sequentially, the form will feel sluggish. Use Promise.all to run them in parallel:
function formOnLoad(executionContext) {
var formContext = executionContext.getFormContext();
var recordId = formContext.data.entity.getId().replace("{", "").replace("}", "");
Promise.all([
Xrm.WebApi.retrieveMultipleRecords("task", "?$filter=_regardingobjectid_value eq " + recordId + "&$select=subject,statecode&$top=5"),
Xrm.WebApi.retrieveMultipleRecords("phonecall", "?$filter=_regardingobjectid_value eq " + recordId + "&$select=subject,statecode&$top=5")
]).then(
function (results) {
var tasks = results[0].entities;
var calls = results[1].entities;
// Do something with the results
if (tasks.length === 0 && calls.length === 0) {
formContext.ui.setFormNotification(
"No activities found for this record.",
"INFO",
"no_activities"
);
}
},
function (error) {
console.error("Error loading activities: " + error.message);
}
);
}
Async OnSave: The Modern Pattern
Starting with model-driven apps, you can use async OnSave handlers. This is critical for scenarios where you need to make a web API call during save — like validating data against another record, creating a related record, or calling an external service.
function onSaveAsync(executionContext) {
var formContext = executionContext.getFormContext();
var amount = formContext.getAttribute("new_amount").getValue();
if (amount <= 10000) return; // no validation needed
// Tell the platform to wait for our async operation
executionContext.getEventArgs().preventDefault();
var customerId = formContext.getAttribute("customerid").getValue();
if (!customerId) {
formContext.ui.setFormNotification(
"Customer is required for amounts over 10,000.",
"ERROR",
"async_validation"
);
return;
}
var accountId = customerId[0].id.replace("{", "").replace("}", "");
// Check the customer's credit limit
Xrm.WebApi.retrieveRecord("account", accountId, "?$select=creditlimit").then(
function (result) {
var creditLimit = result.creditlimit || 0;
if (amount > creditLimit) {
formContext.ui.setFormNotification(
"Amount (" + amount + ") exceeds the customer's credit limit (" + creditLimit + ").",
"ERROR",
"credit_limit_check"
);
// Save stays prevented
} else {
formContext.ui.clearFormNotification("credit_limit_check");
formContext.ui.clearFormNotification("async_validation");
// Allow the save to proceed
formContext.data.save();
}
},
function (error) {
formContext.ui.setFormNotification(
"Failed to validate credit limit. Try again.",
"ERROR",
"credit_limit_error"
);
}
);
}
Important caveat with async OnSave: The newer approach uses executionContext.getEventArgs().disableAsyncTimeout() in combination with returning a Promise. If your environment supports it (check your app version), the cleaner pattern is:
async function onSaveAsync(executionContext) {
executionContext.getEventArgs().disableAsyncTimeout();
var formContext = executionContext.getFormContext();
var amount = formContext.getAttribute("new_amount").getValue();
if (amount > 10000) {
try {
var customerId = formContext.getAttribute("customerid").getValue();
var accountId = customerId[0].id.replace("{", "").replace("}", "");
var result = await Xrm.WebApi.retrieveRecord("account", accountId, "?$select=creditlimit");
if (amount > (result.creditlimit || 0)) {
executionContext.getEventArgs().preventDefault();
formContext.ui.setFormNotification(
"Amount exceeds the customer's credit limit.",
"ERROR",
"credit_check"
);
}
} catch (error) {
executionContext.getEventArgs().preventDefault();
formContext.ui.setFormNotification(
"Credit limit check failed: " + error.message,
"ERROR",
"credit_check_error"
);
}
}
}
This version is much cleaner. async/await makes the control flow obvious. The platform knows to wait for the Promise to resolve before deciding whether to save.
Web Resources: Naming, Deployment, and Organization
Your JavaScript files are uploaded as web resources in the solution. Here’s how to keep them organized.
Naming Convention
Use a publisher prefix and a folder-like structure:
new_/scripts/account_form.js
new_/scripts/contact_form.js
new_/scripts/shared/notifications.js
new_/scripts/shared/validation.js
The / characters create a virtual folder structure in the solution. This doesn’t affect functionality, but it keeps things manageable when you have 30 web resources.
The naming convention I recommend:
{prefix}_/scripts/{entity}_{formname}.js
For shared utilities:
{prefix}_/scripts/shared/{utility_name}.js
Real examples:
pcg_/scripts/account_main.js
pcg_/scripts/opportunity_main.js
pcg_/scripts/shared/lookupFilters.js
pcg_/scripts/shared/formHelpers.js
Registering Scripts on a Form
- Open the form in the form designer
- Select Form Properties (or click the form-level event area)
- Under Form Libraries, click Add and select your web resource
- Under Event Handlers, click Add
- Choose the event (OnLoad, OnSave), select the library, and type the function name
- Check “Pass execution context as first parameter” — I cannot stress this enough
For OnChange handlers, select the specific field first, then add the event handler in the field’s properties.
Solution-Aware Deployment
Always create web resources inside a solution. Never use the default solution for anything you plan to deploy across environments. Web resources belong to the solution’s publisher and respect solution layering.
When you export and import solutions, the web resources travel with them. If you update a script in development, export the solution, and import it to test/production, the updated script deploys automatically.
Watch out for this: If someone edits a web resource directly in production (outside of the managed solution), it creates an unmanaged layer that overrides your managed solution’s version. This causes “my code changes aren’t showing up” headaches. Always clean up unmanaged layers.
Debugging with Browser DevTools
Press F12 in your browser. This is your best tool for debugging model-driven app JavaScript.
Setting Breakpoints
- Open DevTools (F12)
- Go to the Sources tab (Chrome/Edge) or Debugger tab (Firefox)
- Press Ctrl+P to open the file finder
- Type part of your web resource name (e.g.,
account_main) - Click on the file to open it
- Click the line number to set a breakpoint
- Trigger the event (reload the form for OnLoad, change a field for OnChange, click save for OnSave)
Using debugger Statements
If you can’t find your file in the source tree, add a debugger; statement directly in your code:
function formOnLoad(executionContext) {
debugger; // Execution will pause here when DevTools is open
var formContext = executionContext.getFormContext();
// ...
}
Remove debugger statements before deploying to production. If a user happens to have DevTools open, their form will freeze. If they don’t have DevTools open, the statement is ignored — but it’s still sloppy.
Console Testing
You can test expressions directly in the console. For quick checks, the global Xrm object is available:
// In the console — works for quick testing
Xrm.Utility.getGlobalContext().getClientUrl();
Xrm.Utility.getGlobalContext().userSettings.userId;
For form-specific testing, you need a reference to the form context. The easiest way: set a breakpoint in your OnLoad handler, and once it pauses, you can use the formContext variable directly in the console.
Network Tab
The Network tab shows every API call the form makes. Filter by api/data to see Dataverse calls. This is invaluable for:
- Checking if your
Xrm.WebApicalls are actually firing - Seeing the exact OData query being sent
- Checking response codes (403 = security issue, 404 = wrong entity/field name)
- Timing slow calls
Performance: Don’t Block the Form
Here are the performance traps I see on every project.
1. Synchronous API Calls in OnLoad
This is the number one performance killer. Every Xrm.WebApi call in OnLoad that isn’t properly handled adds latency. The form doesn’t finish loading until all OnLoad handlers complete.
Bad: Five sequential API calls on load. Each takes 200-400ms. That’s 1-2 seconds of the user staring at a loading spinner.
Good: Use Promise.all to parallelize. Or better yet, question whether you need those calls at all. Can you put the data on the form as a related field? Can you use a quick view form instead?
2. Too Many OnChange Handlers
Every OnChange handler adds a tiny bit of overhead. But if you have 20 fields each with their own handler, and each handler manipulates visibility of other fields, the form starts to feel sluggish.
Solution: Consolidate related logic into fewer handlers. If five fields all control the same tab’s visibility, write one function and register it on all five fields.
3. Heavy Logic in OnSave
Users expect save to be instant. If your OnSave handler makes three API calls before allowing save, the experience is terrible.
Prefer: Pre-validate in OnChange (show warnings as users fill in the form). By the time they hit save, everything is already validated. OnSave should be a final safety net, not the primary validation mechanism.
4. DOM Manipulation
Do not directly manipulate the DOM. I know some blog posts show you how to change CSS styles or hide elements using document.getElementById. This is unsupported, will break with platform updates, and Microsoft explicitly says not to do it. Use the supported APIs only.
5. Global Variables
Don’t store state in global variables across scripts. Each web resource might load in a different scope. Use the formContext object or attributes to pass data between functions in the same execution context.
What JavaScript in Model-Driven Apps Can’t Do Well
Be honest with your team about the limits.
You can’t reliably control the ribbon/command bar from form JavaScript. The command bar has its own extensibility model (Command Designer, or the old Ribbon XML). Form JavaScript and command bar logic are separate worlds.
You can’t create responsive layouts. The form layout is controlled by the form designer. JavaScript can show/hide sections and tabs, but it can’t move fields around, change column counts, or make the form responsive to screen size.
You can’t intercept navigation reliably. There’s no supported event for “user is about to navigate away.” The OnSave event fires if they save, but if they just click away without saving, your code doesn’t run.
You can’t replace the form UI. If you need a completely custom UI experience — a custom dashboard, a drag-and-drop interface, a canvas-style layout — build a canvas app or a PCF control. Embedding a canvas app in a model-driven form is supported and often the right call.
You can’t unit test easily. There’s no built-in test framework. The Xrm object only exists inside the app runtime. You can mock it, and some teams do write unit tests for their model-driven JS, but the setup cost is high and the coverage is limited to pure logic functions.
A Reusable Pattern: The Form Handler Module
Here’s how I structure a typical form script. One file per entity form. Clear function naming. Minimal global scope.
// pcg_/scripts/account_main.js
var PCG = PCG || {};
PCG.Account = PCG.Account || {};
PCG.Account.FormOnLoad = function (executionContext) {
var formContext = executionContext.getFormContext();
PCG.Account._setInitialVisibility(formContext);
PCG.Account._lockFieldsIfInactive(formContext);
PCG.Account._registerPreSearchFilters(formContext);
};
PCG.Account.FormOnSave = function (executionContext) {
var formContext = executionContext.getFormContext();
PCG.Account._validateRequiredFields(formContext, executionContext);
};
PCG.Account.OnCategoryChange = function (executionContext) {
var formContext = executionContext.getFormContext();
PCG.Account._setInitialVisibility(formContext);
};
// --- Private helper functions ---
PCG.Account._setInitialVisibility = function (formContext) {
var category = formContext.getAttribute("new_category").getValue();
var showFinancials = (category === 100000001 || category === 100000002);
var financialsTab = formContext.ui.tabs.get("tab_financials");
if (financialsTab) {
financialsTab.setVisible(showFinancials);
}
};
PCG.Account._lockFieldsIfInactive = function (formContext) {
var statecode = formContext.getAttribute("statecode").getValue();
if (statecode !== 1) return; // Only lock inactive records
formContext.ui.controls.forEach(function (control) {
if (control.setDisabled) {
control.setDisabled(true);
}
});
};
PCG.Account._registerPreSearchFilters = function (formContext) {
var contactControl = formContext.getControl("primarycontactid");
if (!contactControl) return;
contactControl.addPreSearch(function () {
var accountId = formContext.data.entity.getId().replace("{", "").replace("}", "");
if (!accountId) return;
var filter = "<filter type='and'>" +
"<condition attribute='parentcustomerid' operator='eq' value='" + accountId + "' />" +
"</filter>";
contactControl.addCustomFilter(filter, "contact");
});
};
PCG.Account._validateRequiredFields = function (formContext, executionContext) {
var revenue = formContext.getAttribute("revenue").getValue();
var industry = formContext.getAttribute("industrycode").getValue();
if (revenue > 1000000 && !industry) {
executionContext.getEventArgs().preventDefault();
formContext.ui.setFormNotification(
"Industry is required for accounts with revenue over $1M.",
"ERROR",
"industry_validation"
);
} else {
formContext.ui.clearFormNotification("industry_validation");
}
};
Why this structure works:
- Namespace (
PCG.Account) prevents collisions with other scripts - Public functions (FormOnLoad, FormOnSave, OnCategoryChange) are what you register in the form designer
- Private functions (prefixed with
_) keep logic organized and reusable within the module - One file per form makes it easy to find the code for any form
- No global state — everything flows through
formContext
Quick Reference: Function Registration
When you register handlers in the form designer, you enter the fully qualified function name:
| Event | Library | Function | Pass Context |
|---|---|---|---|
| Form OnLoad | pcg_/scripts/account_main.js | PCG.Account.FormOnLoad | Yes |
| Form OnSave | pcg_/scripts/account_main.js | PCG.Account.FormOnSave | Yes |
| Field OnChange (new_category) | pcg_/scripts/account_main.js | PCG.Account.OnCategoryChange | Yes |
Always pass execution context. I’ve said it three times now. I’ll say it again in the future when someone’s code isn’t working in a code review.
The Bottom Line
JavaScript in model-driven apps is a sharp tool. It fills the gaps between business rules and plugins. It’s the right choice for UI-level logic: visibility, validation, notifications, and data retrieval for display purposes.
But it’s not a replacement for server-side logic. Anything that enforces data integrity belongs in a plugin. Anything a maker can configure belongs in a business rule or Power Automate.
If you’re starting a new project today: use formContext exclusively, namespace your code, structure one file per form, use Xrm.WebApi with Promises (not XMLHttpRequest), and debug with F12. That covers 95% of what you need.
And for the love of your future self — stop using Xrm.Page.
Related articles
Subgrids Done Right
Subgrids show related records directly on a form. Here's how to add them, choose the right relationship, filter the records, enable inline editing, and set up a New button that pre-fills the parent lookup.
Field Visibility with Business Rules
Business rules are the fastest way to control field visibility and editability on model-driven forms without writing code. Here's how to set them up and avoid the common traps.
Adding Custom Buttons to the Command Bar in Model-Driven Apps
Step-by-step guide to adding a custom command bar button to a Dataverse table in a model-driven app — using the modern Command Designer, no XML editing required.