UI Page Patterns
Development patterns and guidelines for ServiceNow UI Pages, covering dirty state management, field extraction, service layer architecture, CSS styling, build constraints, and file organization.
Dirty State Management
MANDATORY for ANY view that creates, edits, or views records with a form. If a form exists, dirty state tracking MUST exist.
RecordProvider tracks dirty state internally -- do NOT implement manual field diffing with JSON.stringify. The ONLY way to check dirty state is useRecord().form.isDirty. NEVER use window.confirm() or window.alert() for dirty state warnings -- use the ServiceNow Modal component instead.
Standard Pattern
import React, { useEffect } from "react";
import { RecordProvider } from "@servicenow/react-components/RecordContext";
import { FormActionBar } from "@servicenow/react-components/FormActionBar";
import { FormColumnLayout } from "@servicenow/react-components/FormColumnLayout";
import { Alert } from "@servicenow/react-components/Alert";
import { useRecord } from "@servicenow/react-components";
function FormWithDirtyTracking() {
const { form } = useRecord();
useEffect(() => {
if (!form.isDirty) return;
const handler = (e: BeforeUnloadEvent) => {
e.preventDefault();
e.returnValue = "";
};
window.addEventListener("beforeunload", handler);
return () => window.removeEventListener("beforeunload", handler);
}, [form.isDirty]);
return (
<>
{form.isDirty && (
<Alert status="warning" content="Unsaved changes" />
)}
<FormActionBar />
<FormColumnLayout />
</>
);
}
export default function RecordForm({ sysId }: { sysId: string }) {
return (
<RecordProvider table="incident" sysId={sysId} isReadOnly={false}>
<FormWithDirtyTracking />
</RecordProvider>
);
}
Warn on In-App Navigation (Modal)
import React, { useState, useCallback } from "react";
import {
Modal,
ModalOpenedSet,
ModalFooterActionClicked
} from "@servicenow/react-components/Modal";
interface UnsavedChangesModalProps {
opened: boolean;
onDiscard: () => void;
onCancel: () => void;
}
function UnsavedChangesModal({
opened,
onDiscard,
onCancel
}: UnsavedChangesModalProps) {
const handleOpenedSet = useCallback<ModalOpenedSet>(() => {
onCancel();
}, [onCancel]);
const handleFooterAction = useCallback<ModalFooterActionClicked>(
e => {
if (e.detail.payload.action.label === "Discard") {
onDiscard();
} else {
onCancel();
}
},
[onDiscard, onCancel]
);
return (
<Modal
opened={opened}
size="sm"
headerLabel="Unsaved Changes"
content="You have unsaved changes. Are you sure you want to leave? Your changes will be lost."
footerActions={[
{ label: "Cancel", variant: "secondary" },
{ label: "Discard", variant: "primary-negative" }
]}
onOpenedSet={handleOpenedSet}
onFooterActionClicked={handleFooterAction}
/>
);
}
Integrate with the navigation pattern. Wrap the actual navigateToView call with a dirty check:
const { form } = useRecord();
const [pendingNavigation, setPendingNavigation] = useState<(() => void) | null>(
null
);
const safeNavigate = useCallback(
(
viewName: string,
recordId?: string | null,
options?: { title?: string }
) => {
if (form.isDirty) {
setPendingNavigation(
() => () => navigateToView(viewName, recordId, options)
);
return;
}
navigateToView(viewName, recordId, options);
},
[form.isDirty, navigateToView]
);
// In JSX -- use safeNavigate instead of navigateToView for all user-triggered navigation:
<UnsavedChangesModal
opened={pendingNavigation !== null}
onDiscard={() => {
pendingNavigation?.();
setPendingNavigation(null);
}}
onCancel={() => setPendingNavigation(null)}
/>;
Dirty State Key Points
- ONLY use
useRecord().form.isDirtyto check dirty state - NEVER use
window.confirm()orwindow.alert()-- use the ServiceNowModalcomponent FormActionBarhandles save/submit/cancel automatically -- dirty state resets on successful save- Use
keyprop onRecordProviderwhen switching records to fully remount the form - For new records: pass
sysId="-1"-- NEVERnullorundefined
Field Extraction Pattern
When using sysparm_display_value=all (recommended), ServiceNow reference, choice, and sys_id fields become objects. React cannot render objects directly, so you must extract primitive values.
Required Utility Functions
ALWAYS use sysparm_display_value=all in ALL Table API calls. Create these utility functions in EVERY project:
// src/client/utils/fields.ts
export const display = field => {
if (typeof field === "string") {
return field;
}
return field?.display_value || "";
};
export const value = field => {
if (typeof field === "string") {
return field;
}
return field?.value || "";
};
Usage
import { display, value } from "./utils/fields";
// For UI display:
<td>{display(record.short_description)}</td>
<td>{display(record.assigned_to)}</td>
// For operations/keys:
await updateRecord(value(record.sys_id), data);
{records.map(r => <li key={value(r.sys_id)}>)}
Common Mistakes
// WRONG - accessing object directly
<span>{record.assigned_to}</span>
// WRONG - assuming string type
<span>{record.assigned_to.toString()}</span>
// CORRECT - using display helper
<span>{display(record.assigned_to)}</span>
// WRONG - using value for display
<span>{value(record.state)}</span> // Shows "2" instead of "In Progress"
// CORRECT - display for UI, value for operations
<span>{display(record.state)}</span> // Shows "In Progress"
await api.update(value(record.sys_id), data); // Uses sys_id value
Service Layer Pattern
Centralize all API calls in a service layer to keep components focused on UI logic, enable easy testing and mocking, standardize error handling, and maintain consistent authentication.
Basic Service Class
// src/client/services/TodoService.ts
export class TodoService {
constructor() {
this.tableName = "x_app_todo";
}
async list() {
const response = await fetch(
`/api/now/table/${this.tableName}?sysparm_display_value=all`,
{
headers: {
Accept: "application/json",
"X-UserToken": window.g_ck
}
}
);
const { result } = await response.json();
return result || [];
}
async create(data) {
const response = await fetch(
`/api/now/table/${this.tableName}?sysparm_display_value=all`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
"X-UserToken": window.g_ck
},
body: JSON.stringify(data)
}
);
return response.json();
}
async update(sysId, data) {
const response = await fetch(
`/api/now/table/${this.tableName}/${sysId}?sysparm_display_value=all`,
{
method: "PATCH",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
"X-UserToken": window.g_ck
},
body: JSON.stringify(data)
}
);
return response.json();
}
async delete(sysId) {
const response = await fetch(`/api/now/table/${this.tableName}/${sysId}`, {
method: "DELETE",
headers: {
Accept: "application/json",
"X-UserToken": window.g_ck
}
});
return response.ok;
}
}
Service with Error Handling
// src/client/services/ApiService.ts
export class ApiService {
constructor(tableName) {
this.tableName = tableName;
this.baseUrl = `/api/now/table/${tableName}`;
}
async request(url, options = {}) {
try {
const response = await fetch(url, {
...options,
headers: {
Accept: "application/json",
"X-UserToken": window.g_ck,
...options.headers
}
});
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new Error(
error.error?.message || `Request failed: ${response.status}`
);
}
if (response.status === 204) {
return true;
}
return await response.json();
} catch (error) {
console.error("API Error:", error);
throw error;
}
}
async list(query = "") {
const params = new URLSearchParams({
sysparm_display_value: "all"
});
if (query) params.set("sysparm_query", query);
const { result } = await this.request(`${this.baseUrl}?${params}`);
return result || [];
}
async get(sysId) {
const { result } = await this.request(
`${this.baseUrl}/${sysId}?sysparm_display_value=all`
);
return result;
}
async create(data) {
const { result } = await this.request(
`${this.baseUrl}?sysparm_display_value=all`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data)
}
);
return result;
}
async update(sysId, data) {
const { result } = await this.request(
`${this.baseUrl}/${sysId}?sysparm_display_value=all`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data)
}
);
return result;
}
async delete(sysId) {
return this.request(`${this.baseUrl}/${sysId}`, { method: "DELETE" });
}
}
Service Key Points
- Always include
X-UserToken: window.g_ck-- required for authentication - Always use
sysparm_display_value=all-- returns both display and raw values - Centralize error handling -- parse JSON errors from ServiceNow responses
- Use
useMemofor service instances -- prevents recreation on re-renders - Keep services under 60 lines -- split into multiple services if needed
CSS Styling Guidelines
Supported CSS Patterns
Import CSS files directly in TSX/TS files using ESM syntax:
import "./filename.css";
import "./app.css";
import "./components/TodoItem.css";
Not Supported
- CSS Modules:
import styles from './file.module.css'-- NOT supported - @import statements: Within CSS files -- NOT supported
- Link tags:
<link rel="stylesheet" href="...">in HTML -- NOT supported - CSS-in-CSS imports: Relative stylesheet references -- NOT supported
File Organization
Place CSS files in src/client alongside their components. Each component can have its own CSS file. The build system automatically bundles all imported CSS.
src/client/
app.tsx
app.css
components/
TodoList.tsx
TodoList.css
TodoItem.tsx
TodoItem.css
Naming Conventions
Since CSS Modules aren't supported, use BEM naming conventions to avoid conflicts:
/* TodoItem.css */
.todo-item {
display: flex;
align-items: center;
padding: 10px;
border-bottom: 1px solid #eee;
}
.todo-item__text {
flex: 1;
}
.todo-item__text--done {
text-decoration: line-through;
opacity: 0.6;
}
.todo-item__delete {
margin-left: auto;
}
ServiceNow Theming Integration
Use CSS variables from the Horizon Design System to allow customer-authored themes to apply to your UI Page. Including the <sdk:now-ux-globals></sdk:now-ux-globals> tag in your HTML brings in support for theming.
.my-card {
background-color: var(--now-color-background-primary);
border: 1px solid var(--now-color-border-primary);
border-radius: var(--now-border-radius-md);
padding: var(--now-spacing-lg);
color: var(--now-color-text-primary);
}
.my-button {
background-color: var(--now-color-interactive-primary);
color: var(--now-color-text-inverse);
padding: var(--now-spacing-sm) var(--now-spacing-md);
border-radius: var(--now-border-radius-sm);
}
.my-button:hover {
background-color: var(--now-color-interactive-primary-hover);
}
Build System Constraints
The build system handles ALL build processes automatically.
MUST NEVER:
- Create webpack.config.js, vite.config.js, or any build configs
- Add build scripts to package.json
- Configure babel, typescript compiler, or bundlers
- Attempt to modify the build pipeline
- Add build tools as dependencies
MUST ALWAYS:
- Trust the IDE build system to handle everything
- Use only the file patterns shown in templates
- Place files in exact locations specified
- Use ESM imports (the IDE handles transformation)
What the IDE Handles Automatically
- TSX transformation
- Module bundling
- CSS processing
- Import resolution
- Development server
- Production builds
Package.json Restrictions
When adding dependencies, preserve existing versions -- never modify them. No "scripts" section, no build configurations. After modifying package.json, always install the dependencies.
HTML Entry Point Requirements
<!-- src/client/index.html -->
<html class="-polaris">
<head>
<title>My Page</title>
<sdk:now-ux-globals></sdk:now-ux-globals>
<script
src="main.tsx?uxpcb=$[UxFrameworkScriptables.getFlushTimestamp()]"
type="module"
></script>
</head>
<body>
<div id="root"></div>
</body>
</html>
The uxpcb parameter is required to ensure that stale UI Page contents are not mistakenly cached. The <sdk:now-ux-globals></sdk:now-ux-globals> tag brings in support for theming and other platform support.
File Size Guidelines
Optimal Ranges by File Type
- Components (50-80 lines ideal, 100 max): Simple display 30-50, Forms 50-80, Complex with state 60-100 max
- Service Modules (30-60 lines): API service 40-60, Single responsibility 30-50
- Hooks (20-50 lines): Simple 20-30, Complex with cleanup 40-50
- Utility Functions (20-40 lines): 3-5 related functions per file
- Main App Component (50-100 lines): Composition and routing logic
When to Split Files
Split when:
- File exceeds 100 lines
- Multiple unrelated responsibilities
- Component has 3+ useEffect hooks
- Service has 5+ API methods
Essential Requirements and Limitations
Core Requirements
- UiPage API Usage: UI Pages must be created using the
UiPageAPI from@servicenow/sdk/core. - HTML Reference: Always use imports (not
Now.include()) to reference HTML files in UI Page definitions. HTML files should only be placed in thesrc/clientdirectory. - Script Management: TypeScript/TSX code in separate files loaded via script tags with
type="module". Do not embed or inline TypeScript directly in HTML. - Use of HTML: Ensure HTML files are valid HTML with self-closing tags for void elements.
- No DOCTYPE: Never add
<!DOCTYPE html>declarations. Never add XML preamble. - No Jelly: Do not include Jelly elements in HTML files.
- No Client Script: Do not include
client_scriptorprocessing_scriptfields. - No Script Includes: Use
<script src="..."></script>instead of<g:script>or<g:include>. - No g_form: Do not reference g_form in UI Pages.
- Use React: Always use React. Do not use pure HTML or other frameworks.
- Event Handling: Use event listeners in TypeScript, not inline handlers like
onclick="function()". - Authentication: Include
X-UserToken: window.g_ckheader in all fetch requests. - Accessibility: Follow WCAG 2.1 AA standards, use semantic HTML, ensure keyboard navigation.
- Ampersand Character: Use
$[AMP]instead of&in text content within HTML files.
Technology Stack (Mandatory)
- React 18.2.0 -- All UI Pages MUST use React exclusively
- @servicenow/react-components ^0.1.0 -- MUST install with caret
^ - ServiceNow Table API -- Primary integration method for CRUD operations
- Fluent DSL -- TypeScript-based configuration language
- Build System -- Platform handles ALL build processes automatically
Limitations
- No media support: Audio, video, and WASM files are not supported
- Deterministic paths: No hashed output paths; file paths must be predictable
- No preloading:
<link rel="preload">is not supported - CSS limitations: No CSS Modules, no @import, no CSS-in-CSS imports, ESM imports only
- Routing: Use URLSearchParams, NOT hash routing
- No server-side rendering: React server components and SSR are not available