Skip to main content
April 1, 2026Noble Desktop Publishing Team/11 min read

Lists: Retaining User Data with Persistent Storage

Master iOS Data Persistence with UserDefaults Storage

Core Learning Objectives

Persistent Storage Implementation

Learn to retain user data between app sessions using UserDefaults. Convert class-based data to simple types for storage compatibility.

Data Module Architecture

Create app-wide accessible data modules. Structure code for maintainability and separation of concerns in Swift projects.

UI State Management

Master checkbox state retention and table view cell reuse patterns. Sync user interface with persistent storage effectively.

Topics Covered in This iOS Development Tutorial:

Retaining the Check Button's Checked/Unchecked State, Data Modules in Swift, Importing & Converting Information Stored in UserDefaults, Updating UserDefaults When Lists or Items are Added, Deleted, or Modified, Syncing the UI with Persistent Storage, Testing the Persistent Storage Implementation

Exercise Preview

ex prev lists persistent storage

Exercise Overview

One of the most frustrating user experiences in app development is data loss—when all user input vanishes after relaunching the simulator or closing the app. To create production-ready applications, we must implement persistent storage that retains user data between sessions. This exercise demonstrates one of the most straightforward approaches to data persistence in iOS development.

When implementing persistent storage, there's a critical architectural consideration that separates novice from professional developers: you cannot directly export custom classes to persistent storage. Different systems implement classes and types differently, making direct class serialization unreliable and often impossible. Instead, we must convert our class-based data structures into fundamental types—dictionaries, arrays, and primitives—and vice versa. This conversion pattern is essential not only for local storage but also for working with cloud services like Firebase, REST APIs, and data synchronization across platforms.

Critical Storage Limitation

Classes cannot be exported directly to persistent storage because different systems implement classes differently. All data must be converted to simple types like dictionaries and arrays.

UserDefaults Storage Approach

Pros
Built into iOS sandbox architecture
Simple implementation for basic data
Automatic security through app sandboxing
No external dependencies required
Cons
Limited to small amounts of data
Large data storage slows app launch
Only supports basic data types
Not suitable for complex data structures

Getting Started

  1. Launch Xcode if it isn't already running.

  2. If you completed the previous exercise, Lists.xcodeproj should remain open. If you closed it, navigate back and reopen the project.

  3. We strongly recommend completing the previous exercises (5A–6B) before proceeding, as this exercise builds upon established patterns and code structure. If you did not complete the previous exercises:

    • Navigate to File > Open
    • Browse to Desktop > Class Files > yourname-iOS App Dev 2 Class > Lists Ready for Persistent Storage and double-click Lists.xcodeproj
  4. Ensure you have the following files open in separate Editor tabs: Main.storyboard, Data Model.swift, ListsVC.swift, ListItemsVC.swift, and ListItemTVCell.swift. Use the Cmd–T shortcut to open new tabs as needed.

Prerequisites Verification

0/3

Retaining the Check Button's Checked/Unchecked State

