Skip to main content

How to Add and Remove Multiple Parent and Child Records Dynamically in Salesforce

While Lightning Web Components (LWC) are the modern standard for Salesforce UI development, thousands of organizations still maintain and build upon complex Visualforce pages for internal data-entry tools. One of the most common—and challenging—requirements is creating a single, unified screen where users can dynamically add multiple parent records (like Accounts) and multiple child records (like Contacts) at the same time before hitting save.

In plain words: To let users click an "Add Row" button for both parent and child records on a single Visualforce page, you must use "nested wrapper classes" in your Apex controller. These wrappers tie the records together in the server's memory, so when the user adds or deletes a specific row, Salesforce knows exactly which data to update without losing their inputs.

Key Points Summary

  • Nested Wrappers are Essential: Standard sObjects cannot hold temporary lists of children in memory easily. A custom Apex class (Wrapper) solves this by bundling an Account and a List of Contacts together.
  • Visualforce Indexing: The `` tag acts as a counter. This is how you pass the exact row index back to Apex so the controller knows which specific row to delete.
  • Lightning Styling: Using `` instantly applies the modern Salesforce Lightning Design System to your legacy Visualforce code.

Step 1: The Apex Controller (Backend)

The magic happens in the Apex controller. We build a Parent Wrapper (AccountWrapper) that contains an Account record and a list of Child Wrappers (Contact_Wrapper). When the user clicks "Add" or "Remove", the controller modifies these lists in memory based on the index parameters sent from the Visualforce page.

Real-Life Example: Nested Wrapper Apex Class
This controller initializes a single Account with a single blank Contact on load, and handles the dynamic list modifications.
public class AddMultipleRecordsController {

    public List<AccountWrapper> list_AccountandContact { get; set; }
    
    // Variables to catch the row numbers from the Visualforce page
    public Integer AccountRowNo { get; set; }
    public Integer ContactRowNo { get; set; }

    public AddMultipleRecordsController() {
        // Initialize with one blank Account and one blank Contact
        list_AccountandContact = new List<AccountWrapper>{
            new AccountWrapper(
                new Account(),
                new List<Contact_Wrapper>{ new Contact_Wrapper(new Contact()) }
            )
        };
    }

    // Add a new parent Account card (with one blank Contact inside it)
    public void addAccountandContact() {
        list_AccountandContact.add(
            new AccountWrapper(
                new Account(),
                new List<Contact_Wrapper>{ new Contact_Wrapper(new Contact()) }
            )
        );
    }

    // Add a blank child Contact row to a specific parent Account
    public void addContact() {
        list_AccountandContact.get(AccountRowNo).Contactwrapper.add(
            new Contact_Wrapper(new Contact())
        );
    }

    // Remove a specific child Contact row
    public void DelContact() {
        list_AccountandContact.get(AccountRowNo).Contactwrapper.remove(ContactRowNo);
    }

    // Remove parent Accounts where the user checked the "Select" box
    public void DeleteSelectedAccount() { 
        List<AccountWrapper> tempAccountList = new List<AccountWrapper>(); 
        for (AccountWrapper wrapAcc : list_AccountandContact) {
            // Only keep accounts that are NOT selected for deletion
            if (!wrapAcc.selected) {
                tempAccountList.add(wrapAcc);
            }
        }
        list_AccountandContact = tempAccountList;
    }

    // ==========================================
    // PARENT WRAPPER CLASS
    // ==========================================
    public class AccountWrapper {
        public Account account { get; set; }
        public Boolean selected { get; set; }
        public List<Contact_Wrapper> Contactwrapper { get; set; }

        public AccountWrapper(Account objAccount, List<Contact_Wrapper> lstContact) {
            this.account = objAccount;
            this.selected = false;
            this.Contactwrapper = lstContact;
        }
    } 

    // ==========================================
    // CHILD WRAPPER CLASS
    // ==========================================
    public class Contact_Wrapper {
        public Contact contact { get; set; }
        public Boolean selectedContact { get; set; }

        public Contact_Wrapper(Contact con) {
            this.contact = con;
            this.selectedContact = false;
        }
    }
}

Step 2: The Visualforce Page (Frontend)

The Visualforce page imports SLDS styling and uses `` to loop through the parent list. Inside that loop, it uses a second `` to loop through the child list. `` is used to send the row indexes back to Apex.

