The View Controller Lifecycle: From Creation to Deallocation

Understand each stage of a UIViewController's lifecycle — creation, loadView, viewDidLoad, viewWillAppear, viewDidAppear, viewWillDisappear, viewDidDisappear, and deallocation — and know precisely what kind of work belongs at each stage.

The previous lesson introduced UIViewController as the object responsible for managing a screen, and mentioned that its lifecycle would be covered in depth here. That lifecycle is one of the most important things to internalize about UIKit development, because getting it wrong — doing the wrong work at the wrong stage — is one of the most common sources of bugs in real Objective-C UIKit code: broken layouts, duplicate observers, wasted work repeated unnecessarily, or state that silently goes stale. This lesson walks through the full sequence a view controller passes through, in order, from the moment it's created to the moment it's deallocated, with a clear statement of what belongs — and what does not belong — at each individual stage.

The Full Sequence, at a Glance

A view controller's lifecycle unfolds as a predictable, ordered sequence of events. It begins with creation — the view controller object itself is instantiated and initialized, before its view exists at all. From there, when the view is first needed, loadView is called to build the view hierarchy, immediately followed by viewDidLoad, called exactly once, after the view hierarchy exists in memory. From that point on, viewWillAppear and viewDidAppear fire every time the view controller's view is about to become, and has become, visible on screen — which can happen more than once over a view controller's lifetime, for instance if the user navigates away and back. Symmetrically, viewWillDisappear and viewDidDisappear fire every time the view is about to stop being visible, and has stopped being visible. Eventually, when nothing holds a strong reference to the view controller any longer, it is deallocated.

The diagram below lays this sequence out exactly as the appear/disappear pairing and the surrounding creation and deallocation bookends occur. Notice the visual symmetry: loadView and viewDidLoad happen once, near the top, while the four 'appear'/'disappear' methods form two matched pairs that can each fire repeatedly over the view controller's life, before the whole sequence eventually ends at deallocation.

Creation: Initial State, No View Yet

A view controller's lifecycle begins the moment it is instantiated — typically via init, initWithNibName:bundle:, or (if loaded from a storyboard) the storyboard's own instantiation machinery. At this point, the view controller object exists as a plain Objective-C object, but critically, its view does not exist yet. Accessing self.view at this stage would actually trigger the view to load early (UIKit's .view property is lazy — reading it loads the view on demand if it hasn't loaded already), which is rarely what you want this early, and is itself listed among this lesson's common mistakes.

The right work for this stage is setting up the view controller's own initial state — instance variables, injected dependencies (a data model object passed in through a custom initializer, for example), or configuration flags — none of which require the view to exist yet. Think of this stage as preparing the view controller to eventually build its view, not as touching the view itself in any way.

loadView: Building the View Hierarchy

loadView is the method responsible for actually creating the view controller's root view and assigning it to self.view. When a view controller is loaded from a storyboard or a nib (Interface Builder's older, single-screen file format), UIKit's default loadView implementation handles this for you automatically by inflating the storyboard or nib content, and you will rarely need to override it in that case.

You override loadView yourself only when building a view hierarchy entirely in code, with no storyboard or nib backing that particular view controller — inside your override, you construct a UIView (or a custom UIView subclass) and assign it directly to self.view, without calling super. This is a less common pattern in modern app development, where storyboards, nibs, and, increasingly, SwiftUI have taken over most of this work, but it remains important to understand, both because it explains exactly where the view hierarchy actually comes from, and because you'll still encounter code written this way in existing Objective-C projects.

viewDidLoad: One-Time Setup After the View Hierarchy Exists

viewDidLoad is called exactly once, immediately after the view hierarchy has been loaded into memory — whether that hierarchy came from loadView, a storyboard, or a nib. This is, by far, the most commonly overridden lifecycle method, and it is the standard place for one-time setup: configuring subviews you've already added (setting a label's initial text, styling a button), setting up table view or collection view data sources and delegates, registering cell classes, and registering for notifications that should persist for the view controller's entire life.

The one thing viewDidLoad is explicitly NOT yet safe for is any work that depends on the view's final layout or geometry. At the point viewDidLoad runs, views have been created and added to the hierarchy, but Auto Layout has not necessarily finished computing final frames — a view's bounds read inside viewDidLoad may not reflect where it will actually end up once layout completes, particularly on a device rotation or a screen size the storyboard's canvas doesn't exactly match. Work that genuinely needs final, laid-out geometry belongs in viewDidLayoutSubviews instead, a related lifecycle method this lesson mentions only briefly as the correct alternative for that specific case.

