afv-library/skills/experience-lwc-generate/references/flow-integration-guide.md

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

  1. Create a Screen Flow in Setup
  2. Add your LWC component to a screen
  3. Map input/output variables
  4. Test in Flow debug mode
  5. 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

  1. Create an Invocable Action that returns your wrapper:

    @InvocableMethod
    public static List<MyWrapper> getData(List<String> inputs) { ... }
    
  2. In Flow Builder, call the Invocable Action before the screen

  3. Store result in an Apex-Defined Variable

  4. 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

  1. Console log received data to verify structure:

    connectedCallback() {
        console.log('Received from Flow:', JSON.stringify(this.inputWrapper));
    }
    
  2. Check Apex class visibility - inner classes need public modifier

  3. 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