<apex:page controller="AddMultipleRecordsController" showHeader="false" lightningStylesheets="true">
    <html xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" lang="en">
        <head>
            <apex:slds />
        </head>
        <apex:form id="formid">
            <div class="slds-scope">
                <apex:actionRegion>
                    <apex:outputPanel id="ouputpanelID">
                        
                        <!-- Initialize Parent Counter -->
                        <apex:variable value="{!0}" var="AccountCounter" />                 
                        
                        <!-- PARENT LOOP -->
                        <apex:repeat value="{!list_AccountandContact}" id="repeatsection" var="ObjAcc">
                            <article class="slds-card slds-m-bottom_medium">
                                <div class="slds-card__header">
                                    <h2 class="slds-text-heading_small"><b>Account Details</b></h2>
                                    
                                    <!-- Parent Data Entry -->
                                    <div class="slds-m-top_small">
                                        <apex:inputCheckbox value="{!ObjAcc.selected}" /> <span class="slds-m-left_xx-small">Select for Deletion</span><br/><br/>
                                        <apex:inputField value="{!ObjAcc.account.Name}" styleClass="slds-input" html-placeholder="Enter Account Name" />
                                    </div>

                                    <!-- CHILD LOOP SECTION -->
                                    <div class="slds-m-top_medium">
                                        <b>Related Contacts</b>
                                        <table class="slds-table slds-table_bordered slds-m-top_x-small">
                                            <thead>
                                                <tr>
                                                    <th>First Name</th>
                                                    <th>Last Name</th>
                                                    <th>Action</th>
                                                </tr>
                                            </thead>
                                            
                                            <!-- Initialize Child Counter -->
                                            <apex:variable value="{!0}" var="ContactCounter" />
                                            
                                            <tbody>
                                                <apex:repeat value="{!ObjAcc.Contactwrapper}" var="ObjCon">
                                                    <tr>
                                                        <td><apex:inputField value="{!ObjCon.contact.FirstName}" styleClass="slds-input"/></td>
                                                        <td><apex:inputField value="{!ObjCon.contact.LastName}" styleClass="slds-input"/></td>
                                                        <td>
                                                            <apex:commandLink value="Remove" action="{!DelContact}" styleClass="slds-text-color_error" reRender="ouputpanelID" rendered="{!ObjAcc.Contactwrapper.size > 1}">
                                                                <!-- Send Indexes to Apex -->
                                                                <apex:param name="AccountRow" value="{!AccountCounter}" assignTo="{!AccountRowNo}"/>
                                                                <apex:param name="ContactRow" value="{!ContactCounter}" assignTo="{!ContactRowNo}"/>
                                                            </apex:commandLink>
                                                        </td>
                                                    </tr>
                                                    
                                                    <!-- Increment Child Counter -->
                                                    <apex:variable var="ContactCounter" value="{!ContactCounter + 1}" />
                                                </apex:repeat>
                                            </tbody>
                                        </table>

                                        <!-- Add Contact Button -->
                                        <div class="slds-m-top_small">
                                            <apex:commandLink value="+ Add Contact" action="{!addContact}" styleClass="slds-button slds-button_neutral" reRender="ouputpanelID">
                                                <apex:param name="AccountRowNo" value="{!AccountCounter}" assignTo="{!AccountRowNo}"/>
                                            </apex:commandLink>
                                        </div>
                                    </div>
                                </div>
                            </article>  
                            
                            <!-- Increment Parent Counter -->
                            <apex:variable var="AccountCounter" value="{!AccountCounter + 1}" />
                        </apex:repeat>

                        <!-- Global Action Buttons -->
                        <div class="slds-m-top_medium">
                            <apex:commandButton value="Delete Selected Accounts" action="{!DeleteSelectedAccount}" styleClass="slds-button slds-button_destructive" reRender="ouputpanelID" rendered="{!list_AccountandContact.size > 1}" />
                            <apex:commandButton value="+ Add Account" action="{!addAccountandContact}" styleClass="slds-button slds-button_brand" reRender="ouputpanelID" />
                        </div>

                    </apex:outputPanel>
                </apex:actionRegion>
            </div>
        </apex:form>
    </html>
</apex:page>

Common Developer Pitfalls

Watch out for these classic traps:
  • Index Mismatches in Nested Loops: If your `` tags are placed incorrectly, the counter will fall out of sync. This causes users to click "Delete" on row 3, but accidentally delete row 1. Make sure to increment the counter immediately *before* the `` closing tag.
  • View State Overhead: Visualforce limits View State size to 135KB. Storing large lists of nested wrappers in memory will bloat the page rapidly, causing it to crash if users add dozens of rows.
  • DML Validation: The code above handles UI functionality, but you still need to write a `Save()` method. Ensure you check for empty required fields before attempting to insert lists of Accounts and Contacts, or the entire page will error out.

Frequently Asked Questions (FAQ)

Q: Should I use this pattern for new Salesforce development?

If you are building a brand new application, no. Visualforce is a legacy framework. You should build dynamic multi-row forms using Lightning Web Components (LWC). In LWC, you manage the rows using a simple Javascript Array of Objects, which completely eliminates server trips and View State limit issues, resulting in much faster performance.

Q: How do I save all these records at once?

To save the data, you would create a SaveData() method in Apex. First, loop through the wrappers and extract the Account records into a List. Run an insert on that List. Next, loop through the wrappers again, take the newly generated Account IDs, and assign them to the AccountId field on the child Contacts before inserting the Contacts.

Q: Why do we use apex:actionRegion?

The <apex:actionRegion> tag determines which parts of the Visualforce form are sent back to the server during an AJAX request. Wrapping our nested tables in this tag ensures we process the row additions/deletions efficiently without re-submitting the entire page DOM.

Core Design Rule: Always use the assignTo attribute inside your `` tags. It is the safest and cleanest way to push frontend row indexes directly into your Apex controller variables.
360 Summary Card
  • Architecture Pattern: Nested Wrapper Classes (Parent -> List<Child>)
  • UI/UX Framework: Salesforce Lightning Design System (SLDS) via ``
  • Essential VF Components: ``, ``, ``, ``
  • Modern Alternative: Lightning Web Components (LWC) handling Javascript Arrays on the client side.