retrieve fields in lwc : be smart

We're all gonna agree on this, using LWC over Aura is way more enjoyable as it brings a much more robust and up to date API, using the Web Component native web technology, to work with.

What's troublesome though, in my opinion, is the use of the functions getFieldValue() and getRecord().

Here's what's the official doc is saying :

 1import { LightningElement, api, wire } from 'lwc';
 2import { getRecord, getFieldValue } from 'lightning/uiRecordApi';
 3
 4import REVENUE_FIELD from '@salesforce/schema/Account.AnnualRevenue';
 5import CREATED_FIELD from '@salesforce/schema/Account.CreatedDate';
 6import EXP_FIELD from '@salesforce/schema/Account.SLAExpirationDate__c';
 7
 8const fields = [REVENUE_FIELD, CREATED_FIELD, EXP_FIELD];
 9
10export default class WireGetValue extends LightningElement {
11    @api recordId;
12
13    @wire(getRecord, { recordId: '$recordId', fields })
14    account;
15
16    get revenue() {
17        return getFieldValue(this.account.data, REVENUE_FIELD);
18    }
19
20    get created() {
21        return getFieldValue(this.account.data, CREATED_FIELD);
22    }
23
24    get expiration() {
25        return getFieldValue(this.account.data, EXP_FIELD);
26    }
27}
28
29// .html
30<div>
31  Field 1: {revenue}
32  Field 2: {created}
33  Field 3: {expiration}
34</div>

Following this example leads to a lack of reusability of the code and a lack of context (as it's not explicit what does the getters revenue,created and expiration of this example represent).

So, let's bring more flexibility to this code by using native javascript capabilities.

Clear context = Clear code

First I would advise to run the sfdx command SFDX: Refresh SObject Definitions from the VSCODE command palette.

It will allow you to have the typescript LSP running and giving you hint on what to type, especially for the import statement.

1// stop doing this as you lose information about the context
2import REVENUE_FIELD from '@salesforce/schema/Account.AnnualRevenue';
3
4// do this instead, the name and the import location are suggested by the typescript LSP
5import AnnualRevenue from '@salesforce/schema/Account.AnnualRevenue';
6

The part about the fields array constant doesn't need to be changed, it'll be really useful.

1const fields = [AnnualRevenue, CreatedDate, SLAExpirationDate__c];

The big change is moving from a model where we need to create as many getters as we have fields imported to a model where we would have only one getter called by the SObject API name it belongs to that will return an object with each key being the field imported.

 1@wire(getRecord, { recordId: '$recordId', fields })
 2_account
 3
 4get Account() {
 5    return Object.fromEntries(
 6        new Map(fields.map(field =>
 7            [field.fieldApiName, getFieldValue(this._account.data, field)]
 8        ))
 9    )
10}
11//equivalent of :
12get Account() {
13  return {
14    'AnnualRevenue': getFieldValue(this._account.data, AnnualRevenue),
15    'CreatedDate': getFieldValue(this._account.data, CreatedDate),
16    'SLAExpirationDate__c': getFieldValue(this._account.data, SLAExpirationDate__c),
17  }
18}

Here we use the previously created fields array to map it into a Map that takes as a key the fieldApiName of the current iteration and as a value the results of the getFieldValue() function. And we using Object.fromEntries to simply convert the created Map into an object literal.

Now, when referencing these values in the html or js, it'll be more readable to have somewhere in your code Account.AnnualRevenue compared to just revenue as it was presented on the official doc as now you have a direct clear view of what the data represents.

Composition and reusability

Imagine you have 50+ LWC in your project. Each needs to access datas on an object. Unless you're a fool you won't copy paste this code 50 times.

So let's make it flexible.

Start by creating a new LWC that will serve as a mixin to compose our needs. I called mine useRecordFields.

 1import { wire } from 'lwc';
 2import { getRecord, getFieldValue } from 'lightning/uiRecordApi';
 3
 4export function useAccountFields(constructorLike, fields) {
 5  return class extends constructorLike {
 6    @wire(getRecord, {recordId: '$recordId', fields: fields })
 7    _account;
 8
 9    get Account() {
10      return Object.fromEntries(
11        new Map(fields.map(field =>
12          [field.fieldApiName, getFieldValue(this._account.data, field)]
13        ))
14      );
15    }
16  }
17}

Then for each SObject that I want to import fields from, I would export a new mixin.

 1import { wire } from 'lwc';
 2import { getRecord, getFieldValue } from 'lightning/uiRecordApi';
 3
 4export function useAccountFields(constructorLike, fields) {
 5  ...
 6}
 7
 8export function useCaseFields(constructorLike, fields) {
 9  return class extends constructorLike {
10    @wire(getRecord, {recordId: '$recordId', fields: fields })
11    _case;
12
13    get Case() {
14      return Object.fromEntries(
15        new Map(fields.map(field =>
16          [field.fieldApiName, getFieldValue(this._case.data, field)]
17        ))
18      );
19    }
20  }
21}

Now inside your main component all you need to do is this

 1import { LightningElement, api } from 'lwc';
 2
 3import AnnualRevenue from '@salesforce/schema/Account.AnnualRevenue'; 
 4import CreatedDate from '@salesforce/schema/Account.CreatedDate';
 5import SLAExpirationDate__c from '@salesforce/schema/Account.SLAExpirationDate__c';
 6
 7import { useAccountFields } from 'c/useRecordFields';
 8
 9const fields = [AnnualRevenue, CreatedDate, SLAExpirationDate__c];
10
11export default class myComponent extends useAccountFields(LightningElement, fields) {
12  @api recordId; // still important
13
14  handleClick() {
15    console.log(this.Account.AnnualRevenue);
16  }
17}

Hope this helps !

UPDATE

The way we wrote the mixin classes useAccountRecords or useCaseRecords is still very imperative. Instead we can think of a way to make it more generic like so

 1export function useRecordFields(genericConstructor, {recordId, fields}) {
 2  const {objectApiName} = fields[0];
 3  class placeholder extends genericConstructor {
 4    @wire(getRecord, {recordId: '$recordId', fields: fields})
 5    _fields
 6  }
 7  Object.defineProperty(placeholder.prototype, objectApiName, {
 8      get() {
 9        return Object.fromEntries(fields.map((field) => {
10          return [field.fieldApiName, getFieldValue(this._fields.data, field)]
11        }))
12      }
13  });  
14  return placeholder;
15}

Here on line 2, we are grabbing the objectApiName prop from the first element of our fields array we provided as a parameter of our mixin, giving us the string value of the SObject it belongs to.

When you import a field in LWC it has this shape, imagine we're in typescript

1type SObjectField = {
2  fieldApiName: string; // => Id/LastModifiedDate/CustomField__c/whatever...
3  objectApiName: string; // => Case/Quote/CustomObject__c/whatever...
4}

So that then we can use Object.defineProperty() on the prototype of our placeholder class to dynamically create a getter whose key will be this objectApiName.

2024-06-22 Patryk

Its amazing to see how you are bringing regular software dev best practices to Salesforce world!