Krzysztof Pintscher
written by Krzysztof Pintscher
posted on January 15, 2024
In Salesforce world since 2014. In the Middle Ages I was an Aura Knight. These days I'm just a LWC cowboy.
Table of Content

LWC and Apex communication – the Ultimate Guide

How many times have you struggled with some issues when you were working on a connection between LWC (or Aura, if you’re old enough) and Apex? How many hours have you spent googling, reading documentation or stackexchange - searching answers for questions like „how to refresh the cache in lwc” or "when to use wire"?

Finally - all these answers are in one place, compressed in one straightforward article! As some of you might know it already - I love to keep my articles in kinda story style, so ... Enjoy!

Some basic facts

I'll assume that we all know the basics, but we'll create one simple example, as a fast reminder.
So - if you want to expose the function for LWC (or Aura, but I’ll stop mentioning Aura from now), you have to annotate function with @AuraEnabled.

    public with sharing class ShadyController {
        @AuraEnabled
        public static String whatsMyName(){
            return 'My name is Slim Shady!';
        }
    }

Only functions annotated with @AuraEnabled can be used in LWC components, to do so - you have to import it first:

    import whatsMyName from '@salesforce/apex/ShadyController.whatsMyName';

The whatsMyName variable becomes a Promise which is a call to our controller, so you should use it like a promise:

    myName;

    connectedCallback() {
        whatsMyName()
            .then(name => this.myName = name);
    }

Or if you're not in context LWR (as of API 59 - which is time of writing this post), you can use async / await syntax as well.

    const myName = await whatsMyName();

That way of calling Apex in LWC is known as calling imperatively. You can also call your Apex methods via @wire adapter, but we'll talk about it later.

@AuraEnabled - fully explained

Usage of wrapper classes

I'm guessing some of you - probably most of you - saw both situations when a wrapper class was used for response or as an input parameter from LWC - and in those wrapper classes, the properties were also annotated with @AuraEnabled. Let's create some examples and I'll explain everything:

    public with sharing class ShadyWrapper {
        @AuraEnabled
        public String name { get; set; }

        @AuraEnabled
        public String nickname;

        public Integer age;
    }

So as you see I have three properties:

  • one with @AuraEnabled and getter and setter
  • one only with @AuraEnabled annotation
  • one without it.

As a first example I'll return ShadyWrapper as a function response, so let's add a new function to our ShadyController:

    @AuraEnabled
    public static ShadyWrapper getShadyWrapper(){
        ShadyWrapper slimShady = new ShadyWrapper();
        slimShady.name = 'Eminem';
        slimShady.nickname = 'Slim Shady';
        slimShady.age = 51;

        return slimShady;
    }

Then in LWC:

    import { LightningElement } from 'lwc';
    import getShadyWrapper from '@salesforce/apex/ShadyController.getShadyWrapper';

    export default class SlimShady extends LightningElement {
        connectedCallback() {
            getShadyWrapper().then(result => console.log(result));
        }
    }

You probably know what the output will look like, but I'll paste it here anyway:

    {
        name: 'Eminem',
        nickname: 'Slim Shady'
    }

Since age wasn't annotaded with @AuraEnabled - it's not visible for LWC components.

Ok, let's check the other way around - LWC will send the wrapper as a parameter, and let's check the outcome. So we've got a new simple function:

    @AuraEnabled
    public static void sendShadyWrapper(ShadyWrapper shadyWrapper){
        System.debug(JSON.serialize(shadyWrapper));
    }

And in our LWC:

    import { LightningElement } from 'lwc';
    import sendShadyWrapper from '@salesforce/apex/ShadyController.sendShadyWrapper';

    export default class SlimShady extends LightningElement {
        connectedCallback() {
            sendShadyWrapper({
                shady: {
                    name: 'Eminem',
                    nickname: 'Slim Shady',
                    age: 51
                }
            });
        }
    }

Ok, let's assume that this function got called and I'm gonna show you a bit of System Log here:

    VARIABLE_SCOPE_BEGIN|[18]|shadyWrapper|ShadyWrapper|true|false
    VARIABLE_ASSIGNMENT|[18]|shadyWrapper|{"name":"Eminem"}|0x7e035610
    USER_DEBUG|[19]|DEBUG|{"nickname":null,"name":"Eminem","age":null}

