How do I pass a SObject record from LWC to a custom Apex method to persist data?

In LWC, it's now preferable to attempt to use Lightning Data Service first, which includes any of the ui*Api adapters such as getRecord, createRecord and updateRecord:

The simplest way to work with data is to use the base Lightning components built on LDS: lightning-record-form, lightning-record-edit-form, or lightning-record-view-form components.

If you need more customization than those components allow, use @wire to specify a Lightning Data Service wire adapter. Each wire adapter gets a different data shape.

Let's say you've exhausted your options and are looking for a scalable, repeatable way to consistently pass data between client and server. An extremely flexible datatype is then Map<String, Object>:

lwcTest.html

<lightning-button label="Mutate Data" onclick={mutateData}></lightning-button>
<lightning-button label="Update Complex" onclick={updateComplexData}></lightning-button>
<lightning-button label="Update Account" onclick={updateAccountData}></lightning-button>

lwcTest.js

import { LightningElement, wire, api } from 'lwc';
import wireSimpleOrComplexData  from '@salesforce/apex/DataServiceCtrl.wireSimpleOrComplexData';
import updateComplex  from '@salesforce/apex/DataServiceCtrl.updateComplex';
import updateAccount  from '@salesforce/apex/DataServiceCtrl.updateAccount';

export default class LwcTest extends LightningElement {
  @api
  get recordId() {
    return this._accountId;
  }
  set recordId(value) {
    this._accountId = value;
  }

  // private
  _accountId;
  _wiredData;
  _account;
  _contacts;
  _opportunities;

  @wire(wireSimpleOrComplexData, { accountId: '$_accountId' })
  wiredData(result) {
    this._wiredData = result; // to preserve refreshApex if needed
    if (result.data) {
      // for single sobject object spread works since this is a shallow clone
      this._account = { ...result.data.accountFromServer };

      // for collections, since every element in array is proxied, we need a deep clone
      this._contacts = JSON.parse(JSON.stringify(result.data.contactsFromServer));

      // if complex objects are wanted, it might be better to do this at the result.data level
      this._opportunities = JSON.parse(JSON.stringify(result.data.opportunitiesFromServer));

      console.log(result.data.user.firstName); // UserInfo.getFirstName()
      console.log(result.data.system.now);     // System.now()
    } else {
      console.log(result.error);
    }
  }

  // Before reaching this, all the following data had their read only proxies removed
  mutateData() {
    this._account.Name += ' Updated';

    for (let contact of this._contacts) {
      contact.Email = contact.LastName + '@test.com';
    }

    for (let opp of this._opportunities) {
      opp.Name += ' Updated';
    }
  }

  updateComplexData() {
    const dataToUpdate = {
      accountAfterMutate: this._account,
      contactsAfterMutate: this._contacts,
      opportunitiesAfterMutate: this._opportunities
    }
    updateComplex({data: dataToUpdate})
      .then(result => {
        // on success, you can bind to a tracked vars to re-render them
        console.log(result);
      })
      .catch(error => {
        console.log(error);
      });
  }

  updateAccountData() {
    updateAccount({accountToUpdate: this._account})
      .then(result => {
        // on success, you can bind to a tracked account variable for template view to re-render the template
        console.log(result);
      })
      .catch(error => {
        console.log(error);
      });
  }
}

DataServiceCtrl.cls

  @AuraEnabled (cacheable=true)
  public static Map<String, Object> wireSimpleOrComplexData(String accountId) {
    Map<String, Object> result = new Map<String, Object>();
    // Data 1 single sobject
    Account acc = [SELECT Id, Name FROM Account WHERE Id =: accountId LIMIT 1];
    result.put('accountFromServer', acc); // otherwise will be an array of 1 if directly SOQL-ed

    // Data 2 collections
    result.put('contactsFromServer', [SELECT Id, LastName FROM Contact WHERE AccountId =: accountId]);
    result.put('opportunitiesFromServer', [SELECT Id, Name FROM Opportunity WHERE AccountId =: accountId]);

    // Data 3 nested properties like a POJO
    Map<String, String> userInfoMap = new Map<String, String>();
    userInfoMap.put('firstName', UserInfo.getFirstName());

    Map<String, Object> systemInfoMap = new Map<String, Object>();
    systemInfoMap.put('now', System.now());
    
    result.put('user', userInfoMap);
    result.put('system', systemInfoMap);

    return result;
  }

  @AuraEnabled
  public static Map<String, Object> updateComplex(Map<String, Object> data) {
    // Because sobjects were directly used, we can use this serialize/deserialize trick to get it back into a useable state
    Account account = (Account) JSON.deserialize(
      JSON.serialize(data.get('accountAfterMutate')),
      Account.class
    );
    List<Contact> contacts = (List<Contact>) JSON.deserialize(
      JSON.serialize(data.get('contactsAfterMutate')),
      List<Contact>.class
    );
    List<Opportunity> opportunities = (List<Opportunity>) JSON.deserialize(
      JSON.serialize(data.get('opportunitiesAfterMutate')),
      List<Opportunity>.class
    );
    // you could put database.saveResult in here if you want
    Map<String, Object> updateResults = new Map<String, Object>();
    update account;
    update contacts;
    update opportunities;
    updateResults.put('account', account);
    updateResults.put('contacts', contacts);
    updateResults.put('opportunities', opportunities);

    return updateResults;
  }

  @AuraEnabled
  public static Account updateAccount(Account accountToUpdate) {
    // no need to serialize/deserialize or transport as JSON here
    update accountToUpdate;
    return accountToUpdate;
  }

