19 KiB
LWC Flow Integration Guide
This guide covers building Lightning Web Components for use in Salesforce Flow Screens.
Overview
┌─────────────────────────────────────────────────────────────────────┐
│ FLOW ↔ LWC COMMUNICATION │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ FLOW │ │ LWC │ │
│ │ Variables │ │ Component │ │
│ └────────┬────────┘ └────────┬────────┘ │
│ │ │ │
│ │ @api (inputOnly) │ │
│ ├─────────────────────────────────────▶│ │
│ │ │ │
│ │ FlowAttributeChangeEvent (outputOnly)│ │
│ │◀─────────────────────────────────────┤ │
│ │ │ │
│ │ FlowNavigationFinishEvent │ │
│ │◀─────────────────────────────────────┤ │
│ │ (NEXT, BACK, FINISH, PAUSE) │ │
│ │
└─────────────────────────────────────────────────────────────────────┘
Quick Reference
| Direction | Mechanism | Use Case |
|---|---|---|
| Flow → LWC | @api with role="inputOnly" |
Pass context data to component |
| LWC → Flow | FlowAttributeChangeEvent |
Return user selections/data |
| LWC → Navigation | FlowNavigationFinishEvent |
Trigger Next/Back/Finish |
Meta.xml Configuration
Target Configuration
<targets>
<target>lightning__FlowScreen</target>
</targets>
Property Roles
<targetConfig targets="lightning__FlowScreen">
<!-- INPUT: Flow → Component -->
<property
name="recordId"
type="String"
label="Record ID"
description="ID from Flow"
role="inputOnly"/>
<!-- OUTPUT: Component → Flow -->
<property
name="selectedValue"
type="String"
label="Selected Value"
description="User's selection"
role="outputOnly"/>
</targetConfig>
Supported Property Types
| Type | Description | Example |
|---|---|---|
String |
Text values | Record IDs, names |
Boolean |
True/false | Flags, completion status |
Integer |
Whole numbers | Counts, indexes |
Date |
Date values | Due dates |
DateTime |
Date and time | Timestamps |
@salesforce/schema/* |
SObject references | Record types |
FlowAttributeChangeEvent
This is the critical mechanism for sending data back to Flow.
Import
import { FlowAttributeChangeEvent } from 'lightning/flowSupport';
Usage
// Dispatch event to update Flow variable
// First param: @api property name (must match meta.xml exactly)
// Second param: new value
this.dispatchEvent(new FlowAttributeChangeEvent(
'selectedRecordId', // Property name
this.recordId // Value
));
Example: Selection Handler
@api selectedRecordId;
@api selectedRecordName;
handleSelect(event) {
const id = event.target.dataset.id;
const name = event.target.dataset.name;
// Update local properties
this.selectedRecordId = id;
this.selectedRecordName = name;
// Notify Flow of BOTH changes
this.dispatchEvent(new FlowAttributeChangeEvent('selectedRecordId', id));
this.dispatchEvent(new FlowAttributeChangeEvent('selectedRecordName', name));
}
Common Mistake
// ❌ WRONG: Only updating local property
this.selectedRecordId = id;
// ✅ CORRECT: Update AND dispatch event
this.selectedRecordId = id;
this.dispatchEvent(new FlowAttributeChangeEvent('selectedRecordId', id));
FlowNavigationFinishEvent
Programmatically trigger Flow navigation from your component.
Import
import { FlowNavigationFinishEvent } from 'lightning/flowSupport';
Navigation Actions
| Action | Description | When Available |
|---|---|---|
'NEXT' |
Go to next screen | Mid-flow screens |
'BACK' |
Go to previous screen | After first screen |
'FINISH' |
Complete the flow | Final screens |
'PAUSE' |
Pause flow (if enabled) | Pausable flows |
Usage
// Navigate to next screen
this.dispatchEvent(new FlowNavigationFinishEvent('NEXT'));
// Navigate back
this.dispatchEvent(new FlowNavigationFinishEvent('BACK'));
// Finish the flow
this.dispatchEvent(new FlowNavigationFinishEvent('FINISH'));
Check Available Actions
Flow provides available actions via a special @api property:
// Automatically populated by Flow runtime
@api availableActions = [];
get canGoNext() {
return this.availableActions.includes('NEXT');
}
get canGoBack() {
return this.availableActions.includes('BACK');
}
handleNext() {
if (this.canGoNext) {
this.dispatchEvent(new FlowNavigationFinishEvent('NEXT'));
}
}
Conditional Navigation Buttons
<template lwc:if={canGoBack}>
<lightning-button label="Back" onclick={handleBack}></lightning-button>
</template>
<template lwc:if={canGoNext}>
<lightning-button label="Next" variant="brand" onclick={handleNext}></lightning-button>
</template>
Validation Before Navigation
Always validate before allowing navigation:
handleNext() {
// Validate
if (!this.selectedRecordId) {
this.errorMessage = 'Please select a record.';
this.dispatchEvent(new FlowAttributeChangeEvent('errorMessage', this.errorMessage));
return;
}
// Clear error
this.errorMessage = null;
this.dispatchEvent(new FlowAttributeChangeEvent('errorMessage', null));
// Mark complete and navigate
this.isComplete = true;
this.dispatchEvent(new FlowAttributeChangeEvent('isComplete', true));
this.dispatchEvent(new FlowNavigationFinishEvent('NEXT'));
}
Apex Integration in Flow Context
Wire Service
import { wire } from 'lwc';
import getRecords from '@salesforce/apex/MyController.getRecords';
@api recordId; // From Flow
@wire(getRecords, { parentId: '$recordId' })
wiredRecords({ error, data }) {
if (data) {
this.records = data;
} else if (error) {
this.error = this.reduceErrors(error);
}
}
Imperative Calls
import processRecord from '@salesforce/apex/MyController.processRecord';
async handleProcess() {
this.isLoading = true;
try {
const result = await processRecord({
recordId: this.selectedRecordId
});
if (result.success) {
this.dispatchEvent(new FlowNavigationFinishEvent('NEXT'));
} else {
this.errorMessage = result.message;
this.dispatchEvent(new FlowAttributeChangeEvent('errorMessage', result.message));
}
} catch (error) {
this.errorMessage = this.reduceErrors(error);
} finally {
this.isLoading = false;
}
}
Flow Context Variables
Flow provides special context via reserved variable names:
<!-- In Flow Builder, map these to your component -->
<property name="recordId" value="{!$Record.Id}"/>
<property name="objectApiName" value="{!$Record.Object}"/>
Testing LWC in Flows
Jest Testing
import { createElement } from 'lwc';
import FlowScreenComponent from 'c/flowScreenComponent';
import { FlowAttributeChangeEvent, FlowNavigationFinishEvent } from 'lightning/flowSupport';
// Mock the flow support module
jest.mock('lightning/flowSupport', () => ({
FlowAttributeChangeEvent: jest.fn(),
FlowNavigationFinishEvent: jest.fn()
}), { virtual: true });
describe('c-flow-screen-component', () => {
afterEach(() => {
while (document.body.firstChild) {
document.body.removeChild(document.body.firstChild);
}
jest.clearAllMocks();
});
it('dispatches FlowAttributeChangeEvent on selection', async () => {
const element = createElement('c-flow-screen-component', {
is: FlowScreenComponent
});
element.availableActions = ['NEXT', 'BACK'];
document.body.appendChild(element);
// Simulate selection
const tile = element.shadowRoot.querySelector('.record-tile');
tile.click();
// Verify event dispatched
expect(FlowAttributeChangeEvent).toHaveBeenCalled();
});
it('dispatches FlowNavigationFinishEvent on next', async () => {
const element = createElement('c-flow-screen-component', {
is: FlowScreenComponent
});
element.availableActions = ['NEXT'];
element.selectedRecordId = '001xx000000001';
document.body.appendChild(element);
// Click next button
const nextButton = element.shadowRoot.querySelector('lightning-button[label="Next"]');
nextButton.click();
// Verify navigation event
expect(FlowNavigationFinishEvent).toHaveBeenCalledWith('NEXT');
});
});
Manual Testing
- Create a Screen Flow in Setup
- Add your LWC component to a screen
- Map input/output variables
- Test in Flow debug mode
- Verify variable values in debug panel
Common Patterns
Selection with Confirmation
handleSelect(event) {
this.selectedId = event.target.dataset.id;
// Don't navigate yet - wait for explicit confirmation
}
handleConfirm() {
if (!this.selectedId) {
this.showError('Please select an item');
return;
}
this.dispatchEvent(new FlowAttributeChangeEvent('selectedId', this.selectedId));
this.dispatchEvent(new FlowNavigationFinishEvent('NEXT'));
}
Multi-Select to Collection
@api selectedIds = [];
handleToggle(event) {
const id = event.target.dataset.id;
if (this.selectedIds.includes(id)) {
this.selectedIds = this.selectedIds.filter(i => i !== id);
} else {
this.selectedIds = [...this.selectedIds, id];
}
// Send collection back to Flow
this.dispatchEvent(new FlowAttributeChangeEvent('selectedIds', this.selectedIds));
}
Conditional Screen (Skip Logic)
connectedCallback() {
// Auto-skip if condition met
if (this.shouldSkip) {
this.dispatchEvent(new FlowNavigationFinishEvent('NEXT'));
}
}
Troubleshooting
| Issue | Cause | Solution |
|---|---|---|
| Output not updating in Flow | Missing FlowAttributeChangeEvent | Always dispatch event after updating @api property |
| Navigation buttons not showing | Wrong availableActions | Check Flow provides availableActions correctly |
| Component not appearing | Missing isExposed: true |
Set in meta.xml |
| Properties not mapping | Role mismatch | Use inputOnly for inputs, outputOnly for outputs |
| Values reset on navigation | Local state not persisted | Use @api properties for all persisted data |
Template
Use the template at assets/flow-screen-component/ as a starting point.
Passing sObjects and Wrapper Classes to Flow
Overview
Flow can receive complex Apex types through apex:// type bindings. This enables:
- Passing sObjects directly (not just IDs)
- Passing wrapper/DTO classes with multiple fields
- Two-way data binding for record editing
apex:// Type Syntax
In your meta.xml, reference Apex classes using the apex:// prefix:
<targetConfig targets="lightning__FlowScreen">
<!-- Pass entire Account record -->
<property
name="accountRecord"
type="apex://Account"
label="Account Record"
role="inputOnly"/>
<!-- Pass custom wrapper class -->
<property
name="orderSummary"
type="apex://OrderController.OrderSummaryWrapper"
label="Order Summary"
role="inputOnly"/>
<!-- Output a modified record -->
<property
name="updatedAccount"
type="apex://Account"
label="Updated Account"
role="outputOnly"/>
</targetConfig>
Wrapper Class Requirements
Apex wrapper classes must be public and have public properties:
public class OrderController {
// Wrapper class for Flow
public class OrderSummaryWrapper {
@AuraEnabled public String orderId;
@AuraEnabled public String orderName;
@AuraEnabled public Decimal totalAmount;
@AuraEnabled public List<LineItemWrapper> lineItems;
@AuraEnabled public Account customer;
}
public class LineItemWrapper {
@AuraEnabled public String productName;
@AuraEnabled public Integer quantity;
@AuraEnabled public Decimal unitPrice;
}
// Invocable method to create the wrapper
@InvocableMethod(label='Get Order Summary')
public static List<OrderSummaryWrapper> getOrderSummary(List<Id> orderIds) {
// Query and build wrapper...
}
}
Using sObjects in LWC
import { api, LightningElement } from 'lwc';
import { FlowAttributeChangeEvent } from 'lightning/flowSupport';
export default class AccountEditor extends LightningElement {
// Receive sObject from Flow
@api accountRecord;
// Track local modifications
_modifiedAccount;
connectedCallback() {
// Create a working copy
this._modifiedAccount = { ...this.accountRecord };
}
handleNameChange(event) {
this._modifiedAccount.Name = event.target.value;
}
handleSave() {
// Send modified record back to Flow
this.dispatchEvent(
new FlowAttributeChangeEvent('updatedAccount', this._modifiedAccount)
);
}
}
Using Wrapper Classes in LWC
import { api, LightningElement } from 'lwc';
export default class OrderSummaryViewer extends LightningElement {
@api orderSummary; // apex://OrderController.OrderSummaryWrapper
get formattedTotal() {
return this.orderSummary?.totalAmount?.toLocaleString('en-US', {
style: 'currency',
currency: 'USD'
});
}
get lineItems() {
return this.orderSummary?.lineItems || [];
}
get customerName() {
// Access nested sObject
return this.orderSummary?.customer?.Name || 'Unknown';
}
}
Flow Configuration for apex:// Types
-
Create an Invocable Action that returns your wrapper:
@InvocableMethod public static List<MyWrapper> getData(List<String> inputs) { ... } -
In Flow Builder, call the Invocable Action before the screen
-
Store result in an Apex-Defined Variable
-
Pass to LWC via the screen component input mapping
Common Patterns
Pattern 1: Record Edit with Validation
// LWC that receives, edits, and returns an sObject
@api inputRecord; // apex://Contact (inputOnly)
@api outputRecord; // apex://Contact (outputOnly)
@api isValid = false; // Boolean (outputOnly)
handleFieldChange(event) {
const field = event.target.dataset.field;
this.workingRecord[field] = event.target.value;
// Validate and update outputs
this.isValid = this.validateRecord();
this.dispatchEvent(new FlowAttributeChangeEvent('outputRecord', this.workingRecord));
this.dispatchEvent(new FlowAttributeChangeEvent('isValid', this.isValid));
}
Pattern 2: Multi-Record Selection
// Select from a list, output selected items
@api availableRecords; // apex://Account[] (inputOnly)
@api selectedRecords = []; // apex://Account[] (outputOnly)
handleSelect(event) {
const id = event.target.dataset.id;
const record = this.availableRecords.find(r => r.Id === id);
if (record && !this.selectedRecords.find(r => r.Id === id)) {
this.selectedRecords = [...this.selectedRecords, record];
this.dispatchEvent(
new FlowAttributeChangeEvent('selectedRecords', this.selectedRecords)
);
}
}
Pattern 3: Master-Detail Editing
// Edit parent with nested child records
@api orderWrapper; // apex://OrderController.OrderWithLines (inputOnly)
@api updatedOrder; // apex://OrderController.OrderWithLines (outputOnly)
handleLineItemChange(event) {
const index = event.target.dataset.index;
const field = event.target.dataset.field;
const value = event.target.value;
// Update nested structure
const updated = JSON.parse(JSON.stringify(this.orderWrapper));
updated.lineItems[index][field] = value;
// Recalculate totals
updated.totalAmount = updated.lineItems.reduce(
(sum, item) => sum + (item.quantity * item.unitPrice), 0
);
this.updatedOrder = updated;
this.dispatchEvent(new FlowAttributeChangeEvent('updatedOrder', updated));
}
Limitations
| Limitation | Workaround |
|---|---|
No @JsonAccess support |
Ensure wrapper classes don't require JSON annotation |
| 1000 record limit per collection | Paginate or filter in Apex before passing |
| No generic types | Create specific wrapper classes |
| Complex nesting depth | Flatten deep hierarchies |
Debugging Tips
-
Console log received data to verify structure:
connectedCallback() { console.log('Received from Flow:', JSON.stringify(this.inputWrapper)); } -
Check Apex class visibility - inner classes need
publicmodifier -
Verify @AuraEnabled on all properties you need to access
Cross-Skill Integration
| Integration | See Also |
|---|---|
| Flow → Apex → LWC | triangle-pattern.md |
| Apex @AuraEnabled | sf-apex/references/best-practices.md |
| Flow Templates | sf-flow/assets/ |
| Async Notifications | async-notification-patterns.md |
| State Management | state-management.md |