UITableView and UICollectionView: Data Sources, Reuse, and Scroll Performance
Understand the data source/delegate architecture behind UITableView and UICollectionView, how cell reuse and registration keep scrolling smooth, how NSIndexPath addresses content, and how diffable data sources, prefetching, and asynchronous image loading fit into building a performant, large-dataset list.
Almost every non-trivial iOS app shows the user a scrolling list or grid of content somewhere — a contact list, a news feed, a photo grid, a settings screen — and UITableView and UICollectionView are the two UIKit classes that make that possible without you writing your own scrolling and view-recycling logic from scratch. Both are built on the same fundamental architecture you first met in this course's Delegation and Protocols modules: a data source object that supplies content, and a delegate object that handles interaction, both connected to the table or collection view through formal protocols rather than subclassing. This lesson works through that architecture end to end — the data source and delegate protocols themselves, the cell-reuse mechanism that makes scrolling through thousands of rows performant, how cells get registered and dequeued, how NSIndexPath addresses a specific piece of content, the modern diffable-data-source approach to updating what's on screen, and the practical performance concerns — expensive work in cellForRowAtIndexPath:, asynchronous image loading with its reuse gotcha, and prefetching — that separate a list that scrolls smoothly from one that stutters.
Data Source: Supplying What to Display
UITableViewDataSource and UICollectionViewDataSource are the protocols responsible for telling a table or collection view *what* content exists and *how* to display each piece of it — nothing about interaction, only content. This is the exact same protocol-based pattern this course covered in its earlier Delegation and Protocols modules: the table view itself has no idea what your data actually is, and instead calls a small, formally defined set of methods on whatever object you've assigned to its dataSource property, trusting that object to answer honestly.
The two data source methods every table view needs at minimum are -tableView:numberOfRowsInSection:, which returns an integer telling the table view how many rows exist in a given section, and -tableView:cellForRowAtIndexPath:, which returns a fully configured UITableViewCell for a specific row. A table with more than one section also implements -numberOfSectionsInTableView: (it defaults to 1 if omitted). UICollectionViewDataSource mirrors this shape with -collectionView:numberOfItemsInSection: and -collectionView:cellForItemAtIndexPath:.
Because the data source is just an object conforming to a protocol, it doesn't have to be the view controller — in a larger app it's common to factor a table's data source out into its own small class, especially when the same list of underlying data needs to back more than one presentation, or when you want to unit-test the 'what goes in which row' logic independently of any view controller lifecycle at all. What matters is only that the object is set as tableView.dataSource and correctly implements the required methods; the table view itself never needs to know anything more about it than that.
Delegate: Handling Interaction and Layout Events
UITableViewDelegate and UICollectionViewDelegate are a separate protocol from the data source, and the split matters conceptually: the data source answers 'what content exists,' while the delegate answers 'what happens when the user interacts with it, and how should rows be sized or styled beyond their default appearance.' A common early mistake for someone new to the pattern is assuming one protocol implies the other — in fact the two are entirely independent, and many objects implement both simply because it's convenient for the view controller to be a natural home for both roles, not because the protocols require it.
The most commonly implemented delegate method is -tableView:didSelectRowAtIndexPath:, called when the user taps a row, which is where you'd typically push a detail screen or otherwise respond to the selection; the table view itself does not decide what tapping a row *means* to your app, only that a tap on a specific index path occurred. Sizing-related delegate methods like -tableView:heightForRowAtIndexPath: let you override a row's height per-index-path, though modern table views frequently rely on self-sizing cells (driven by Auto Layout's intrinsic content size, from the previous lesson) and the constant UITableViewAutomaticDimension instead of computing heights by hand.
UICollectionViewDelegate and its companion UICollectionViewDelegateFlowLayout follow the same split, adding grid-specific concerns a table view doesn't have — -collectionView:layout:sizeForItemAtIndexPath: for per-item sizing in a flow layout, and section-level inset and spacing methods — because a collection view's layout is a pluggable object in its own right (UICollectionViewFlowLayout being the common built-in choice), a concept UITableView doesn't need since its layout is always a single scrolling column.
Cell Reuse: The Core Performance Technique
The single most important performance idea behind both UITableView and UICollectionView is that they never create one cell view per row of data up front. A table showing ten thousand rows does not allocate ten thousand UITableViewCell instances — it allocates only enough cell views to cover what's visible on screen at once, plus a small buffer, and as you scroll, cells that scroll off one edge of the screen are not destroyed; they're recycled and handed back to you to reconfigure with new data for the row that's about to scroll onto the opposite edge.
This matters enormously for both memory and CPU. Creating and destroying a UITableViewCell (and its full subview hierarchy — labels, image views, Auto Layout constraints) on every single scroll frame would be prohibitively expensive; view creation, constraint setup, and memory allocation are all real work, and doing that work sixty times a second while a user flicks through a long list would visibly drop frames. Reuse turns an unbounded, data-size-proportional amount of view-creation work into a small, fixed, screen-size-proportional amount — a handful of cell instances are created once, near the start of scrolling, and then endlessly repainted with new content for the rest of the list's lifetime.
The mechanism that makes this safe and correct is the reuse identifier: every cell class or nib is registered under a string identifier, and when the table view needs a cell for a row, you ask it to dequeue one under that identifier rather than allocating a new one yourself. If an off-screen cell with that identifier is available for recycling, the table view hands it back to you already allocated (but still configured with whatever the *previous* row put into it) and it is entirely your responsibility, inside cellForRowAtIndexPath:, to overwrite every piece of that stale content with the new row's data — this is precisely why the asynchronous image loading section later in this lesson has a gotcha: any data you don't explicitly reset can leak visually from a previous row into the one currently being displayed.
Registration and Dequeuing
Before a table view can hand you a recycled cell for a given identifier, it needs to know how to create one from scratch the first few times, before there's anything to recycle — that's what registration does. -registerClass:forCellReuseIdentifier: tells the table view 'when you need a fresh cell under this identifier and have none to recycle, allocate one with +alloc]init] from this class,' while -registerNib:forCellReuseIdentifier: does the same but instantiates the cell from a XIB file instead of a plain class, for cells whose layout is built in Interface Builder rather than in code.
Both are typically called once, in -viewDidLoad, before the table view ever needs a cell. From that point on, cellForRowAtIndexPath: calls -dequeueReusableCellWithIdentifier:forIndexPath:, and the table view handles the rest transparently: if a recyclable cell under that identifier exists, it's returned; if not, one is created fresh using whatever was registered, and either way you get back a cell object ready to be configured — you never need to check which case happened, or write your own nil-check-and-allocate fallback logic, which is exactly what the forIndexPath: variant of the dequeue method guarantees over the older, non-indexPath version that could still return nil and required a manual if (cell == nil) { ... } fallback.
The reuse identifier itself is just a string you choose, and a single table view frequently registers more than one identifier when its rows come in visually distinct shapes — a 'header' style cell and a 'standard' style cell in the same list, say — dequeuing under whichever identifier matches the row currently being configured, based on whatever your data model says that row's kind should be.
Indexing: NSIndexPath
NSIndexPath is the small value object both table and collection views use to address a specific piece of content: it bundles a section integer and a row integer (called item when accessed through UICollectionView's convenience API, though the underlying object is the same class) into one value, passed into nearly every data source and delegate method so your code knows exactly which row or item is being asked about. You create one directly with +indexPathForRow:inSection: (or +indexPathForItem:inSection: for a collection view) when you need to refer to a specific cell yourself — for instance, to tell the table view to scroll to it, or to reload just that one row.
Treating an index path as a compound key rather than a single flat row number is what makes multi-section tables and collections straightforward: your data model can be organized as an array of section objects, each holding an array of its own rows, and indexPath.section/indexPath.row map directly onto 'which section array, then which element within it' without you ever having to compute a flattened offset by hand. Getting this indexing wrong — mixing up .section and .row, or assuming a flat array where the table view actually has multiple sections — is a common, if easy-to-spot-once-you-know-to-look, source of 'wrong row shows the wrong data' bugs.
Diffable Data Source Concepts: Animating Only What Changed
For years, updating a table view's visible content after the underlying data changed meant manually calling -beginUpdates, then some combination of -insertRowsAtIndexPaths:withRowAnimation:, -deleteRowsAtIndexPaths:withRowAnimation:, and -reloadRowsAtIndexPaths:withRowAnimation: for exactly the rows that changed, and finally -endUpdates to commit the batch — and, critically, you were responsible for making sure the index paths you passed to those calls matched the actual before/after shape of your data exactly, or the table view would throw an internal-consistency exception at runtime. Getting that bookkeeping right by hand, especially for a data set that could change in several ways at once (some rows added, some removed, some reordered), was a genuine, recurring source of bugs.
A diffable data source flips this around: instead of you calculating which specific inserts/deletes/reloads to issue, you hand the data source a 'snapshot' — a value describing the complete, current shape of your sections and identifiers-for-items — and it computes the difference between that snapshot and whatever it previously displayed, then performs and animates exactly the minimal set of insertions, deletions, and moves needed to get from the old state to the new one, entirely on your behalf. Conceptually this is the same idea as a source-control diff: you describe the 'after' state completely, and the system figures out the minimal edit script to get there from the 'before' state, rather than you hand-authoring that edit script yourself.
The practical benefit is real even setting animation aside: because you're never manually pairing up before/after index paths, an entire category of 'my insert/delete calls didn't match my data mutation and the table view crashed' bug simply cannot happen — the data source always derives its batch of changes correctly from the snapshot diff itself, since it's the one computing the diff rather than trusting you to have computed it correctly by hand.
Performance: What Not to Do in cellForRowAtIndexPath:
-tableView:cellForRowAtIndexPath: (and its collection view equivalent) is called extremely frequently — every time a cell scrolls onto screen, and potentially many times per second during fast scrolling as cells are dequeued and reconfigured one after another. Any work you do inside it runs on the main thread, synchronously, before that cell can be displayed, so anything slow in there is directly, visibly slow on screen: a noticeably dropped frame, a momentary stutter, or scrolling that simply feels less smooth than a well-optimized list.
The concrete things to avoid doing inside cellForRowAtIndexPath: are, broadly: synchronous network or disk I/O, expensive text layout or measurement that could be precomputed once and cached, decoding a large image synchronously on the main thread, and any heavyweight object allocation that could instead happen once at cell-registration time and be reused across dequeues. Configuration — assigning already-available strings and already-decoded images to existing label/image view properties — is cheap and belongs here; fetching or computing that data from scratch does not.
The general principle is to do as little work as possible, on the main thread, inside the method that runs on every scroll frame, and to push everything that can be done ahead of time — or off the main thread — out of it. The next two sections, asynchronous image loading and prefetching, are the two most common concrete techniques for doing exactly that.
Asynchronous Image Loading and Prefetching
Loading an image from the network or decoding a large image from disk is exactly the kind of expensive, blocking work cellForRowAtIndexPath: should never do synchronously — so the standard approach is to kick the load off asynchronously (on a background queue) and update the cell's image view later, on the main thread, once the image has finished loading. That sounds simple, but it runs directly into cell reuse: because cells are recycled, the specific UITableViewCell instance you started an async image load for might, by the time that load finishes, have already been dequeued again and reconfigured to display an entirely different row further down the list — and if your completion handler blindly sets cell.imageView.image = downloadedImage without checking anything, it can paint the wrong image onto the wrong (now-different) row, a subtle and very common real-world bug often described as 'stale image on scroll.'
The standard guard against this is to capture the index path (or some other identifier for which row the load was originally for) alongside the async request, and, in the completion handler, re-verify that the cell currently at that index path is still the same cell instance the load was started for (or, more simply, that the index path the load was for still matches what the table view currently expects at that row) before applying the image — if it doesn't match, the cell has been reused for something else in the meantime and the stale image should simply be discarded rather than applied. Tracking an in-flight load per cell (so a fast scroll doesn't leave several superseded loads racing to paint the same, now-wrong, cell) is the more complete version of the same guard.
Prefetching, through the UITableViewDataSourcePrefetching protocol (and its UICollectionView counterpart), goes a step further than just handling loads asynchronously when a cell becomes visible — it lets the table view tell you, in -tableView:prefetchRowsAtIndexPaths:, about rows that are *about to* scroll into view, ahead of time, based on the current scroll velocity and direction, so you can start their network or disk work early and have a much better chance of the data already being ready by the time the row's cell actually needs it. -tableView:cancelPrefetchingForRowsAtIndexPaths: is the corresponding cleanup call, letting you cancel a prefetch that's no longer needed because the user scrolled the other way before it arrived.
Accessibility: A Preview
Table and collection views come with a meaningful amount of accessibility behavior built in for free — VoiceOver, for instance, already understands how to navigate row by row and announce a cell's accessible content by combining its subviews' text in a sensible order — but a list of custom cells still needs some attention from you to be genuinely usable with assistive technologies: setting a clear, correctly-summarized accessibilityLabel on a cell whose visual layout spreads information across several labels and images that wouldn't otherwise read sensibly out loud in order, and making sure custom controls inside a cell (a button, a switch) are themselves properly exposed as distinct accessibility elements rather than swallowed into the cell's own summary.
This lesson only previews the topic at the level relevant to lists specifically — how cell reuse interacts with accessibility elements that need to be reset alongside everything else you reset in cellForRowAtIndexPath:, and the basic expectation that a reused cell's accessibility properties are just as much your responsibility to keep in sync with its current row as its visible text and images are. The dedicated Accessibility module later in this course covers the full breadth of the subject — VoiceOver, Dynamic Type, and the wider accessibility APIs — in the depth it deserves.
Registering, Dequeuing, and Indexing a Table View
Register once (viewDidLoad); dequeue on every call to cellForRowAtIndexPath:. dequeueReusableCellWithIdentifier:forIndexPath: always returns a usable cell — no manual nil-check fallback needed. Every piece of a dequeued cell's content must be explicitly overwritten with the current row's data, since it may carry a previous row's leftover state.
// Registration (typically in viewDidLoad):
[self.tableView registerClass:[UITableViewCell class]
forCellReuseIdentifier:@"CellID"];
// Data source:
- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section {
return self.items.count;
}
- (UITableViewCell *)tableView:(UITableView *)tableView
cellForRowAtIndexPath:(NSIndexPath *)indexPath {
UITableViewCell *cell =
[tableView dequeueReusableCellWithIdentifier:@"CellID" forIndexPath:indexPath];
cell.textLabel.text = self.items[indexPath.row];
return cell;
}
// Delegate:
- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath {
[tableView deselectRowAtIndexPath:indexPath animated:YES];
}Code Examples
This view controller adopts both UITableViewDataSource and UICollectionViewDelegate-style protocols in one class for convenience, but the two roles remain conceptually distinct: numberOfRowsInSection: and cellForRowAtIndexPath: answer 'what content exists,' while didSelectRowAtIndexPath: answers 'what happens on interaction.' Registration happens exactly once in viewDidLoad, and every subsequent cell request goes through dequeueReusableCellWithIdentifier:forIndexPath:, so no cell is ever allocated more than the small number needed to cover what's actually on screen.
UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"ContactCell" forIndexPath:indexPath];
The image view is reset to a placeholder immediately and synchronously, before any async work starts, so a recycled cell never briefly shows the previous row's stale photo while the new one loads. The actual load happens on a background queue, and the completion block hops back to the main queue to apply the result — but only after checking that tableView's current index path for cell still matches the indexPath the load was originally started for; if the cell has since been recycled for a different row, currentPath won't match and the (now-stale) downloaded image is silently discarded instead of painted onto the wrong row.
if (currentPath && [currentPath isEqual:indexPath]) { Common mistake: Setting cell.imageView.image = image inside the completion block without re-checking the cell's current index path — on a fast scroll, the cell can easily have been reused for a different row by the time the async load finishes.
Assigning prefetchDataSource opts the table view into calling prefetchRowsAtIndexPaths: for rows it predicts, from current scroll velocity and direction, are about to become visible — well before cellForRowAtIndexPath: is actually called for them. Starting the image load here means the data has a real head start, so by the time the corresponding cell is dequeued and configured, the image is often already loaded (or close to it) rather than starting from zero at the moment it's needed. The cancellation method is just as important: if the user reverses scroll direction before a prefetched row arrives, canceling avoids wasting bandwidth and CPU on a load that's no longer useful.
@interface ContactsViewController () <UITableViewDataSource, UITableViewDelegate>
@property (nonatomic, strong) NSArray<NSString *> *names;
@end
@implementation ContactsViewController
- (void)viewDidLoad {
[super viewDidLoad];
[self.tableView registerClass:[UITableViewCell class]
forCellReuseIdentifier:@"ContactCell"];
self.tableView.dataSource = self;
self.tableView.delegate = self;
}
- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section {
return self.names.count;
}
- (UITableViewCell *)tableView:(UITableView *)tableView
cellForRowAtIndexPath:(NSIndexPath *)indexPath {
UITableViewCell *cell =
[tableView dequeueReusableCellWithIdentifier:@"ContactCell" forIndexPath:indexPath];
cell.textLabel.text = self.names[indexPath.row];
return cell;
}
- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath {
NSLog(@"Selected: %@", self.names[indexPath.row]);
[tableView deselectRowAtIndexPath:indexPath animated:YES];
}
@end- (UITableViewCell *)tableView:(UITableView *)tableView
cellForRowAtIndexPath:(NSIndexPath *)indexPath {
UITableViewCell *cell =
[tableView dequeueReusableCellWithIdentifier:@"PhotoCell" forIndexPath:indexPath];
NSURL *imageURL = self.photoURLs[indexPath.row];
cell.imageView.image = self.placeholderImage; // always reset first — clears stale content
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
NSData *data = [NSData dataWithContentsOfURL:imageURL];
UIImage *image = [UIImage imageWithData:data];
dispatch_async(dispatch_get_main_queue(), ^{
// Guard: only apply the image if this cell is still showing the same row.
NSIndexPath *currentPath = [tableView indexPathForCell:cell];
if (currentPath && [currentPath isEqual:indexPath]) {
cell.imageView.image = image;
}
});
});
return cell;
}@interface FeedViewController () <UITableViewDataSourcePrefetching>
@end
@implementation FeedViewController
- (void)viewDidLoad {
[super viewDidLoad];
self.tableView.prefetchDataSource = self;
}
- (void)tableView:(UITableView *)tableView
prefetchRowsAtIndexPaths:(NSArray<NSIndexPath *> *)indexPaths {
for (NSIndexPath *indexPath in indexPaths) {
NSURL *url = self.photoURLs[indexPath.row];
[self.imageLoader startPrefetchingURL:url]; // begins the load early, off the main thread
}
}
- (void)tableView:(UITableView *)tableView
cancelPrefetchingForRowsAtIndexPaths:(NSArray<NSIndexPath *> *)indexPaths {
for (NSIndexPath *indexPath in indexPaths) {
NSURL *url = self.photoURLs[indexPath.row];
[self.imageLoader cancelPrefetchingURL:url];
}
}
@endLists in Production Apps
Nearly every major app you use daily — a mail client, a social feed, a music library, a messaging app's conversation list — is, underneath its visual polish, a UITableView or UICollectionView backed by exactly this architecture: a data source, a delegate, registered and dequeued reusable cells, and (in most modern codebases) a diffable data source managing updates as new content arrives. The performance techniques in this lesson are not academic — a feed that stutters while scrolling through photos, or that briefly flashes the wrong thumbnail on a fast scroll, is almost always tracing back to exactly the two gotchas covered here: expensive synchronous work inside cellForRowAtIndexPath:, and an unguarded asynchronous image load racing against cell reuse.
Applying an asynchronously loaded image to a cell without checking whether that cell has since been recycled for a different row.
Capture the index path the load was started for, and in the completion handler re-check that the cell's current index path still matches before applying the image.
Because cells are reused, an async load started for row 3 can easily complete after that same cell instance has been recycled and reconfigured for row 40 — applying the image unconditionally paints stale content onto the wrong row.
Doing synchronous network calls, disk reads, or expensive image decoding directly inside tableView:cellForRowAtIndexPath:.
Configure the cell with already-available data synchronously, and kick off any expensive work asynchronously, updating the cell later with a reuse guard.
cellForRowAtIndexPath: runs on the main thread and is called extremely frequently during scrolling; any slow synchronous work inside it directly causes dropped frames and visible stutter.
Forgetting to reset a cell's content (image, accessory views, background color) that a previous row's configuration set, assuming a freshly dequeued cell starts blank.
Explicitly set every piece of a cell's visible state in cellForRowAtIndexPath:, including resetting anything that isn't relevant to the current row back to a known default.
A dequeued cell can carry over whatever the previous row configured it with; anything you don't overwrite can visibly leak from one row into another as the user scrolls.
Manually calling insertRowsAtIndexPaths:/deleteRowsAtIndexPaths: with index paths that don't precisely match the actual before/after shape of the underlying data.
Either compute the exact matching index paths by hand with great care, or use a diffable data source and hand it a snapshot, letting it compute the correct minimal diff itself.
A mismatch between the index paths passed to a batch update and the data's real change throws an internal-consistency exception at runtime; a diffable data source removes this entire class of bug by deriving the update from a snapshot diff instead of trusting hand-computed index paths.
What is the fundamental difference between UITableViewDataSource and UITableViewDelegate? A. There is no real difference; both protocols are interchangeable B. The data source supplies content (row counts, configured cells); the delegate handles interaction and layout events like selection C. The delegate is required, while the data source is entirely optional D. The data source only applies to UICollectionView, never to UITableView
B. The data source supplies content (row counts, configured cells); the delegate handles interaction and layout events like selection — The data source answers 'what content exists and how is each cell configured,' while the delegate answers 'what happens when the user interacts, and how should specific rows be sized' — two independent protocols an object can adopt separately.
Why does cell reuse matter for performance on a table with thousands of rows? A. It reduces network usage by caching every row's data permanently in memory B. It avoids creating and destroying a full cell view hierarchy for every row; only a small, screen-size-proportional number of cells are ever allocated C. It disables Auto Layout on cells to speed up rendering D. It has no performance effect; it exists only to simplify the data source's code
B. It avoids creating and destroying a full cell view hierarchy for every row; only a small, screen-size-proportional number of cells are ever allocated — Without reuse, scrolling would require allocating a brand-new cell view hierarchy for every row that scrolls into view; reuse recycles a small, fixed number of off-screen cells instead, turning an unbounded cost into one proportional to screen size, not data size.
What does registerClass:forCellReuseIdentifier: (or registerNib:forCellReuseIdentifier:) accomplish? A. It immediately creates every cell the table view will ever need B. It tells the table view how to create a fresh cell under a given identifier when none is available to recycle C. It permanently disables cell reuse for that identifier D. It is required only when using Interface Builder, never when building cells entirely in code
B. It tells the table view how to create a fresh cell under a given identifier when none is available to recycle — Registration supplies the recipe (a class or a nib) the table view falls back to when dequeueReusableCellWithIdentifier:forIndexPath: has no recyclable cell available; from then on, dequeuing transparently returns either a recycled cell or a freshly created one.
What two integers does an NSIndexPath bundle together for table/collection view use? A. A row (or item) and a section B. An x and y pixel coordinate C. A width and a height D. A priority and a constant, as used by NSLayoutConstraint
A. A row (or item) and a section — NSIndexPath combines a section and a row (item, for collection views) into a single compound key, letting a multi-section data model be addressed directly without manually flattening it into one array.
What problem does a diffable data source solve compared to manual beginUpdates/endUpdates batch calls? A. It makes cell reuse unnecessary B. It removes the need for a delegate object entirely C. It removes the need to manually compute and pass matching before/after index paths, deriving the correct minimal update from a snapshot diff instead D. It eliminates the need for a reuse identifier when dequeuing cells
C. It removes the need to manually compute and pass matching before/after index paths, deriving the correct minimal update from a snapshot diff instead — With manual batch updates, you must compute index paths for inserts/deletes/reloads that exactly match your data's real change or the table view throws an exception; a diffable data source instead computes that diff itself from an old/new snapshot comparison.
An asynchronously loaded image is applied to a cell without any reuse guard, and the app occasionally shows the wrong photo on a fast scroll. What is the most likely cause? A. The image failed to decode correctly B. The cell that started the load has since been recycled and reconfigured for a different row before the load's completion handler ran C. UITableView does not support asynchronous image loading at all D. The table view's delegate was never assigned
B. The cell that started the load has since been recycled and reconfigured for a different row before the load's completion handler ran — Because cells are reused, the specific cell instance an async load was started for can be dequeued again for a different row before that load finishes; without checking the cell's current index path in the completion handler, the (now stale) image gets applied to whatever row the cell currently represents.
Write the tableView:cellForRowAtIndexPath: implementation for a table showing NSDictionary objects (each with a "title" NSString and an "imageURL" NSURL), using a registered reuse identifier, that resets the cell's image to a placeholder immediately and loads the real image asynchronously with a guard against cell reuse.
UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"ItemCell" forIndexPath:indexPath]; NSDictionary *item = self.items[indexPath.row]; cell.textLabel.text = item[@"title"]; cell.imageView.image = self.placeholder; NSURL *url = item[@"imageURL"]; dispatch_async(dispatch_get_global_queue(0,0), ^{ NSData *data = [NSData dataWithContentsOfURL:url]; UIImage *img = [UIImage imageWithData:data]; dispatch_async(dispatch_get_main_queue(), ^{ if ([[tableView indexPathForCell:cell] isEqual:indexPath]) { cell.imageView.image = img; } }); }); return cell;
Dequeue the cell first and set its title synchronously; set the image view to a placeholder before starting any async work; in the async completion block, compare the table view's current indexPathForCell: against the indexPath the load was started for before applying the loaded image.