As you can see - only the name property got populated. Do you see the difference already?

Even if property is decorated with @AuraEnabled, but doesn't have getter and setter it can't be populated via call like this. Property without @AuraEnabled is not accessible in both cases.

I know some folks are sending JSON strings from LWC and parsing them in Apex, but - personally - I don't think it's a best solution, for me it's a bit workaround, but let's stay with same wrapper and call it that way.

    @AuraEnabled
    public static void sendShadyJSONWrapper(String shadyString){
        ShadyWrapper slimShady = (ShadyWrapper) JSON.deserialize(shadyString, ShadyWrapper.class);
        System.debug(JSON.serialize(slimShady));
    }

And LWC:

    import { LightningElement } from 'lwc';
    import sendShadyJSONWrapper from '@salesforce/apex/ShadyController.sendShadyJSONWrapper';

    export default class SlimShady extends LightningElement {
        connectedCallback() {
            sendShadyJSONWrapper({
                shadyString: JSON.stringify({
                    name: 'Eminem',
                    nickname: 'Slim Shady',
                    age: 51
                })
            });
        }
    }

Do you know whats gonna happen in that case?
Yes, you're right - we'll have our ShadyWrapper filled with all of the informations passed from LWC - both without getter and setter and without @AuraEnabled.

    METHOD_ENTRY|[24]||System.JSON.deserialize(String, System.Type)
    VARIABLE_SCOPE_BEGIN|[24]|slimShady|ShadyWrapper|true|false
    VARIABLE_ASSIGNMENT|[24]|slimShady|{"age":51,"name":"Eminem","nickname":"Slim Shady"}|0x26a2a9a6
    USER_DEBUG|[25]|DEBUG|{"nickname":"Slim Shady","name":"Eminem","age":51}

Bonus - Apex Inner Classes

There's a note, somewhere deep inside the Salesforce documentation, which says:

You can’t use an Apex inner class as a parameter or return value for
an Apex method that's called by an Aura component.

Let's check it!
First - I'm will check if inner class is visible at all.
So, our wrapper looks like this:

    public with sharing class ShadyWrapper {
        @AuraEnabled
        public String name { get; set; }

        @AuraEnabled
        public String nickname;

        public Integer age;

        @AuraEnabled
        public InnerShady shady { get; set; }

        public class InnerShady {
            @AuraEnabled
            public String innerName { get; set; }
        }
    }

And our LWC looks like this:

    import { LightningElement } from 'lwc';
    import sendShadyWrapper from '@salesforce/apex/ShadyController.sendShadyWrapper';
    import sendShadyJSONWrapper from '@salesforce/apex/ShadyController.sendShadyJSONWrapper';

    export default class SlimShady extends LightningElement {
        connectedCallback() {
            sendShadyWrapper({
                shadyWrapper: {
                    name: 'Eminem',
                    nickname: 'Slim Shady',
                    age: 51,
                    shady: {
                        innerName: 'Shady Shady'
                    }
                }
            });

            sendShadyJSONWrapper({
                shadyString: JSON.stringify({
                    name: 'Eminem',
                    nickname: 'Slim Shady',
                    age: 51,
                    shady: {
                        innerName: 'Shady Shady'
                    }
                })
            });
        }
    }

Results? Our sendShadyWrapper log looks like this:

    VARIABLE_ASSIGNMENT|[18]|shadyWrapper|{"name":"Eminem","shady":"0x41a4c043"}|0x6977f061
    USER_DEBUG|[19]|DEBUG|{"shady":{"innerName":"Shady Shady"},"nickname":null,"name":"Eminem","age":null}

And sendShadyJSONWrapper log looks like this:

    VARIABLE_ASSIGNMENT|[24]|slimShady|{"age":51,"name":"Eminem","nickname":"Slim Shady","shady":"0x34af1d1a"}|0x62bd2328
    USER_DEBUG|[25]|DEBUG|{"shady":{"innerName":"Shady Shady"},"nickname":"Slim Shady","name":"Eminem","age":51}

So as you can see - that works fine.