viewWillAppear and viewDidAppear: Every Time the Screen Appears

Unlike viewDidLoad, which runs exactly once, viewWillAppear: and viewDidAppear: run every single time the view controller's view is about to become, and has become, visible on screen. This distinction matters enormously: if a user navigates from screen A to screen B and then back to screen A, screen A's viewWillAppear:/viewDidAppear: fire again on that return trip, but its viewDidLoad does not — the view hierarchy was already loaded the first time and doesn't need to be rebuilt.

This makes viewWillAppear: and viewDidAppear: the right place for work that should happen every time the screen becomes visible, not just the first time: refreshing data that may have changed while the user was on a different screen, starting an animation that should play whenever the screen appears, or registering time-sensitive observers (like a timer, or a notification observer specifically tied to this screen being on-screen) that should only be active while this screen is actually visible. viewWillAppear: fires just before the view becomes visible (useful for state that should be set up right away, before the user sees anything), while viewDidAppear: fires just after (useful for anything that should only start once the screen is fully, actually on screen — starting a camera preview, for instance).

viewWillDisappear and viewDidDisappear: Symmetric Teardown

viewWillDisappear: and viewDidDisappear: are the symmetric counterparts to the appear methods, firing just before and just after the view controller's view stops being visible — whether because the user navigated to another screen, dismissed a modally presented view controller, or the app itself moved to the background. Anything started in viewWillAppear:/viewDidAppear: that shouldn't keep running while the screen is off-screen belongs here as its teardown: pausing a timer, stopping an animation, saving in-progress state, or — critically — unregistering any observer that was registered in the corresponding appear method.

The pairing here is deliberate and important: whatever you set up in viewWillAppear:/viewDidAppear: should generally be torn down in viewWillDisappear:/viewDidDisappear:, so that the resource (an observer, a timer, a running animation) is active only while the screen is genuinely visible, and cleanly stopped the moment it isn't. Getting this pairing wrong — registering without a matching removal — is common enough to deserve its own dedicated mention in this lesson's mistakes section below.

Deallocation: Final Cleanup

Eventually, once nothing holds a strong reference to a view controller any longer — the user has navigated away from it permanently and it's been popped off a navigation stack, dismissed, or otherwise released — ARC deallocates it, triggering its dealloc method, exactly as this course's earlier memory-management modules covered for any Objective-C object. dealloc is the very last point in a view controller's lifecycle, and the right work here is final cleanup: releasing any manually-managed resources, and as a safety net, removing any observers you might not already have removed in viewDidDisappear:.

Refer back to this course's earlier memory-management lessons for the full mechanics of dealloc and ARC — the same rules that apply to any Objective-C object's deallocation apply here without modification. What's specific to the view controller lifecycle is simply recognizing dealloc as the true final stage, after every possible appear/disappear cycle has already happened, closing out the sequence the diagram in this lesson lays out.

Common Mistakes Around the Lifecycle

The single most common lifecycle mistake is doing layout-dependent work inside viewDidLoad, before the view's geometry is final. Reading a subview's bounds to do custom drawing or precise positioning inside viewDidLoad can use stale or default geometry, producing a layout that looks correct in one configuration (say, a particular device size in a storyboard preview) but wrong in another. The fix is almost always to move that specific work into viewDidLayoutSubviews, which UIKit calls whenever layout has actually been finalized — including after rotation or any other geometry change — rather than only once at view-load time.

The second common mistake is an asymmetry between the appear and disappear methods: registering an observer (with NSNotificationCenter, for instance) inside viewDidAppear: without a matching removal inside viewDidDisappear:. Because viewDidAppear: can fire repeatedly — every time the user navigates back to this screen — an unpaired registration means the view controller ends up registered as an observer multiple times over, and when the observed event eventually fires, the corresponding handler method runs once for every leftover registration, not once as intended. This produces subtle, hard-to-diagnose bugs like a notification handler firing two, three, or more times after the user has simply navigated to and from the screen a few times.

Code Examples

This viewDidLoad override does exactly the kind of work that belongs at this stage: creating and styling a subview once, and setting a value (the navigation title) that doesn't need to be refreshed every time the screen appears. Because viewDidLoad only runs once per view controller instance, this setup happens exactly once, no matter how many times the user later navigates to and away from this screen.

self.nameLabel = [[UILabel alloc] initWithFrame:CGRectMake(20, 100, 280, 30)];

This example shows the correct pairing this lesson emphasizes: the observer registered in viewDidAppear: is removed in the matching viewDidDisappear:. Because this pairing is symmetric, the view controller is registered as an observer exactly once for every period it's actually on screen, no matter how many times the user navigates to and away from it — avoiding the duplicate-registration mistake described below.

