Don’t touch my outlets

Outlets (@IBOutlet) are view controller properties that point to views in the storyboard, such as UILabels, UITextFields, and so forth. Outlets are wonderful things, but you should consider them private. Only the view controller that declares an outlet should touch the outlet. Everyone else: hands off.

I do in fact formally declare all my outlets private, so no other view controller can touch them. If I had my way, all outlets would be declared private by default, and I’ve filed a bug report with Apple suggesting this. A view controller’s view, the interface that it controls, belongs to that view controller and no one else.

But this isn’t just a moral principle. If you touch another view controller’s outlets at the wrong time, you can crash. It’s a common mistake, and now I’ll explain why it happens and what you should do about it.


I think we all know the common patterns for performing a segue and passing data to the next view controller. Here are two of them. The user might trigger the segue, we catch it in prepareForSegue, we grab the destination view controller and configure it:

override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
    if segue.identifier == "second" {
        let svc = segue.destination as! SecondViewController
        svc.data = "This is very important data!"
        svc.delegate = self
    }
}

Or, we might pull the view controller instance out of the storyboard manually and configure it. For example, we might do this when the user selects our table view cell:

override func tableView(_ tableView: UITableView, 
    didSelectRowAt indexPath: IndexPath) {
        let t = self.storyboard!.instantiateViewController(
            withIdentifier: "tracks") as! TracksViewController
        t.mediaItemCollection = self.albums[indexPath.row]
        self.navigationController!.pushViewController(t, animated: true)
}

Well, a common beginner mistake is, at that moment, to try to set something about the destination view controller’s outlets. If you do, you’ll crash. That’s what happened to this user on Stack Overflow:

func tableView(_ tableView: UITableView, 
    didSelectRowAt indexPath: IndexPath) {
        let vc = storyboard?.instantiateViewController(
            withIdentifier: "NoteDetailsViewController") as! NoteDetailsViewController
        vc.noteDetailsOutlet.text = notes[indexPath.row]
        self.show(vc, sender: self)
}

Why did we crash? Because noteDetailsOutlet is nil. Why is it nil? Because it is an outlet. It starts out life as a nil Optional, waiting for the actual view to be hooked up to it. But that hasn’t happened yet!

Things happen in an order. You may find this little dance amusingly baroque, but it’s how things work:

  • instantiateViewController or prepareForSegue – The destination view controller exists, but that’s all. It has no view and its outlets have not been set. You can set its non-outlet properties but that’s all you can do.

  • The segue starts to happen.

  • The destination view controller gets viewDidLoad. Now it has a view and its outlets are set.

  • The segue completes and the destination view controller gets viewWillAppear and later, viewDidAppear. Now its view is actually in the interface.

So what’s the correct pattern? First, you set an ordinary property in the destination view controller. Then, when the view controller’s view is loaded, its viewDidLoad uses that property to configure its own outlet.

Here’s the right way to configure this in the destination view controller:

private @IBOutlet var noteDetailsOutlet : UILabel!
var noteDetailsText: String? {
    didSet {
        if let text = self.noteDetailsText { 
            self.noteDetailsOutlet?.text = text
        }
    }
}
override func viewDidLoad() {
    super.viewDidLoad()
    if let text = self.noteDetailsText { 
        self.noteDetailsOutlet?.text = text
    }
}

The value of this pattern is that no matter when noteDetailsText gets set — whether before or after viewDidLoad — it will be passed safely into noteDetailsOutlet.text. (Okay, there’s some repetition of code, but that’s an implementation detail; I’m sure you can see how to fix that.)

So do it like that! That pattern lets you keep your outlets private, and resolves the timing problem.


One final word. You may be tempted to cheat and force the view controller to load its view prematurely, like this:

func tableView(_ tableView: UITableView, 
    didSelectRowAt indexPath: IndexPath) {
        let vc = storyboard?.instantiateViewController(
            withIdentifier: "NoteDetailsViewController") as! NoteDetailsViewController
        vc.loadViewIfNeeded() // <-- the horror
        vc.noteDetailsOutlet.text = notes[indexPath.row]
        self.show(vc, sender: self)
}

Don’t do it. Just don’t. Thanks.