I started looking around and took a cue from the topic Migrate Apex, which states:

Aura components and Lightning web components both use an Apex controller to read or persist Salesforce data. There are no syntax differences for the two programming models.

So based on how it worked in Lightning Aura Components, I attempted to see if it worked in LWC as well, and Yes, it does work.

In summary, I needed to make sure that I represent the SObject record/data as a JSON and then pass that as a parameter.

Below is the sample code that worked for me, where I was able to construct a JSON/manipulate an existing JSON and then pass it as a parameter to a custom Apex method to create/update a record.

HTML

<lightning-card title="My Hello World" icon-name="standard:contact">
    {recordId}
</lightning-card>
<lightning-button label="Create Record" onclick={createRecord}></lightning-button>
<lightning-button label="Update Record" onclick={udpateRecord}></lightning-button>

JavaScript

import createContactRecord from '@salesforce/apex/ContactController.createContactRecord';
import updateContactRecord from '@salesforce/apex/ContactController.updateContactRecord';
import myContact from "@salesforce/apex/ContactController.fetchContact";
....

@track recordId;
contactRecord;


// fetches a contact record from Apex
@wire (myContact)
    fetchedContact({error, data}){
        if(data){

            // this is where I save the fetched contact which will be updated later
            this.contactRecord = JSON.stringify(data);
            ...
            ...
        }
        ...
    }


// my create record JS function, where I construct a SObject and am able to 
// successfully create a record
createRecord() {

    // created a JSON representation of the Contact record, 
    // same as we would do in Lightning Aura Components

    let cont = { 'sobjectType': 'Contact' };
    cont.FirstName = 'Jayant';
    cont.LastName = 'From LWC';

    createContactRecord({newRecord: cont})
        .then(result => {
            this.recordId = result;
            console.log(result);
        })
        .catch(error => {
            console.log(error);
            this.error = error;
        });
}


// my update record JS function, where I manipulate the JSON 
// and set some values to be able to successfully update the record
updateRecord() {
    let cont = JSON.parse(this.contactRecord);

    // update the fields those are required to be updated
    cont.LastName = '-LWC1';

    updateContactRecord({recordForUpdate: cont})
        .then(result => {
            this.wiredContact = result;
            console.log(result);
        })
        .catch(error => {
            console.log(error);
            this.error = error;
        });
}   

Apex

@AuraEnabled(cacheable=true)
public static Contact fetchContact(){
    return [SELECT Id,Name, LastName FROM Contact where Id='xxxx' LIMIT  1];
}


@AuraEnabled
public static String createContactRecord(Contact newRecord){
    insert newRecord;
    return newRecord.Id;
}


@AuraEnabled
public static String updateContactRecord(Contact recordForUpdate){
    update recordForUpdate;
    return recordForUpdate.Name;
}

Here is my code which I use to CRU records in LWC. Fairly basic example. I am not using String or JSON manipulation. I am also using static binding using fieldName imports

HTML:

<lightning-input label="FirstName" value={realFormData.FirstName} if:true={realFormData} onchange={updateValue}  data-field="FirstName"></lightning-input>
<lightning-input label="LastName" value={realFormData.LastName} if:true={realFormData} onchange={updateValue}  data-field="LastName"></lightning-input>

{recordId} <br/>
<button  class="slds-button" onclick={saveRecord}> Save Record</button>

<br/>
<button  class="slds-button" onclick={createRecord}> Create new hardcored CONTACT Record and load in UI</button>

`

JS:

import { LightningElement ,wire,track,api } from 'lwc';
import getMyContact from "@salesforce/apex/ContactController.fetchContact";
import updateMyContact from "@salesforce/apex/ContactController.updateContact";
import createMyContact from "@salesforce/apex/ContactController.createContact";
import { refreshApex } from '@salesforce/apex';
import CONTACT_FIRSTNAME from '@salesforce/schema/Contact.FirstName';
import CONTACT_LASTNAME from '@salesforce/schema/Contact.LastName';


export default class MyCmp extends LightningElement {


 @api wiredContact;
 @api recordId;
 @api realFormData;


 @wire (getMyContact , { contactId: '$recordId' })
        fetchedContact( resp){
           this.wiredContact = resp;
           this.realFormData = {... this.wiredContact.data};

    }


    updateValue(event){



        this.realFormData = {...this.realFormData , [event.target.dataset.field] : event.detail.value};
        console.log( this.realFormData);
    }


    saveRecord(event ){

        updateMyContact({con : this.realFormData}).then(()=>{

            console.log('Refresh Apex called');
            refreshApex(this.wiredContact);
        });

    }


    createRecord(event ){
            let newContact = { [CONTACT_FIRSTNAME.fieldApiName] : 'Pikachdu' ,[CONTACT_LASTNAME.fieldApiName] : 'Raichu' };



            createMyContact({con : newContact}).then((resp)=>{
                            this.recordId = resp.Id; //this will auto call wireMethod/


            }).catch((err) => {
               // Handle any error that occurred in any of the previous
               // promises in the chain.

               console.log(JSON.stringify(err));
             });



        }




}

Apex:

public class ContactController {

    @AuraEnabled(cacheable=true)
    public static Contact fetchContact(Id contactId){
        return [SELECT Id,FirstName,LastName FROM COntact where id=:contactId LIMIT  1];
    }


    @AuraEnabled
    public static void updateContact(Contact con){
        update con;
    }

    @AuraEnabled
    public static contact createContact(Contact con){

        insert con;
        return con;


    }

}