[[NSNotificationCenter defaultCenter] removeObserver:self name:@"DataUpdatedNotification" object:nil];

Common mistake: Omitting the removeObserver: call in viewDidDisappear: (or putting the addObserver: call in viewDidLoad instead, where it only runs once but then can never be cleanly paired with a per-appearance removal) leads to duplicate registrations and a handler that fires multiple times per notification after repeated navigation.

This example contrasts viewDidLoad (where the badge view is created and added, but its final size is not yet guaranteed) with viewDidLayoutSubviews, called whenever Auto Layout has finished computing final geometry, including after rotation. Computing the corner radius as half the badge's height — a common way to make a view perfectly circular — genuinely needs the view's final bounds, which is exactly the kind of work this lesson's mistakes section flags as wrong to do inside viewDidLoad. viewDidLayoutSubviews can be called multiple times, so the badgeLayerConfigured guard here prevents redundant recomputation of a value that, in this particular example, doesn't need to change after the first correct layout pass.

self.badge.layer.cornerRadius = self.badge.bounds.size.height / 2.0;

@interface ProfileViewController : UIViewController
@property (nonatomic, strong) UILabel *nameLabel;
@end

@implementation ProfileViewController

- (void)viewDidLoad {
    [super viewDidLoad];

    // One-time setup: configuring a subview, style that never changes again.
    self.nameLabel = [[UILabel alloc] initWithFrame:CGRectMake(20, 100, 280, 30)];
    self.nameLabel.font = [UIFont boldSystemFontOfSize:18];
    [self.view addSubview:self.nameLabel];

    // Registering a data source once is appropriate here.
    self.title = @"Profile";
}

@end
- (void)viewDidAppear:(BOOL)animated {
    [super viewDidAppear:animated];

    [[NSNotificationCenter defaultCenter] addObserver:self
                                              selector:@selector(handleDataUpdated:)
                                                  name:@"DataUpdatedNotification"
                                                object:nil];
    [self refreshData];
}

- (void)viewDidDisappear:(BOOL)animated {
    [super viewDidDisappear:animated];

    [[NSNotificationCenter defaultCenter] removeObserver:self
                                                      name:@"DataUpdatedNotification"
                                                    object:nil];
}

- (void)handleDataUpdated:(NSNotification *)notification {
    [self refreshData];
}
@interface BadgeView : UIView
@end

@implementation BadgeView
@end

@interface DashboardViewController : UIViewController
@property (nonatomic, strong) BadgeView *badge;
@property (nonatomic, assign) BOOL badgeLayerConfigured;
@end

@implementation DashboardViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    self.badge = [[BadgeView alloc] init];
    [self.view addSubview:self.badge];
    // NOTE: self.badge.bounds is not yet final here — do not read it for
    // layout-dependent drawing in viewDidLoad.
}

- (void)viewDidLayoutSubviews {
    [super viewDidLayoutSubviews];

    if (!self.badgeLayerConfigured) {
        // Safe here: bounds reflects the badge's actual, final size.
        self.badge.layer.cornerRadius = self.badge.bounds.size.height / 2.0;
        self.badgeLayerConfigured = YES;
    }
}

@end

The UIViewController Lifecycle

Creation, loadView, and viewDidLoad each happen once. viewWillAppear/viewDidAppear and viewWillDisappear/viewDidDisappear can each recur many times as the user navigates to and from the screen, before the sequence eventually ends at deallocation.

Computing a view's final on-screen size or position inside viewDidLoad.

Move geometry-dependent work into viewDidLayoutSubviews, which UIKit calls once Auto Layout has actually finished computing final frames, including after rotation or other size changes.

At the point viewDidLoad runs, the view hierarchy exists but has not necessarily been through a full layout pass, so bounds and frame values read there can be stale, default, or otherwise not representative of the view's true final geometry.

Registering an observer in viewDidAppear: with no corresponding removeObserver: call in viewDidDisappear:.

Always pair an addObserver: call made in an 'appear' method with a matching removeObserver: call in the corresponding 'disappear' method, so the registration count stays exactly one for each period the screen is actually visible.

Because viewDidAppear: can fire many times over a view controller's life, an unpaired registration accumulates duplicate registrations, and the observer's handler method then fires once per leftover registration instead of once per actual event.

Reading self.view inside a custom initializer or immediately after alloc/init, before the view controller is actually about to be displayed.

