Two real components. Every concept broken down. From basic queries to nested children, variables, and live refresh.
Before touching code, understand what GraphQL actually is and why it matters in Salesforce.
Your query shape = your response shape. Whatever structure you write in the query, you get back data in that exact same shape. This is the #1 rule — everything else follows from it.
REST: You call /api/accounts and get 50+ fields back whether you want them or not.
GraphQL: You ask for exactly Name + Industry. Server returns only those 2 fields. Surgical precision.
Salesforce wraps everything inside uiapi → query. Think of uiapi as the API gateway and query as the "read" section of the menu. Always the outer shell.
Unlike standard GraphQL where Name returns a string, Salesforce wraps fields: Name { value }. This is because Salesforce adds displayValue metadata too. Only Id is a plain string — no { value } needed.
@wire(graphql) adapter is only for fetching data. For Create, Update, Delete — you still use lightning/uiRecordApi (createRecord, updateRecord, deleteRecord). After mutations, call refreshGraphQL() to re-sync the UI.
Fetch a list of Accounts with GraphQL, display them in a table, and Create / Update / Delete via uiRecordApi.
| Part | Code | What it means |
|---|---|---|
| Operation | query AccountList { … } | Named GraphQL operation. Always name your queries — helps with debugging and caching. |
| API Gateway | uiapi { … } | Salesforce's root namespace. Every Salesforce GraphQL query starts here. |
| Read section | query { … } | Inside uiapi — signals "I'm reading data." (Not mutating.) The word query appears twice — outer is the operation type, inner is uiapi's read section. |
| Object + limit | Account(first: 10) | The sObject you're querying, and how many records max to return. Like SOQL LIMIT. |
| Sort | orderBy: { Name: { order: ASC } } | Sorts results ascending by Name. Like SOQL ORDER BY Name ASC. |
| List wrapper | edges { … } | GraphQL "connection" pattern. Wraps the list of results. Always required when fetching multiple records. |
| Single record | node { … } | Each individual record in the list. Like one row in a database result. |
| System field | Id | The record's Salesforce ID. Only field that returns a plain string — no { value } wrapper needed. |
| Standard field | Name { value } | All standard fields return an object with { value } (and optionally displayValue). Must unwrap in JS. |
| Optional field | Industry { value } | Nullable field. Use ?. in JS to safely access: Industry?.value ?? '—' |
query AccountList { uiapi { query { Account(first: 10) { edges { node { Id Name { value } Industry { value } } } } } } }
{
"uiapi": {
"query": {
"Account": {
"edges": [
{
"node": {
"Id": "001...",
"Name": {
"value": "Acme Corp"
},
"Industry": {
"value": "Technology"
}
}
}
]
}
}
}
}
// Clean flat array: [ { Id: "001...", Name: "Acme Corp", Industry: "Technology" }, ... ] // Template uses clean names: {acct.Name} {acct.Industry} // No acct.Name.value needed!
// data.uiapi.query.Account.edges is an array of { node: {...} } objects // .map() loops through each edge and extracts what we need: this.accounts = data.uiapi.query.Account.edges.map(edge => ({ Id: edge.node.Id, // Id = plain string, no .value Name: edge.node.Name.value, // unwrap .value → "Acme Corp" Industry: edge.node.Industry?.value ?? '—' // ?. handles null, ?? sets fallback }));
import { createRecord } from 'lightning/uiRecordApi'; async handleCreate() { await createRecord({ apiName: 'Account', fields: { Name: this.newName.trim() } }); await refreshGraphQL(this.wiredResult); // re-run the @wire query }
import { updateRecord } from 'lightning/uiRecordApi'; async handleUppercase(event) { const id = event.target.dataset.id; const current = this.accounts.find(a => a.Id === id); await updateRecord({ fields: { Id: id, Name: current.Name.toUpperCase() } }); await refreshGraphQL(this.wiredResult); }
import { deleteRecord } from 'lightning/uiRecordApi'; async handleDelete(event) { const id = event.target.dataset.id; await deleteRecord(id); await refreshGraphQL(this.wiredResult); // sync the list }
// KEY PATTERN: Always store the wire result for later refresh wiredResult; // ← class property @wire(graphql, { query: gql`...` }) handleQuery(result) { this.wiredResult = result; // ← store it HERE const { data, errors } = result; // ... process data } // Later, after any mutation: await refreshGraphQL(this.wiredResult); // ← use stored ref
A detail-page component that fetches one Account plus its related Contacts and Opportunities — all in a single GraphQL query. Introduces dynamic variables, nested child queries, and formatted display values.
Instead of hardcoding a filter value, you declare a variable with $accountId: ID! (the ! means required). The value is passed at runtime via the variables option in @wire. This makes the query reusable for any record.
The string '$variables' tells the wire framework to call this.variables getter reactively. When this.recordId changes (e.g. user navigates to another record), the getter returns a new value and the wire automatically re-runs the query.
Inside the Account node, you can query related Contacts and Opportunities directly. This is the superpower of GraphQL — what would be 3 REST API calls becomes 1 GraphQL query. Each child relationship can have its own first, orderBy, and where arguments.
Fields like AnnualRevenue and Amount support both value (raw number: 1500000) and displayValue (formatted string: "$1,500,000"). Use displayValue when showing to users, value for calculations.
Add totalCount alongside edges to get the total number of matching records (respects your where filter) without having to iterate. Useful for showing "37 Contacts" in a badge even if you only fetched the first 10.
On a detail page, your where clause filters to one record. The response still wraps it in edges[0] — GraphQL always returns a list. Access it with data.uiapi.query.Account.edges[0]. Always guard with a null check (if (!edge)).
// $accountId: ID! declares a required variable of type Salesforce ID query AccountDetail($accountId: ID!) { uiapi { query { // where: { Id: { eq: $accountId } } → runtime variable injected here Account(where: { Id: { eq: $accountId } }) { edges { node { Id Name { value } Industry { value } AnnualRevenue { value // raw: 1500000 displayValue // formatted: "$1,500,000" ← show this to users } // ↓ NESTED CHILD QUERY — Contacts related to this Account Contacts( first: 50 orderBy: { LastName: { order: ASC } } ) { edges { node { Id Name { value } Title { value } Email { value } } } totalCount // total contacts, even if first: 50 limits display } // ↓ NESTED CHILD QUERY — Opportunities related to this Account Opportunities( first: 50 orderBy: { CloseDate: { order: DESC } } ) { edges { node { Id Name { value } StageName { value } Amount { value // raw number for calculations displayValue // "$250,000" for display } CloseDate { value // "2025-06-30" (ISO) displayValue // "6/30/2025" (locale-formatted) } } } totalCount } } } } } } }
@api recordId; // platform sets this automatically // Getter returns variables object get variables() { return { accountId: this.recordId }; } // '$variables' tells wire to call this.variables @wire(graphql, { query: gql`query AccountDetail($accountId: ID!) { ... }`, variables: '$variables' // ← the reactive link }) handleResult(result) { ... }
// User is on Account record: 001abc123... // Platform sets: this.recordId = "001abc123..." // variables getter returns: { accountId: "001abc123..." } // Wire runs query with: where: { Id: { eq: "001abc123..." } } // User navigates to different record? // recordId changes → getter returns new value // → wire auto-reruns! No manual refresh needed.
const edge = data.uiapi.query.Account.edges[0]; // detail page = one record if (!edge) { this.error = 'Account not found.'; return; } const node = edge.node; // 1. Parent account this.account = { Id: node.Id, Name: node.Name.value, Industry: node.Industry?.value ?? '—', Revenue: node.AnnualRevenue?.displayValue ?? '—' // displayValue = "$1,500,000" }; // 2. Child contacts — nested edges.map() inside parent node this.contacts = node.Contacts.edges.map(e => ({ Id: e.node.Id, Name: e.node.Name?.value ?? '—', Title: e.node.Title?.value ?? '—', Email: e.node.Email?.value ?? '—' })); // 3. Child opportunities this.opportunities = node.Opportunities.edges.map(e => ({ Id: e.node.Id, Name: e.node.Name?.value ?? '—', StageName: e.node.StageName?.value ?? '—', Amount: e.node.Amount?.displayValue ?? '—', // "$250,000" CloseDate: e.node.CloseDate?.displayValue ?? '—' // "6/30/2025" }));
The third evolution: instead of mixing GraphQL reads with uiRecordApi writes, this component does everything in GraphQL — including Create, Update, and Delete — using the newer lightning/graphql module and executeMutation.
lightning/uiGraphQLApi. This component imports from lightning/graphql — a newer module that adds executeMutation support and a different refresh mechanism. The @wire(graphql) syntax is the same; what changes is how you refresh and how you write data.
Instead of calling createRecord() / updateRecord() / deleteRecord() from uiRecordApi, you write a mutation GraphQL operation and execute it with executeMutation({ query: mutation }). The write goes through the GraphQL API end-to-end — same transport, same namespace, consistent mental model.
In Components 1 & 2, you imported refreshGraphQL and called refreshGraphQL(this.wiredResult). In this pattern, the wire result itself hands you a refresh function: const { data, errors, refresh } = result. Store it and call it directly: await this.refreshGraphQL(). Cleaner API.
Mutations live inside the same uiapi { } namespace. The pattern is: ObjectCreate, ObjectUpdate, ObjectDelete. Each takes an input argument and returns a Record { } with the created/updated fields — or just Id for deletes.
Since values are interpolated directly into the gql template literal (e.g. ${safeName}), you must escape them first. JSON.stringify(raw) wraps the value in quotes and escapes any special characters — preventing injection issues with names like O'Brien "Co".
When you delete a record via GraphQL mutation, Salesforce's Lightning Data Service (LDS) cache automatically removes it from all active wire subscriptions. You don't need to call refresh after a delete — the list updates itself. (Calling refresh is still safe, just not required.)
import { gql, graphql, refreshGraphQL } from 'lightning/uiGraphQLApi'; // Refresh: pass stored result reference wiredResult; handleQuery(result) { this.wiredResult = result; } await refreshGraphQL(this.wiredResult); // Write: use separate uiRecordApi import { createRecord } from 'lightning/uiRecordApi';
import { gql, graphql, executeMutation } from 'lightning/graphql'; // Refresh: function comes FROM wire result refreshGraphQL; handleQuery(result) { const { data, errors, refresh } = result; if (refresh) this.refreshGraphQL = refresh; } await this.refreshGraphQL?.(); // Write: executeMutation — no uiRecordApi!
// 1. Escape user input first — NEVER inject raw strings const safeName = JSON.stringify(this.newName.trim()); // "Acme Corp" → '"Acme Corp"' // 2. Build the mutation using template literal injection const mutation = gql` mutation CreateAccount { uiapi { AccountCreate(input: { // ObjectCreate pattern Account: { Name: ${`${`${`safeName`}`}`} } // injected as: "Acme Corp" }) { Record { // returns the newly created record Id Name { value } } } } } \`; // 3. Execute and handle errors const result = await executeMutation({ query: mutation }); if (result?.errors?.length) throw new Error(result.errors[0].message); // 4. Refresh the wire query to show the new record await this.refreshGraphQL?.();
// Update requires both Id + the fields to change const safeId = JSON.stringify(id); const safeName = JSON.stringify(current.Name.toUpperCase()); const mutation = gql` mutation UpdateAccount { uiapi { AccountUpdate(input: { // ObjectUpdate pattern Id: ${`${`${`safeId`}`}`}, // which record to update Account: { Name: ${`${`${`safeName`}`}`} } // what to change }) { Record { // returns the updated record Id Name { value } } } } } \`; const result = await executeMutation({ query: mutation }); // LDS cache often updates automatically for updates, but refresh to be safe await this.refreshGraphQL?.();
// Delete only needs the Id — no other fields required const safeId = JSON.stringify(id); const mutation = gql` mutation DeleteAccount { uiapi { AccountDelete(input: { Id: ${`${`${`safeId`}`}`} }) { // ObjectDelete pattern Id // returns just the Id of the deleted record } } } \`; const result = await executeMutation({ query: mutation }); // 💡 LDS auto-prunes deleted records from all active wires // No explicit refresh needed — the list updates automatically!
// WHY: If a user types: O'Brien "Co" \ // and you inject it raw: Name: O'Brien "Co" \ // → GraphQL parse error (broken string literal) // gqlString() helper uses JSON.stringify() to escape safely: gqlString(raw) { return JSON.stringify(raw ?? ''); } // Examples: // "Acme Corp" → '"Acme Corp"' ✅ plain string // "O'Brien Co" → '"O\'Brien Co"' ✅ apostrophe escaped // 'Test "Name"' → '"Test \\"Name\\""' ✅ quotes escaped // "Backslash \\ test" → '"Backslash \\\\ test"' ✅ backslash escaped // Always escape BEFORE injecting into gql template literals // This is your protection against GraphQL injection
| Operation | uiRecordApi (Components 1 & 2) | GraphQL Mutation (Component 3) |
|---|---|---|
| Create | createRecord({ apiName, fields }) | AccountCreate(input: { Account: { Name: "…" } }) |
| Update | updateRecord({ fields: { Id, Name } }) | AccountUpdate(input: { Id: "…", Account: { Name: "…" } }) |
| Delete | deleteRecord(id) | AccountDelete(input: { Id: "…" }) |
| Refresh after | refreshGraphQL(this.wiredResult) | this.refreshGraphQL() (from wire result) |
| Auto-refresh on delete | No — must call refreshGraphQL | Yes — LDS prunes automatically |
| Import module | lightning/uiGraphQLApi + lightning/uiRecordApi | lightning/graphql (single import) |
| Mental model | Mixed: GraphQL reads + REST-style writes | Unified: everything is GraphQL |
Common GraphQL query structures you'll use in Salesforce LWC projects.
query BasicContacts { uiapi { query { Contact { edges { node { Id FirstName { value } LastName { value } Email { value } } } } } } } // You asked for 3 fields. Server returns 3 fields. // REST would return 50+ fields. GraphQL is precise.
query FilteredAccounts { uiapi { query { Account( where: { // Single field condition: Industry: { eq: "Technology" } // AND multiple conditions: and: [ { Industry: { eq: "Technology" } } { AnnualRevenue: { gt: 1000000 } } ] // OR conditions: or: [ { Industry: { eq: "Technology" } } { Industry: { eq: "Finance" } } ] // Contains (LIKE %word%): Name: { like: "%Salesforce%" } // Null check: AnnualRevenue: { isNull: false } } ) { edges { node { Id Name { value } } } } } } } // Operators: eq, ne, lt, lte, gt, gte, like, in, nin, isNull
query Top10Accounts { uiapi { query { Account( first: 10 // LIMIT — max records returned orderBy: { // ORDER BY Name: { order: ASC } // ASC or DESC } ) { edges { node { Id Name { value } CreatedDate { value displayValue } } } } } } } // Multi-field sort (primary + secondary): // orderBy: { Industry: { order: ASC } Name: { order: ASC } }
// With a $after variable (cursor from previous page) query PaginatedAccounts($after: String) { uiapi { query { Account( first: 10 after: $after // pass cursor from previous response ) { edges { cursor // bookmark for this specific record node { Id Name { value } } } pageInfo { hasNextPage // true if more records exist endCursor // pass this as $after for next page } totalCount // total matching records overall } } } } // In JS: pass variables: { after: this.endCursor } to @wire
query Account360($accountId: ID!) { uiapi { query { Account(where: { Id: { eq: $accountId } }) { edges { node { Id Name { value } Industry { value } // Child relationship — same edges/node pattern Contacts(first: 50 orderBy: { LastName: { order: ASC } }) { edges { node { Id Name { value } Email { value } } } totalCount // total contacts for this account } Opportunities( first: 50 orderBy: { CloseDate: { order: DESC } } where: { IsClosed: { eq: false } } ) { edges { node { Id Name { value } Amount { value displayValue } StageName { value } } } totalCount } } } } } } } // REST equivalent: GET /accounts/id + GET /contacts?AccountId=id // + GET /opportunities?AccountId=id = 3 calls // GraphQL: 1 call. Done.
query DashboardData { uiapi { query { // Object 1 — same query, side by side Account(first: 5 orderBy: { Name: { order: ASC } }) { edges { node { Id Name { value } Industry { value } } } } // Object 2 — completely separate object in the same request Contact(first: 5) { edges { node { Id FirstName { value } LastName { value } } } } // Object 3 — with its own filter Opportunity( first: 5 where: { StageName: { eq: "Closed Won" } } ) { edges { node { Id Name { value } Amount { displayValue } } } } } } } // 3 objects, 1 API call. REST equivalent: 3 separate calls.
// STEP 1: Declare variable in query ($ prefix, type with !) query FilterByStage($stage: String!, $minAmount: Decimal) { uiapi { query { Opportunity( where: { StageName: { eq: $stage } Amount: { gte: $minAmount } } ) { edges { node { Id Name { value } Amount { displayValue } } } } } } } // STEP 2: In your LWC JS — reactive getter get variables() { return { stage: this.selectedStage, // from UI picklist minAmount: this.minimumAmount // from UI input }; } // STEP 3: Wire it up @wire(graphql, { query: gql`...the query above...`, variables: '$variables' // reactive — reruns when getter returns new value }) handleResult(result) { ... }
From import to template — every step in the lifecycle of a Salesforce GraphQL component.
gql is a tagged template literal that marks your query string. graphql is the wire adapter. refreshGraphQL forces a re-fetch after mutations.import { gql, graphql, refreshGraphQL } from 'lightning/uiGraphQLApi';
'$variables' string in @wire tells the framework to watch this getter reactively.get variables() { return { accountId: this.recordId }; }
refreshGraphQL is called.@wire(graphql, { query: gql`...`, variables: '$variables' }) handleResult(result) { ... }
result into a class property. You'll need this reference later to call refreshGraphQL() after any create/update/delete. Without it, you can't force a refresh.this.wiredResult = result; // store FIRST, then process const { data, errors } = result;
.map() to extract and flatten into simple objects your template can use without any property chaining.this.accounts = data.uiapi.query.Account.edges.map(e => ({ Id: e.node.Id, Name: e.node.Name.value, Industry: e.node.Industry?.value ?? '—' }));
refreshGraphQL(this.wiredResult) to re-run the @wire query and sync the UI with the new data.await createRecord({ apiName: 'Account', fields: { Name: 'Acme' } }); await refreshGraphQL(this.wiredResult); // triggers step 4-5 again
{acct.Name}.<template for:each={accounts} for:item="acct"> <tr key={acct.Id}> <td>{acct.Name}</td> <!-- clean, not {acct.Name.value} --> <td>{acct.Industry}</td> </tr> </template>
Everything in one place. Bookmark this page.
| Concept | Syntax | Notes |
|---|---|---|
| Import | import { gql, graphql, refreshGraphQL } from 'lightning/uiGraphQLApi' | All 3 from the same module |
| Wire decorator | @wire(graphql, { query: gql`...` }) | Runs on load, re-runs on variable change |
| Named query | query MyQueryName { ... } | Always name queries — helps debugging |
| API gateway | uiapi { query { ... } } | Always the outer shell in Salesforce GraphQL |
| Limit records | Account(first: 10) | Like SOQL LIMIT — always set this |
| Sort | orderBy: { Name: { order: ASC } } | ASC or DESC. Multiple fields supported |
| Filter equals | where: { Industry: { eq: "Technology" } } | Like WHERE in SOQL |
| Filter contains | Name: { like: "%Acme%" } | % is wildcard — like SOQL LIKE |
| AND / OR | and: [...], or: [...] | Array of condition objects |
| List wrapper | edges { node { ... } } | Always required for list results |
| Id field | Id | Plain string — no { value } needed |
| Standard field | Name { value } | All fields need { value } in Salesforce GraphQL |
| Formatted value | Amount { value displayValue } | displayValue = "$1,500,000" (locale-formatted) |
| Variable declare | query MyQuery($id: ID!) { ... } | ! = required. Types: ID!, String, Decimal, Boolean |
| Variable use | where: { Id: { eq: $id } } | $ prefix when using the variable |
| Reactive variables | variables: '$variables' | Calls this.variables getter; reruns on change |
| Single record | edges[0].node | Detail pages — always guard with null check |
| Total count | totalCount | Alongside edges — total matching records |
| Pagination info | pageInfo { hasNextPage endCursor } | For Load More / infinite scroll |
| Null-safe JS | field?.value ?? 'fallback' | Always use for nullable fields |
| Force refresh | await refreshGraphQL(this.wiredResult) | Call after every create/update/delete |
| Error handling | const { data, errors } = result | Check both — can have partial errors |
| ⚡ GraphQL Mutations (lightning/graphql module) | ||
| Mutation import | import { gql, graphql, executeMutation } from 'lightning/graphql' | Different module from uiGraphQLApi |
| Refresh from wire | const { data, errors, refresh } = result; this.fn = refresh; | No need to import refreshGraphQL separately |
| Create mutation | AccountCreate(input: { Account: { Name: "…" } }) { Record { Id } } | Inside uiapi { } namespace |
| Update mutation | AccountUpdate(input: { Id: "…", Account: { Name: "…" } }) { Record { Id } } | Id + fields to change |
| Delete mutation | AccountDelete(input: { Id: "…" }) { Id } | Returns Id of deleted record |
| Execute mutation | const result = await executeMutation({ query: mutation }) | mutation is built with gql`` |
| String safety | JSON.stringify(rawValue) | Always escape before gql template injection |
| LDS auto-prune | (no code needed after delete) | Deletes auto-remove from wire — no refresh required |
executeMutation (lightning/graphql) for writes.this.wiredResult = result (uiGraphQLApi) or capture refresh from wire result (lightning/graphql).executeMutation only exists in lightning/graphql, not lightning/uiGraphQLApi.JSON.stringify() before injecting user values into gql template literals.