Let's try exactly whats the note says - inner class as parameter alone.

    @AuraEnabled
    public static ShadyWrapper.InnerShady getInnerShady(){
        ShadyWrapper.InnerShady innerShady = new ShadyWrapper.InnerShady();
        innerShady.innerName = 'Shady Shady';

        return innerShady;
    }

    @AuraEnabled
    public static void setInnerShady(ShadyWrapper.InnerShady innerShady){
        System.debug(JSON.serialize(innerShady));
    }

And our LWC:

    import { LightningElement } from 'lwc';
    import getInnerShady from '@salesforce/apex/ShadyController.getInnerShady';
    import setInnerShady from '@salesforce/apex/ShadyController.setInnerShady';

    export default class SlimShady extends LightningElement {
        connectedCallback() {
            getInnerShady().then(result => console.log(result));
            setInnerShady({ innerShady: { innerName: 'Shady Shady!' } });
        }
    }

Result from getInnerShady function:

    { innerName: "Shady Shady" }

Somehow the our InnerShady got back to LWC.

How about setting one? Let's see debug log:

    VARIABLE_ASSIGNMENT|[37]|innerShady|{"innerName":"Shady Shady!"}|0x22cd3591
    USER_DEBUG|[38]|DEBUG|{"innerName":"Shady Shady!"}

Looks like InnerShady got parsed as parameter as well... so did I made some mistake, or documentation is outdated?

Yet another example - which works fine:

    @AuraEnabled
    public static ShadyResponse getInnerShady(){
        ShadyResponse innerShady = new ShadyResponse();
        innerShady.shadyTrack = 'Shady Shady';

        return innerShady;
    }

    public class ShadyResponse {
        @AuraEnabled
        public string shadyTrack;
    }

And call it in LWC:

    connectedCallback() {
        getInnerShady().then(result => console.log(result));
    }

In the console we'll see:

{ "shadyTrack": "Shady Shady" }

ShadyResponse was a inner class of our main class ShadyController.

Troublesome parameters

When it comes to sending parameters - you can send almost everything you want from LWC to Apex. ALMOST. We've actually covered the wrapper (custom class) type, but you can also send: Boolean, Integer, Long, Decimal, Double, String, Object, Blob, Date, DateTime, Time, List and Map. What you can't send then...? Set! What's gonna happen, when you'll try to send Set as parameter and how to deal with it? Let's check together!

Set Issues

Let's create new method in our ShadyController class:

    @AuraEnabled
    public static void sendShadySet(Set<String> shadySet){
        System.debug(JSON.serialize(shadySet));
    }

Deploy.... and ups!

    ERRORS
    ────────────────────────────────────────────────────────────────
    AuraEnabled methods do not support parameter type of Set<String>

How we can solve it then? Let's try create a wrapper!

    public with sharing class ShadySetWrapper {
        @AuraEnabled
        public Set<String> shadySet { get; set; }
    }

Does it compiles? Yes - only @AuraEnabled methods doesn't support Set. Let's continue.
Updated method looks like this:

    @AuraEnabled
    public static void sendShadySet(ShadySetWrapper shadySetWrapper){
        System.debug(JSON.serialize(shadySetWrapper));
    }

And it also compiles correctly.
Ok, let's try to send a Set of Strings from LWC.

    import { LightningElement } from 'lwc';
    import sendShadySet from '@salesforce/apex/ShadyController.sendShadySet';

    export default class SlimShady extends LightningElement {
        connectedCallback() {
            sendShadySet({ shadySetWrapper: { shadySet: ['slim', 'shady'] }});
        }
    }

Our debug log looks like this:
... yeah, it doesn't look at all, instead of correct debug log, we have a message in browser dev console: "Unsupported action parameter of type 'Set'".

