How to Set Up UITableView in Swift (Storyboard & Code)

Written by

in

Quick answer: To display data in a UITableView in Swift, register a cell class or nib with a reuse identifier, assign your view controller as the table view’s dataSource and delegate, and implement numberOfRowsInSection and cellForRowAt. Use dequeueReusableCell(withIdentifier:for:) inside cellForRowAt to recycle cells efficiently as the user scrolls.

Why this happens

Setting up a UITableView from scratch is easy to get wrong because UIKit separates the view from the data and interaction layers. Unlike a simple UILabel or UIStackView, a table view does not store your model array directly. Instead, it asks a UITableViewDataSource how many rows to render and requests each cell on demand as the user scrolls.

If you forget any one of the required steps—connecting the IBOutlet, setting dataSource and delegate, or registering a cell reuse identifier—the table view either renders an empty white screen or crashes at runtime with NSInternalInconsistencyException when attempting to dequeue an unregistered cell.

To keep scrolling smooth even with thousands of rows, UITableView recycles off-screen cells rather than allocating a new UITableViewCell for every item in your array. Once you wire up registration, delegation, and dequeuing together, the pattern stays consistent across every UIKit list screen.

How to fix it

1. Set up UITableView with a Storyboard and IBOutlet

If your project uses Storyboards, add a UITableView to your scene in Interface Builder and connect it to an @IBOutlet in your UIViewController.

  1. Open your Storyboard, drag a Table View from the Object Library onto your view controller, and pin its four edges to the safe area or superview using Auto Layout constraints.
  2. Control-drag from the Table View in the canvas to your ViewController.swift file to create @IBOutlet weak var tableView: UITableView!.
  3. Adopt UITableViewDataSource and UITableViewDelegate, register UITableViewCell.self, and implement the required methods:
import UIKit

class ViewController: UIViewController, UITableViewDataSource, UITableViewDelegate {

    @IBOutlet weak var tableView: UITableView!

    private let animals = ["Horse", "Cow", "Camel", "Sheep", "Goat"]
    private let cellReuseIdentifier = "AnimalCell"

    override func viewDidLoad() {
        super.viewDidLoad()

        tableView.register(UITableViewCell.self, forCellReuseIdentifier: cellReuseIdentifier)
        tableView.dataSource = self
        tableView.delegate = self
    }

    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return animals.count
    }

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: cellReuseIdentifier, for: indexPath)

        var content = cell.defaultContentConfiguration()
        content.text = animals[indexPath.row]
        cell.contentConfiguration = content

        return cell
    }

    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        tableView.deselectRow(at: indexPath, animated: true)
        print("Tapped row \(indexPath.row): \(animals[indexPath.row])")
    }
}

Using dequeueReusableCell(withIdentifier:for:) returns a non-optional UITableViewCell, so you never need force-casting (as!) or optional unwrapping. Calling defaultContentConfiguration() (available since iOS 14) is the modern replacement for cell.textLabel?.text.

2. Create UITableView programmatically without Storyboards

If you build your UI in code, instantiate UITableView as a property, disable translatesAutoresizingMaskIntoConstraints, add it to view, and pin it with Auto Layout constraints.

import UIKit

class ViewController: UIViewController, UITableViewDataSource, UITableViewDelegate {

    private let tableView = UITableView(frame: .zero, style: .plain)
    private let animals = ["Horse", "Cow", "Camel", "Sheep", "Goat"]
    private let cellReuseIdentifier = "AnimalCell"

    override func viewDidLoad() {
        super.viewDidLoad()
        setupTableView()
    }

    private func setupTableView() {
        tableView.translatesAutoresizingMaskIntoConstraints = false
        tableView.register(UITableViewCell.self, forCellReuseIdentifier: cellReuseIdentifier)
        tableView.dataSource = self
        tableView.delegate = self

        view.addSubview(tableView)

        NSLayoutConstraint.activate([
            tableView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
            tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
        ])
    }

    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return animals.count
    }

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: cellReuseIdentifier, for: indexPath)

        var content = cell.defaultContentConfiguration()
        content.text = animals[indexPath.row]
        cell.contentConfiguration = content

        return cell
    }

    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        tableView.deselectRow(at: indexPath, animated: true)
        print("Tapped row \(indexPath.row): \(animals[indexPath.row])")
    }
}