Before implementing full persistent storage, we need to address a fundamental issue where data is lost even before the app is relaunched. This problem illustrates a common misunderstanding about UITableView cell reuse patterns that can plague even experienced developers.

  1. If the Simulator isn't currently running, return to Xcode and click the Run button run icon.

  2. If no lists appear on the initial screen, use the text field and Plus (+) button to create one.

  3. Tap a list name to navigate to the detail screen.

    If no items exist, add at least two items to test our functionality.

  4. Tap the Check button Unchecked next to one of the items to mark it as completed.

  5. Continue testing with additional items if desired, making note of which items you've marked as checked versus unchecked.

  6. Tap the < Lists button to return to the previous screen, then tap the list name again to navigate back to the detail view.

    Notice the problem: all Checked images Checked have disappeared. The app has failed to retain the checked/unchecked state of the items.

  7. Return to Xcode to diagnose and resolve this issue.

  8. The method responsible for changing the button image resides in ListItemTVCell.swift. Click on its tab at the top of Xcode.

  9. In the checkButtonTapped method, locate and copy this complete line:

    checkButton.setImage(item.checked ? UIImage(named: "Checked.png") : UIImage(named: "Unchecked.png"), for:.normal)
  10. The solution requires adding similar code to the parent View Controller. Switch to the ListItemsVC.swift tab to implement this fix.

  11. Paste the copied code into the cellForRowAt method at the location shown in bold:

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
       let listItemTVCell = tableView.dequeueReusableCell(withIdentifier: "ListItemTVCell", for: indexPath) as! ListItemTVCell
       listItemTVCell.item = list.items[indexPath.row]
       listItemTVCell.itemNameLabel.text = listItemTVCell.item.title
       checkButton.setImage(item.checked ? UIImage(named: "Checked.png") : UIImage(named: "Unchecked.png"), for:.normal)
    
       return listItemTVCell
    }
  12. Since checkButton and item are properties of the listItemTVCell instance, update the code with the proper object references:

    listItemTVCell.checkButton.setImage(listItemTVCell.item.checked ? UIImage(named: "Checked.png") : UIImage(named: "Unchecked.png"), for:.normal)
  13. Test the implementation by clicking the Run button run icon again.

  14. Create a new list, add several items, and test the check functionality by marking some items as completed.

  15. Navigate back to the lists screen using the < Lists button, then return to the detail screen by tapping the list name.

    Success! The items you marked as checked Checked should maintain their state.

  16. Understanding why this fix was necessary is crucial for iOS development. Return to Xcode to examine the underlying mechanism.

  17. In ListItemsVC.swift, locate this line in the cellForRowAt method:

    let listItemTVCell = tableView.dequeueReusableCell(withIdentifier: "ListItemTVCell", for: indexPath) as! ListItemTVCell

    This line reveals iOS's sophisticated memory management system for table views. Table view cells that scroll off-screen aren't destroyed—they're placed in a reuse queue where the cell objects (but not their specific data) wait for reuse. When iOS needs to display a new cell, this code retrieves a recycled cell from the queue, populates it with relevant data, and displays it on screen.

    This reuse pattern is fundamental to iOS performance optimization. It ensures smooth scrolling through potentially thousands of items because only a small number of cell objects ever exist in memory simultaneously. However, when a cell enters the reuse queue, iOS discards all its specific display state because that information is no longer relevant to its new context.

    This is why you can never rely on table view cells to maintain state information like checked/unchecked status. The professional solution requires implementing state management in two places: the interactive control's event handler (like our checkButtonTapped method) AND the cellForRowAt method that configures cells for display.

Table View Cell Memory Management

Table View Cells get queued and reused for performance. When cells enter the queue, all specific data is forgotten. Never rely on cells to store boolean states like checked/unchecked status.

Fix Checkbox State Loss

1

Copy Button Image Code

Extract the setImage method from checkButtonTapped in ListItemTVCell.swift

2

Add to cellForRowAt Method

Paste and modify code in ListItemsVC.swift cellForRowAt method with proper cell references

3

Test State Retention

Navigate between screens to verify checkbox states persist during app session

Data Modules in Swift

As our application grows in complexity, multiple files will need access to persistent storage functionality. This creates an architectural challenge: how do we organize data access code to be maintainable, testable, and accessible throughout the app? The answer lies in creating a dedicated data module.