Ok mister, cut to the chase - how to handle that case? Well - you have two options. One (which I don't like) - send it stringified and parse it on Apex side.
Our LWC call looks like this:

    sendShadySet({ shadySet: JSON.stringify(['slim', 'shady']) });

And Apex method:

    @AuraEnabled
    public static void sendShadySet(String shadySet){
        Set<String> slimShadySet = (Set<String>) JSON.deserialize(shadySet, Set<String>.class);
        System.debug(JSON.serialize(slimShadySet));
    }

Does it works? Yes, look at the debug log:

    USER_DEBUG|[44]|DEBUG|["slim","shady"]

Let's add a bit of trolling to that code, let's see how Apex will handle that:

    sendShadySet({ shadySet: JSON.stringify(['slim', 'shady', 'slim', 'eminem', 'shady']) });

See what I did there? Added slim and shady twice.

And, well, it's correct:

    USER_DEBUG|[44]|DEBUG|["slim","shady","eminem"]

But I would say the easiest solution would be to send parameter as List and cast it into Set.

    sendShadySet({ shadySet: ['slim', 'shady', 'slim', 'eminem', 'shady'] });

Apex:

    @AuraEnabled
    public static void sendShadySet(List<String> shadySet){
        Set<String> slimShadySet = new Set<String>(shadySet);
        System.debug(JSON.serialize(slimShadySet));
    }

And, of course, no surprises here:

    USER_DEBUG|[44]|DEBUG|["slim","shady","eminem"]

Boolean parameters

Ok, enough about Set. Let's cover one more troublesome case.

As JavaScript developer you probably might have one habit, which is skipping parameter when you want to pass it as falsy value:

    function functionWithFalsyParameter(willBeFalsy) {
        // do something
        if (willBeFalsy) {
            return;
        }
        // continue
    }

In this case if we will call this function as functionWithFalsyParameter(), the willBeFalsy indeed will be falsy (undefined). But what if we'll try to do same with Apex?

Before we'll dive in to examples - let's make it clear - you should have full control of what parameters you're sending and you should avoid situations described below. But we know that theory is one thing and real life is the other.

Our Apex method:

    @AuraEnabled
    public static String isRealSlimShady(Boolean isRealSlimShady){
        try {
            if (isRealSlimShady) {
                return 'Please stand-up!';
            }
            return 'Impostor alert!';
        } catch (Exception e) {
            throw new AuraHandledException(e.getMessage());
        }
    }

And let's try to call it without passing our parameter:

    import { LightningElement } from 'lwc';
    import isRealSlimShady from '@salesforce/apex/ShadyController.isRealSlimShady';

    export default class SlimShady extends LightningElement {
        connectedCallback() {
            isRealSlimShady()
                .then(result => console.log(result))
                .catch(error => console.error(error));
        }
    }

Does it work? Unfortunately no - and we'll get message in console saying "Attempt to de-reference a null object". How to solve it then?

Overloading? It's not working for @AuraEnabled methods, since API 55 it's throwing error while deploying.
We'll have to add one line - right after opening try clause:

    try {
        isRealSlimShady = isRealSlimShady != null ? isRealSlimShady : false;
        if (isRealSlimShady) {
        ...

or it depends on your case, but here this will also work fine:

    try {
        if (isRealSlimShady != null && isRealSlimShady) {
        ...

You can also try some other approach (I won't share it here tho) - send this parameter as String, check with String.isNotBlank and then read Boolean.valueOf.

But like I've mentioned before - you should try to avoid situation like this - even if you find it tempting - please make sure you're sending the right payload to Apex functions and send all parameters correctly.

@wire service

With the Lightning Web Components Salesforce introduced the wire service with bunch of adapters for us to use. Before going any further and deeper, I stronlgy recomemnd to check the official documentation (even tho the documentation might be boring or some examples might be a bit outdated), because not all of the developers knows for example that the data passed from wire is read-only.
I personally do like using wire service, but it's not suitable for every case - or in other words - it's easier and more elegant to use imperative call instead of wired one, for example when you have some chained or dependent calls which you want to use as await calls.
"When to use wire then?" you ask. Well... that's really up to you, but of course there are situations when you have to use it - like if you want to use some of the uiRecordApi adapters. One of my favourites features is possibility of using the reactive properties - it's just great for creating data tables with pagination and search functionality.
You can also use your own Apex functions with wire service, but keep in mind that in order to use them you'll have to decorate it with (cacheable=true), next to @AuraEnabled annotation.

But enough of theory and storytelling, let's get down to the business (I'll keep it short tho).

@wire in practice

Let's create some shady examples. You've seen the documentation from the link above, right? Have you noticed that the wire result is described as propertyOrFunction? But what does it means, exactly? From what I've seen - it's not so obvious for all of the developers out there.

I've created Apex function called wiredSlimShady, which I'll call in two different ways by using wire service:

    @wire(wiredSlimShady, { track: '$shadySong' })
    slimShadyTrack;
    @wire(wiredSlimShady, { track: '$shadySong' })
    slimShadyTrack({ data, error }) {
        if (data) {
            this.parseShadyLyrics(data);
        } else if (error) {
            console.error(error);
        }
    }

Do you see the difference? propertyOrFunction - first example is property, second is function. Our wiredSlimShady will be called on every change of shadySong property - to make sure we're on same page: we'll have an input, which will update this.shadySong upon every keypress event, so full code would be something like:

    @wire(wiredSlimShady, { track: '$shadySong' })
    slimShadySong;

    handleInputChange(event) {
        this.shadySong = event.target.value;
    }

As you can see - when you assign wire result as property, you can get the results of the call via getters:

    get shadyErrors() {
        return this.slimShadySong?.error;
    }

    get shadyData() {
        return this.slimShadySong?.data;
    }

The downside of that way is fact that you can't (or shouldn't) manipulate the data.
Usage of function gives you more flexibility:

    @wire(wiredSlimShady, { track: '$shadySong' })
    slimShadySong({ data, error }) {
        if (data) {
            this.parseShadyLyrics(data);
        } else if (error) {
            this.errors = error;
        }
    }

    parseShadyLyrics(lyrics) {
        this.songLyrics = lyrics.map(value => ...);
    }

IMPORTANT: first call is always returning both data and error as undefined.

You may ask - which one is faster, imperative or wire?
Well... I did some experiments, measured time and it's almost the same - imperative calls were just SLIGHTLY faster, like 10ms - no-one gonna notice that difference.

Cache

Ok, last topic - let's talk about cache!
I've mentioned (cacheable=true) before - and for some of you it might be bit painfull. I'll share some secrets how to use that cache and how to refresh it.

The best source of that knowledge is, again, documentation, but I'll tell you this story in my own way - and keep it short (again).

Cache in wire service

When it comes for refreshing cache for wired functions is pretty easy - there are two steps and two cases.

You have to import refreshApex and call it in right way - depends on whether you've assigned wire results to property or function.

The property case is easy:

import { refreshApex } from '@salesforce/apex';
...
    @wire(wiredSlimShady, { track: '$shadySong' })
    slimShadySong;

    handleRefreshShady() {
        refreshApex(this.slimShadySong);
    }

The function case is bit more complicated. You have to store the function response and in order to refresh cache - call the refreshApex on property which we've used to store that response. We'll have to modify our code a little bit:

    slimShadyTrackResult;

    @wire(wiredSlimShady, { track: '$shadySong' })
    slimShadySong(result) {
        this.slimShadySongResult = result;
        const { data, error } = result;
        if (data) {
            this.parseShadyLyrics(data);
        } else if (error) {
            console.error(error);
        }
    }

    handleRefreshShady() {
        refreshApex(this.slimShadySongResult);
    }

As you can see - we've stored result as separated property (and used later in refresh function) and extracted data and error from it.

Last case for wire is calling notifyRecordUpdateAvailable - but it's well described here. You can force to refresh the single record cache retrieved via getRecord uiApi wire adapter by using this function when it was changed - for example by custom Apex function. notifyRecordUpdateAvailable is replacement for deprecated getRecordNotifyChange function.

Cache in imperatively called functions

So how about data which was loaded imperatively? This part is bit tricky.
There's no official way that I would be aware of, but I have my own workaround: I'm adding _timestamp parameter, which is not consumed by Apex function, but the fact that is being sent with new value is enough to refresh cache.
Let's take a look:

    params = {};
    _timestamp = new Date().getTime();

    connectedCallback() {
        this.loadSlimShady();
    }

    loadSlimShady() {
        getSlimShady({...this.params,  _timestamp: this._timestamp })
            .then(result => {
                this.data = result;
            })
            .catch(error => {
                console.log(error);
            });
    }

    refreshSlimShady() {
        this._timestamp = new Date().getTime();
        this.loadSlimShady();
    }

So as you can see _timestamp got new date value in refresh function - it actually can be whatever value you want, but from my perspective - timestamp makes sense.

If you got this far - I would like to thank you for participation in this journey and I hope you've enjoyed this article and learned something new. Leave thumbs up and subscribe to Beyond The Cloud for more! Did I miss something? Would you like to discuss about something what I wrote? Comments section is for you!