Skip to main content
Version: 4.11.0

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.isDirty to check dirty state
  • NEVER use window.confirm() or window.alert() -- use the ServiceNow Modal component
  • FormActionBar handles save/submit/cancel automatically -- dirty state resets on successful save
  • Use key prop on RecordProvider when switching records to fully remount the form
  • For new records: pass sysId="-1" -- NEVER null or undefined

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​

  1. Always include X-UserToken: window.g_ck -- required for authentication
  2. Always use sysparm_display_value=all -- returns both display and raw values
  3. Centralize error handling -- parse JSON errors from ServiceNow responses
  4. Use useMemo for service instances -- prevents recreation on re-renders
  5. 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​

  1. UiPage API Usage: UI Pages must be created using the UiPage API from @servicenow/sdk/core.
  2. HTML Reference: Always use imports (not Now.include()) to reference HTML files in UI Page definitions. HTML files should only be placed in the src/client directory.
  3. Script Management: TypeScript/TSX code in separate files loaded via script tags with type="module". Do not embed or inline TypeScript directly in HTML.
  4. Use of HTML: Ensure HTML files are valid HTML with self-closing tags for void elements.
  5. No DOCTYPE: Never add <!DOCTYPE html> declarations. Never add XML preamble.
  6. No Jelly: Do not include Jelly elements in HTML files.
  7. No Client Script: Do not include client_script or processing_script fields.
  8. No Script Includes: Use <script src="..."></script> instead of <g:script> or <g:include>.
  9. No g_form: Do not reference g_form in UI Pages.
  10. Use React: Always use React. Do not use pure HTML or other frameworks.
  11. Event Handling: Use event listeners in TypeScript, not inline handlers like onclick="function()".
  12. Authentication: Include X-UserToken: window.g_ck header in all fetch requests.
  13. Accessibility: Follow WCAG 2.1 AA standards, use semantic HTML, ensure keyboard navigation.
  14. Ampersand Character: Use $[AMP] instead of & in text content within HTML files.
  15. Scope Access: When you need to reference your application's scope or scope ID, read it from the project's now.config.json file if it is not otherwise available.

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