Modules in Swift are self-contained units of code that can be imported and used across projects. The Swift ecosystem includes thousands of third-party modules available through package managers, and many developers share their solutions at community sites. By restructuring our data model file into a comprehensive data module, we create a clean separation of concerns and establish patterns that scale with professional development practices.

  1. Click on the Data Model.swift tab at the top of Xcode.

  2. In the Project navigator project navigator icon, single-click on Data Model to make the filename editable.

  3. Type Data Module and press Return to confirm the change.

  4. Update the header comment to reflect the new purpose and prevent confusion when revisiting this project:

    //
    //  Data Module.swift
    //  Lists

    TIP: When renaming files referenced elsewhere in your codebase, use Xcode's refactoring tools. See the Renaming Classes reference section for comprehensive guidance on managing dependencies during refactoring.

  5. Create clear separation between our data model and persistent storage functionality with a MARK comment:

    import UIKit
    
    // MARK: - Data Model
    
    class ListItem {
  6. Add a second MARK comment at the bottom of the file to organize our new persistent storage code:

    var lists = [List]() { didSet { lists.sort() { $0.title < $1.title } } }
    
    
    // MARK: - Methods for Persistent Storage
  7. Implement the two essential methods that form the foundation of our persistence system:

    // MARK: - Methods for Persistent Storage
    
    func loadLists() {
    
    }
    
    
    func saveLists() {
    
    }

    These methods represent the core of any data persistence architecture. The loadLists method handles data hydration—converting stored simple types back into our rich List and ListItem class instances. This process populates our data model and UI with previously saved user data.

    The saveLists method manages data dehydration—converting our class instances into simple types suitable for persistent storage. This method ensures our storage remains synchronized whenever users add, modify, or delete lists and items. Understanding this conversion pattern is essential for working with any external data system, from local databases to cloud APIs.

File Organization Best Practice

Rename Data Model.swift to Data Module.swift when expanding beyond simple models. This reflects the file's broader responsibility for data management and storage operations.

Essential Data Module Methods

loadLists Method

Retrieves data from persistent storage and converts simple types back into List and ListItem class instances for the app to use.

saveLists Method

Converts class-based data into simple types and saves changes to persistent storage when lists or items are modified.

Importing & Converting Information Stored in UserDefaults

iOS offers several persistent storage solutions, each with distinct advantages and complexity trade-offs. For our application, UserDefaults provides the most straightforward implementation while teaching fundamental concepts applicable to more advanced storage systems.

Understanding UserDefaults requires grasping iOS's security architecture. Each iOS application operates within a sandbox—a protective container that isolates the app from the operating system and other applications. If malicious code compromises one app or a critical error occurs, the sandbox contains the damage, protecting the user's device and other applications. This security model means apps cannot store data outside their designated sandbox, making UserDefaults each app's private, built-in storage system.

UserDefaults is optimized for storing user preferences and small amounts of configuration data. While convenient for our tutorial, be aware that storing large datasets in UserDefaults can significantly slow app launch times in production applications.

  1. Begin implementing the loadLists method by creating a UserDefaults instance. Add the following constant to establish access to our app's UserDefaults:

    func loadLists() {
       let retrievedData = UserDefaults.standard
    }
  2. UserDefaults stores data in a property list (plist) format, supporting only fundamental data types: strings, numbers, arrays, and dictionaries. Complex objects must be converted to these basic types. Access our stored dictionary using the UserDefaults dictionary method:

    let retrievedData = UserDefaults.standard.dictionary(forKey: "ListOfLists")

    Every item in UserDefaults requires a unique key for identification. Choose descriptive key names like ListOfLists that clearly indicate the stored data's purpose and make your code self-documenting.

    TIP: Examine your app's property list structure by opening Info.plist in a new Xcode tab. This demonstrates the key-value organization UserDefaults employs.

  3. Implement defensive programming with a guard statement to handle cases where data retrieval fails:

    guard let retrievedData = UserDefaults.standard.dictionary(forKey: "ListOfLists") else { return }

    Guard statements provide elegant error handling and early exit patterns. The else clause specifies the response to failure conditions—in this case, gracefully exiting the method if no stored data exists. When the guard succeeds, we're guaranteed that retrievedData contains valid data, eliminating the need for additional nil-checking throughout the method.

    This code attempts to retrieve the dictionary stored under the ListOfLists key. Success populates the retrievedData constant with our stored data; failure triggers the early return.

  4. Ensure perfect synchronization between our data model and UserDefaults by clearing any existing data before loading:

    guard let retrievedData = UserDefaults.standard.dictionary(forKey: "ListOfLists") else { return }
    lists.removeAll()

    NOTE: The global lists variable serves as our primary data model, representing the authoritative "list of lists" that drives our user interface. This array must mirror the information stored in our UserDefaults dictionary.

  5. Design the data structure format that will enable seamless conversion between UserDefaults storage and our object model. The following diagram illustrates the nested structure we'll implement:

    lists persistent storage format visualized

    Our storage architecture uses a three-level hierarchy: a dictionary at the root level uses list titles as keys for easy lookup. Each dictionary value contains an array representing all items within that list. Each list item is stored as an array containing two elements: the item's title (String) and its completion status (Boolean). This structure balances simplicity with the flexibility needed for our data relationships.

  6. Cast the raw UserDefaults data into our structured format using Swift's type system:

    lists.removeAll()
    
    let dictionaryOfArraysOfArrays = retrievedData as! [String:[[Any]]]
  7. Understanding this type annotation is crucial for working with complex data structures:

    • The [String:[[Any]]] format defines a dictionary where keys are strings and values are arrays of arrays
    • The innermost arrays use Any type because they contain heterogeneous data—both strings (item titles) and booleans (completion status)
    • retrievedData contains the raw, untyped data from UserDefaults, while dictionaryOfArraysOfArrays provides type-safe access to our structured data format
  8. Convert the structured UserDefaults data back into our native Swift classes using nested iteration. Begin by iterating through each list in the dictionary:

    let dictionaryOfArraysOfArrays = retrievedData as! [String:[[Any]]]
    for (listTitle, listItems) in dictionaryOfArraysOfArrays {
    
    }

    The for (key, value) in dictionary syntax provides clean access to both the list title (key) and its associated items (value). Despite the complex UserDefaults interaction, the core iteration logic remains familiar Swift code.

  9. Create a new List instance for each retrieved list using our existing data model:

    for (listTitle, listItems) in dictionaryOfArraysOfArrays {
       let newList = List(listTitle: listTitle)!
    }

    This instantiates a fresh List object using our established initializer. The listTitle parameter receives the key from our iteration loop, connecting the stored data with our object model.

  10. Process the array of list items associated with each list:

    for (listTitle, listItems) in dictionaryOfArraysOfArrays {
       let newList = List(listTitle: listTitle)!
       let arrayOfArrays = listItems
    }
  11. Each list item requires individual processing to extract both the title and completion status. Add a nested loop to iterate through individual items:

    for (listTitle, listItems) in dictionaryOfArraysOfArrays {
       let newList = List(listTitle: listTitle)!
       let arrayOfArrays = listItems
       for array in arrayOfArrays {
    
       }
    }

    This nested iteration pattern is common when working with complex data structures from external sources. The outer loop handles lists, while the inner loop processes individual items within each list.

  12. Begin creating ListItem instances from the stored data. Add the following code to append new ListItem objects to our current list (this line is incomplete and will show an error temporarily):

    for array in arrayOfArrays {
       newList.items.append(ListItem
    }

    NOTE: The newList constant is a List class instance with an items property that stores an array of ListItem objects. We use dot notation to access this property and append new items as we reconstruct them from storage.

  13. Complete the ListItem initialization by adding the opening parenthesis. Xcode will display available initializer options:

Key Takeaways

1UserDefaults provides simple persistent storage within iOS app sandbox, but only supports basic data types like strings, arrays, and dictionaries
2Class objects must be converted to simple types before storage and converted back when loading, as different systems implement classes differently
3Table View Cell reuse requires state-changing code in both interactive handlers and cellForRowAt method to prevent data loss
4Guard statements provide essential error handling when accessing UserDefaults, preventing crashes from missing or corrupted data
5Data modules should be organized with clear separation between model classes and persistent storage methods for maintainability
6Property lists store data as key-value pairs, requiring unique keys and supporting nested collections like arrays of arrays
7Memory management in table views discards cell-specific data during reuse, making cells unreliable for storing boolean states
8The sandbox architecture protects iOS apps from security breaches by isolating each app's data and preventing cross-app access

RELATED ARTICLES