Avoid accessing self.view until it's actually needed (typically once the system is about to display the view controller); if early access is unavoidable, be aware it triggers loadView immediately, which may be premature.

UIKit's .view property lazily triggers loadView the first time it's read, so touching self.view too early — before other setup that init or configuration steps expect to run first — can build the view hierarchy in an unintended order relative to the rest of your setup code.

Assuming viewDidLoad runs every time a screen becomes visible, the same way viewWillAppear: and viewDidAppear: do.

Remember viewDidLoad runs exactly once per view controller instance, while viewWillAppear:/viewDidAppear: run every time the screen becomes visible, including on repeat visits after navigating away and back.

Placing 'refresh this data every time the user sees this screen' logic inside viewDidLoad means it only ever runs the first time, silently failing to refresh on subsequent visits — a bug that's easy to miss during initial testing if you only ever view the screen once per app launch.

Put the following in correct lifecycle order: viewDidLoad, Creation, viewWillAppear, loadView. A. viewDidLoad, Creation, loadView, viewWillAppear B. Creation, loadView, viewDidLoad, viewWillAppear C. loadView, Creation, viewDidLoad, viewWillAppear D. Creation, viewDidLoad, loadView, viewWillAppear

B. Creation, loadView, viewDidLoad, viewWillAppear — The lifecycle proceeds Creation → loadView → viewDidLoad → viewWillAppear, exactly as the lesson's diagram lays out: the view controller is created first, then its view is built (loadView), then one-time setup runs (viewDidLoad), then it's about to become visible (viewWillAppear).

Which lifecycle method runs exactly once per view controller instance, no matter how many times the user later navigates to and from that screen? A. viewWillAppear B. viewDidAppear C. viewDidLoad D. viewWillDisappear

C. viewDidLoad — viewDidLoad runs exactly once, right after the view hierarchy is first loaded into memory. viewWillAppear, viewDidAppear, viewWillDisappear, and viewDidDisappear can each fire repeatedly across the view controller's life.

Why is reading a view's bounds inside viewDidLoad potentially unsafe for layout-dependent calculations? A. Because viewDidLoad never has access to self.view at all B. Because Auto Layout has not necessarily finished computing final geometry at that point, so bounds may not reflect the view's true final size C. Because bounds is only available after the view controller is deallocated D. Because viewDidLoad runs on a background thread

B. Because Auto Layout has not necessarily finished computing final geometry at that point, so bounds may not reflect the view's true final size — The view hierarchy exists by viewDidLoad, but a full Auto Layout pass has not necessarily completed, so geometry-dependent work should generally happen in viewDidLayoutSubviews instead, once final frames are actually settled.

An observer is registered in viewDidAppear: but never removed in viewDidDisappear:. What is the likely symptom after the user navigates to and from that screen three times? A. The app crashes immediately on the first navigation B. The observer's handler method fires multiple times for a single notification, once per leftover registration C. Nothing — NSNotificationCenter automatically deduplicates observers D. The view controller fails to deallocate but otherwise behaves normally

B. The observer's handler method fires multiple times for a single notification, once per leftover registration — Each unpaired addObserver: call adds another registration for the same object and notification name, so after three appearances, the handler method runs three times for a single posted notification — exactly the duplicate-registration mistake this lesson highlights.

What kind of work is explicitly appropriate to place in viewWillAppear:/viewDidAppear: rather than viewDidLoad? A. Work that should happen exactly once, the very first time the view is created B. Work that should happen every time the screen is about to become, or has become, visible — such as refreshing data or starting a screen-specific timer C. Building the view hierarchy from scratch D. Final resource cleanup before the object is destroyed

B. Work that should happen every time the screen is about to become, or has become, visible — such as refreshing data or starting a screen-specific timer — viewWillAppear:/viewDidAppear: are the right place for per-appearance work — refreshing data, starting an animation or timer — precisely because, unlike viewDidLoad, they run every time the screen becomes visible again, not just once.

Which lifecycle stage is the appropriate place for final cleanup, and relies on the same rules covered in this course's earlier memory-management lessons? A. loadView B. viewWillDisappear C. Deallocation (dealloc) D. viewDidLoad

C. Deallocation (dealloc) — Deallocation — dealloc — is the true final stage of the lifecycle, after every possible appear/disappear cycle, and follows the same ARC-based rules this course's memory-management modules already covered for any Objective-C object.

Creation
  ↓
loadView
  ↓
viewDidLoad
  ↓
viewWillAppear
  ↓
viewDidAppear
  ↓
viewWillDisappear
  ↓
viewDidDisappear
  ↓
Deallocation