How to Pass Data Between View Controllers in Swift (Forward and Back)

How to Pass Data Between View Controllers in Swift (Forward and Back)

Written by

in

Quick answer: Pass data forward by setting a property on the destination view controller before it appears — in prepare(for:sender:) for segues, or directly after creating it. Pass data back with a delegate protocol or a closure so the form screen receives the selection without using global state.

Why this happens

A view controller owns its view hierarchy, not the data other screens need. UIKit does not hand data from one controller to another automatically. When you present or push a new screen, it starts with whatever state its own properties and storyboard have. If the form screen needs the products the user picked, something has to put that array there.

The common beginner fix is to store the array on the app delegate or in a singleton. That compiles, but it hides the dependency. Any screen can read or write the array, its lifetime is tied to the whole app, and tests become harder. The same problem appears when the selection screen tries to write back to the form screen directly: it should not need to know how the form is built.

The reliable pattern is explicit ownership. The form screen owns the form data. It passes what the selection screen needs when it creates it, and it receives the result through a delegate or closure when the selection screen finishes.

How to fix it

1. Pass data forward with a property

Create the destination view controller, set the property it needs, then push or present it. This is the simplest approach and works with or without storyboards.

let selectionVC = ProductSelectionViewController(nibName: nil, bundle: nil)
selectionVC.selectedProducts = form.products
navigationController?.pushViewController(selectionVC, animated: true)

The destination must declare the property:

final class ProductSelectionViewController: UITableViewController {
    var selectedProducts: [Product] = []
}

Set the property before the view loads. If you set it after viewDidLoad has already run, the screen may not reflect the new value until you reload its views manually.

2. Pass data forward through a segue

With storyboards, override prepare(for:sender:). It is called before the destination view controller appears, which makes it the right place to hand over data.

override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
    guard segue.identifier == "showProductSelection" else { return }

    let destination = segue.destination
    let selectionVC: ProductSelectionViewController

    if let navigationController = destination as? UINavigationController,
       let top = navigationController.topViewController as? ProductSelectionViewController {
        selectionVC = top
    } else if let viewController = destination as? ProductSelectionViewController {
        selectionVC = viewController
    } else {
        return
    }

    selectionVC.selectedProducts = form.products
    selectionVC.delegate = self
}

If the destination is embedded in a navigation controller, segue.destination is the navigation controller, not your screen. Unwrap topViewController as shown above.

3. Pass data back with a delegate

Passing data back is the part that usually causes trouble. The selection screen should not reach into the form screen. Instead, define a protocol in the selection screen and let the form screen conform to it.

protocol ProductSelectionDelegate: AnyObject {
    func productSelection(_ controller: ProductSelectionViewController,
                          didSelect products: [Product])
}

final class ProductSelectionViewController: UITableViewController {
    weak var delegate: ProductSelectionDelegate?
    var selectedProducts: [Product] = []

    @IBAction func doneTapped(_ sender: UIBarButtonItem) {
        delegate?.productSelection(self, didSelect: selectedProducts)
        navigationController?.popViewController(animated: true)
    }
}

The form screen adopts the protocol and updates its model:

extension FormViewController: ProductSelectionDelegate {
    func productSelection(_ controller: ProductSelectionViewController,
                          didSelect products: [Product]) {
        form.products = products
        tableView.reloadData()
    }
}

Set the delegate when you create or prepare the selection screen. Declare it as weak so the selection screen does not keep the form screen alive after it is dismissed.

4. Pass data back with a closure

A closure is a lighter alternative when the child screen has one job: report a result. It avoids a protocol and an extension when you only need one callback.

final class ProductSelectionViewController: UITableViewController {
    var onSelection: (([Product]) -> Void)?
    var selectedProducts: [Product] = []

    @IBAction func doneTapped(_ sender: UIBarButtonItem) {
        onSelection?(selectedProducts)
        navigationController?.popViewController(animated: true)
    }
}

Assign the closure before presenting the screen:

selectionVC.onSelection = { [weak self] products in
    self?.form.products = products
    self?.tableView.reloadData()
}

Use [weak self] when the closure captures the form screen. A strong capture can create a retain cycle if the selection screen is also retained by the form screen.

5. Share the form model for multi-step forms

If several screens edit the same form, pass a reference to a model object instead of copying individual values. A class reference means every screen sees the same data.

final class OrderForm {
    var products: [Product] = []
    var customerName: String = ""
}

Pass the model forward:

selectionVC.form = form

The selection screen writes into it:

final class ProductSelectionViewController: UITableViewController {
    var form: OrderForm?

    @IBAction func doneTapped(_ sender: UIBarButtonItem) {
        form?.products = selectedProducts
        navigationController?.popViewController(animated: true)
    }
}

Reload the form screen when it comes back into view:

override func viewWillAppear(_ animated: Bool) {
    super.viewWillAppear(animated)
    tableView.reloadData()
}

This pattern keeps the form state in one place and avoids passing five separate properties through every screen. Save to Core Data only when the user submits the form, not on every selection change.

How to prevent it

  • Keep form state in a model owned by the form screen, not in AppDelegate or SceneDelegate.
  • Pass dependencies through properties or initializers before presenting a screen.
  • Declare delegate properties as weak to avoid retain cycles.
  • Use closures for one-off callbacks and delegates for reusable screens with multiple events.
  • Reload table or collection views in viewWillAppear when returning from a selection screen.
  • Save to Core Data on submit, not after every individual selection.

FAQ

Should I use a singleton or the app delegate to share data between view controllers?

No. It makes the data global and hides which screen owns it. Pass the model or values explicitly, or use a shared model object that the form screen owns.

Delegate or closure for passing data back?

Use a closure when there is one callback, such as “selection finished.” Use a delegate when the child screen needs to report several events or when the same screen is reused in different places.

How do I pass multiple selected products back from a UITableViewController?

Keep the selected products in an array on the selection controller. When the user taps Done, pass that array through the delegate method or closure, then pop or dismiss. The form screen assigns it to its model and reloads its table.

Sources & credits

Parts of this guide are adapted from content licensed under CC BY-SA 4.0; this article is shared under the same license.