How to Pass Data Using prepare(for:sender:) in Swift

How to Pass Data Using prepare(for:sender:) in Swift

Written by

in

Quick answer: To pass an object during a storyboard segue in Swift, override prepare(for:sender:), verify the segue.identifier, downcast segue.destination to your target view controller class, and assign the object to a public property on that view controller. You can also pass custom data directly through the sender parameter in performSegue(withIdentifier:sender:).

Why this happens

In UIKit storyboard architectures, storyboard transitions trigger the prepare(for:sender:) lifecycle method on the presenting UIViewController. The UIKit framework fixes the method signature to accept only two parameters: the UIStoryboardSegue object and an optional Any? sender. Because you cannot change this method signature to accept custom arguments, passing arbitrary data models—such as selected items, database records, or coordinate annotations—requires specific patterns.

Developers frequently encounter two primary obstacles when handling segues:

  1. Dynamic context: When a user taps an accessory button (like a MKAnnotationView callout) or an arbitrary button on screen, the source view controller knows which model triggered the action, but prepare(for:sender:) runs separately from the tap handler.
  2. Type erasure: The destination property on UIStoryboardSegue is typed as a generic UIViewController. If the destination view controller is embedded in a UINavigationController, accessing custom properties on the target controller causes a runtime failure unless the navigation container is unwrapped first.

How to fix it

1. Pass data directly by downcasting the destination view controller

The most standard approach checks the segue identifier, downcasts segue.destination to your custom view controller type, and directly assigns the required data to a public property.

First, define a receiving property on your destination view controller:

import UIKit

class DetailViewController: UIViewController {
    // Public property to receive data
    var selectedCity: String?

    override func viewDidLoad() {
        super.viewDidLoad()
        if let city = selectedCity {
            print("Loaded details for: \(city)")
        }
    }
}

In your presenting view controller, override prepare(for:sender:) and assign the data:

import UIKit

class MasterViewController: UIViewController {
    private let destinationSegueID = "ShowDetailSegue"
    private var currentCity = "Austin"

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

        if let detailVC = segue.destination as? DetailViewController {
            detailVC.selectedCity = currentCity
        }
    }
}

2. Forward the object through performSegue(withIdentifier:sender:)

Storing dynamic items in intermediate instance variables on the source view controller creates mutable state that can easily get out of sync. A cleaner solution passes the model object directly into the sender parameter when initiating the segue programmatically.

This pattern works especially well when handling multiple buttons, table cells, or map annotations:

import UIKit
import MapKit

struct LocationModel {
    let name: String
    let coordinate: CLLocationCoordinate2D
}

class MapViewController: UIViewController, MKMapViewDelegate {
    @IBOutlet weak var mapView: MKMapView!

    private let detailSegueID = "ShowLocationDetail"

    // Triggered when a user taps a callout accessory button
    func mapView(_ mapView: MKMapView, annotationView view: MKAnnotationView, calloutAccessoryControlTapped control: UIControl) {
        guard let annotation = view.annotation else {
            return
        }

        let selectedLocation = LocationModel(
            name: annotation.title ?? "Unknown",
            coordinate: annotation.coordinate
        )

        // Pass the model directly into the sender argument
        performSegue(withIdentifier: detailSegueID, sender: selectedLocation)
    }

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

        // Cast both the destination controller and the sender object
        if let detailVC = segue.destination as? DetailViewController,
           let location = sender as? LocationModel {
            detailVC.selectedCity = location.name
        }
    }
}

Using sender ensures that whatever object triggered the segue is forwarded directly to the preparation step without holding temporary instance properties.

3. Handle destinations embedded inside a UINavigationController

When a segue presents modally, storyboard configurations often embed the target view controller inside a UINavigationController. In this scenario, segue.destination is the UINavigationController, not your custom subclass. Attempting a direct cast like segue.destination as? DetailViewController fails and yields nil.

Resolve this by unwrapping topViewController:

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

    // Handle direct presentation or embedded navigation controller
    let targetVC: DetailViewController?

    if let navController = segue.destination as? UINavigationController {
        targetVC = navController.topViewController as? DetailViewController
    } else {
        targetVC = segue.destination as? DetailViewController
    }

    if let detailVC = targetVC, let location = sender as? LocationModel {
        detailVC.selectedCity = location.name
    }
}

4. Alternative: Use storyboard creator blocks (iOS 13+)

If your deployment target supports iOS 13 or later, you can bypass prepare(for:sender:) entirely by using instantiateViewController(creator:). This pattern allows true dependency injection through custom initializers, eliminating the need for optional or implicitly unwrapped properties on the destination.

Define a custom initializer on the destination view controller:

import UIKit

class DetailViewController: UIViewController {
    let selectedCity: String

    init?(coder: NSCoder, selectedCity: String) {
        self.selectedCity = selectedCity
        super.init(coder: coder)
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented. Use init(coder:selectedCity:).")
    }
}

Instantiate and present it programmatically without an outlet-based segue:

func showDetail(for cityName: String) {
    guard let detailVC = storyboard?.instantiateViewController(identifier: "DetailViewController", creator: { coder in
        DetailViewController(coder: coder, selectedCity: cityName)
    }) else {
        return
    }

    navigationController?.pushViewController(detailVC, animated: true)
}

How to prevent it

  • Avoid intermediate state: Do not store transient tap data in class-level instance variables solely to read it in prepare(for:sender:). Pass it via the sender parameter in performSegue(withIdentifier:sender:).
  • Unwrap navigation controllers: Always check if segue.destination is a UINavigationController before attempting to cast to your target view controller.
  • Use string enums for segue IDs: Replace raw strings with strongly typed enum SegueIdentifier: String values to catch typos at compile time.
  • Adopt dependency injection: Where possible, replace storyboard segues with IBSegueAction or instantiateViewController(creator:) to enforce non-optional parameters on destination controllers.

FAQ

Why is segue.destination nil or failing the downcast?

The destination view controller is likely wrapped in a UINavigationController or UITabBarController. Cast segue.destination to UINavigationController first, then inspect its topViewController or viewControllers.first property to access your custom class.

Can I pass multiple items through the sender parameter?

Yes. Because sender is of type Any?, you can pass a custom struct, a Dictionary, a tuple, or an array of objects. In prepare(for:sender:), cast sender to that specific tuple or struct type.

How can the destination view controller send data back to the presenter?

Define a delegate protocol on the destination view controller. In prepare(for:sender:), set destinationVC.delegate = self. When the destination finishes its work, it calls the delegate method on the presenting controller before dismissing itself.

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.