Prefer Auto Layout constraints over hardcoding CGRect frames so the table view adapts cleanly across iPhone and iPad screen sizes and device rotations.

3. Use UITableViewDiffableDataSource for dynamic lists

If your list updates frequently—for example, when filtering search results or fetching paginated API data—calling tableView.reloadData() can feel abrupt, while manual insertRows and deleteRows calls often trigger index-mismatch crashes. For iOS 13 and later, UITableViewDiffableDataSource computes row diffs and animations automatically.

import UIKit

class ViewController: UIViewController, UITableViewDelegate {

    private enum Section {
        case main
    }

    private let tableView = UITableView(frame: .zero, style: .plain)
    private let cellReuseIdentifier = "AnimalCell"
    private var dataSource: UITableViewDiffableDataSource<Section, String>!

    override func viewDidLoad() {
        super.viewDidLoad()
        setupTableView()
        configureDataSource()
        applySnapshot(animals: ["Horse", "Cow", "Camel", "Sheep", "Goat"])
    }

    private func setupTableView() {
        tableView.translatesAutoresizingMaskIntoConstraints = false
        tableView.register(UITableViewCell.self, forCellReuseIdentifier: cellReuseIdentifier)
        tableView.delegate = self

        view.addSubview(tableView)
        NSLayoutConstraint.activate([
            tableView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
            tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
        ])
    }

    private func configureDataSource() {
        dataSource = UITableViewDiffableDataSource<Section, String>(tableView: tableView) { tableView, indexPath, animal in
            let cell = tableView.dequeueReusableCell(withIdentifier: self.cellReuseIdentifier, for: indexPath)
            var content = cell.defaultContentConfiguration()
            content.text = animal
            cell.contentConfiguration = content
            return cell
        }
    }

    private func applySnapshot(animals: [String]) {
        var snapshot = NSDiffableDataSourceSnapshot<Section, String>()
        snapshot.appendSections([.main])
        snapshot.appendItems(animals)
        dataSource.apply(snapshot, animatingDifferences: true)
    }

    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        tableView.deselectRow(at: indexPath, animated: true)
        if let animal = dataSource.itemIdentifier(for: indexPath) {
            print("Tapped: \(animal)")
        }
    }
}

Note that items used with UITableViewDiffableDataSource must conform to Hashable and be unique within the snapshot.

How to prevent it

  • Keep reuse identifiers in a constant: Store your cell identifier in a static let or private let string so register(_:forCellReuseIdentifier:) and dequeueReusableCell(withIdentifier:for:) never diverge due to a typo.
  • Always use dequeueReusableCell(withIdentifier:for:): Passing for: indexPath guarantees a non-optional cell and catches missing cell registrations immediately during testing.
  • Verify Storyboard connections: When using Storyboards, check the Connections Inspector to confirm tableView is linked and there are no leftover broken outlets.
  • Deselect rows on tap: Call tableView.deselectRow(at: indexPath, animated: true) inside didSelectRowAt so rows do not stay highlighted after navigation.

FAQ

Why is my UITableView blank even though my data array is not empty?

A blank UITableView usually means either tableView.dataSource = self was omitted or the table view’s Auto Layout constraints give it a zero width or height. Check that both dataSource is assigned and the view has valid frame constraints.

Should I use UITableViewController or UIViewController with a UITableView?

Use UITableViewController when the entire screen is a single scrolling list and you want automatic keyboard-avoidance and pull-to-refresh support. Use a standard UIViewController with an embedded UITableView when you need additional pinned headers, footers, or floating buttons outside the scrollable area.

Why does cell.textLabel show a deprecation warning in Xcode?

Apple deprecated textLabel, detailTextLabel, and imageView on UITableViewCell starting in iOS 14 in favor of UIListContentConfiguration. Call var content = cell.defaultContentConfiguration(), set content.text, and assign it back to cell.contentConfiguration to silence the warning.

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.