Objective-C is not the language I would choose for a new iOS app in 2026.
For new Apple development, Swift is the normal answer. Swift is cleaner, easier to read, easier to hire for, and much better aligned with modern Apple APIs, SwiftUI, async/await, and current Xcode templates.
But Objective-C is still not dead.
It survives because a lot of real Apple software was written before Swift became the default. It also survives because Objective-C is not just syntax. It is a runtime model, a messaging system, and a compatibility layer that still appears in old apps, SDKs, crash logs, AppKit/UIKit patterns, and reverse engineering workflows.
So the practical answer is this:
Do not learn Objective-C first if your goal is modern iOS development. Learn Swift first.
But if you work with real production iOS/macOS apps, legacy code, QA automation, mobile security, SDKs, or reverse engineering, you should understand enough Objective-C to read it and debug it.
Not worship it.
Read it. Recognize it. Survive it.
What Objective-C Code Actually Looks Like
Objective-C usually has two files:
UserSession.h
UserSession.m
The .h file is the public interface:
// UserSession.h
#import <Foundation/Foundation.h>
@interface UserSession : NSObject
@property (nonatomic, copy) NSString *userId;
@property (nonatomic, assign, getter=isLoggedIn) BOOL loggedIn;
- (instancetype)initWithUserId:(NSString *)userId;
- (void)logout;
@end
The .m file is the implementation:
// UserSession.m
#import "UserSession.h"
@implementation UserSession
- (instancetype)initWithUserId:(NSString *)userId {
self = [super init];
if (self) {
_userId = [userId copy];
_loggedIn = YES;
}
return self;
}
- (void)logout {
self.userId = nil;
self.loggedIn = NO;
}
@end
Then you use it like this:
UserSession *session = [[UserSession alloc] initWithUserId:@"12345"];
if ([session isLoggedIn]) {
NSLog(@"User is logged in: %@", session.userId);
}
[session logout];
The first thing that looks weird is the square bracket syntax:
[session logout];
That is a message send.
In many languages you would write:
session.logout()
In Objective-C, you send a message to an object:
[object doSomething];
[object doSomethingWithValue:value];
[object doSomethingWithFirstValue:first secondValue:second];
That style looks old, but it tells you something important about Objective-C: method calls are dynamic.
The Runtime Is the Real Reason Objective-C Still Matters
Objective-C has a runtime that lets the app inspect and modify behavior while it is running.
That is not just trivia. It matters for debugging, analytics SDKs, testing tools, old frameworks, and reverse engineering.
Example: checking whether an object supports a method.
SEL selector = @selector(logout);
if ([session respondsToSelector:selector]) {
[session performSelector:selector];
}
This is very Objective-C.
The method is represented as a selector:
@selector(logout)
A selector is basically the name of a method known to the Objective-C runtime.
Another example:
Class cls = [session class];
NSLog(@"Class name: %@", NSStringFromClass(cls));
Or:
BOOL isUserSession = [session isKindOfClass:[UserSession class]];
Objective-C code can ask objects what they are and what they can do.
That is one reason it became useful for Cocoa, AppKit, UIKit, dynamic UI frameworks, testing, mocking, analytics, and runtime instrumentation.
A Realistic UIKit Example
A lot of old iOS code looks like this:
// LoginViewController.h
#import <UIKit/UIKit.h>
@interface LoginViewController : UIViewController
@end
// LoginViewController.m
#import "LoginViewController.h"
@interface LoginViewController ()
@property (nonatomic, strong) UITextField *emailField;
@property (nonatomic, strong) UIButton *loginButton;
@end
@implementation LoginViewController
- (void)viewDidLoad {
[super viewDidLoad];
self.view.backgroundColor = [UIColor systemBackgroundColor];
self.emailField = [[UITextField alloc] initWithFrame:CGRectMake(20, 100, 280, 44)];
self.emailField.placeholder = @"Email";
self.emailField.borderStyle = UITextBorderStyleRoundedRect;
self.loginButton = [UIButton buttonWithType:UIButtonTypeSystem];
self.loginButton.frame = CGRectMake(20, 160, 280, 44);
[self.loginButton setTitle:@"Log In" forState:UIControlStateNormal];
[self.loginButton addTarget:self
action:@selector(loginButtonTapped:)
forControlEvents:UIControlEventTouchUpInside];
[self.view addSubview:self.emailField];
[self.view addSubview:self.loginButton];
}
- (void)loginButtonTapped:(UIButton *)sender {
NSString *email = self.emailField.text;
if (email.length == 0) {
NSLog(@"Email is empty");
return;
}
NSLog(@"Logging in with email: %@", email);
}
@end
This code shows several Objective-C patterns that still matter:
@interfaceand@implementation- properties
- UIKit objects
- target-action
- selectors
UIViewController- private class extension
- message sending
NSString
The important line is this:
[self.loginButton addTarget:self
action:@selector(loginButtonTapped:)
forControlEvents:UIControlEventTouchUpInside];
The button does not call a closure here. It stores a target and a selector. When the event happens, UIKit sends the loginButtonTapped: message to the target.
This old pattern still influences Apple UI development.
Delegates: One of the Most Important Objective-C Patterns
If you have touched Apple development, you have seen delegates.
Objective-C made delegates everywhere.
A delegate is just another object that receives callbacks.
Example:
// DownloadManager.h
#import <Foundation/Foundation.h>
@class DownloadManager;
@protocol DownloadManagerDelegate <NSObject>
- (void)downloadManagerDidFinish:(DownloadManager *)manager;
- (void)downloadManager:(DownloadManager *)manager didFailWithError:(NSError *)error;
@end
@interface DownloadManager : NSObject
@property (nonatomic, weak) id<DownloadManagerDelegate> delegate;
- (void)startDownload;
@end
Implementation:
// DownloadManager.m
#import "DownloadManager.h"
@implementation DownloadManager
- (void)startDownload {
BOOL success = YES;
if (success) {
[self.delegate downloadManagerDidFinish:self];
} else {
NSError *error = [NSError errorWithDomain:@"DownloadError"
code:1001
userInfo:nil];
[self.delegate downloadManager:self didFailWithError:error];
}
}
@end
Using it:
@interface MyController () <DownloadManagerDelegate>
@property (nonatomic, strong) DownloadManager *downloadManager;
@end
@implementation MyController
- (void)viewDidLoad {
[super viewDidLoad];
self.downloadManager = [[DownloadManager alloc] init];
self.downloadManager.delegate = self;
[self.downloadManager startDownload];
}
- (void)downloadManagerDidFinish:(DownloadManager *)manager {
NSLog(@"Download finished");
}
- (void)downloadManager:(DownloadManager *)manager didFailWithError:(NSError *)error {
NSLog(@"Download failed: %@", error.localizedDescription);
}
@end
This is not just old syntax. This is the root of a lot of Apple API design.
Even Swift code still uses delegate-style APIs:
tableView.delegate = self
tableView.dataSource = self
That pattern came from the Objective-C world.
Categories: Extending Classes Without Subclassing
Objective-C categories let you add methods to existing classes.
Example:
// NSString+Validation.h
#import <Foundation/Foundation.h>
@interface NSString (Validation)
- (BOOL)isValidEmail;
@end
// NSString+Validation.m
#import "NSString+Validation.h"
@implementation NSString (Validation)
- (BOOL)isValidEmail {
return [self containsString:@"@"] && [self containsString:@"."];
}
@end
Usage:
NSString *email = @"test@example.com";
if ([email isValidEmail]) {
NSLog(@"Valid email");
}
Categories are useful, but they can also be dangerous.
If two categories add the same method name to the same class, behavior can become unpredictable. This matters in old codebases where many teams and SDKs extended Apple classes.
You can see why Objective-C runtime behavior can become messy.
It gives you power.
It also gives you rope.
Blocks: Objective-C Closures
Objective-C also has blocks, which are similar to closures or lambdas.
Example:
void (^completion)(BOOL success) = ^(BOOL success) {
if (success) {
NSLog(@"Success");
} else {
NSLog(@"Failed");
}
};
completion(YES);
A more realistic async-style API:
typedef void (^LoginCompletion)(BOOL success, NSError *error);
- (void)loginWithEmail:(NSString *)email
password:(NSString *)password
completion:(LoginCompletion)completion {
BOOL success = email.length > 0 && password.length > 0;
if (success) {
completion(YES, nil);
} else {
NSError *error = [NSError errorWithDomain:@"LoginError"
code:401
userInfo:nil];
completion(NO, error);
}
}
Usage:
[authService loginWithEmail:@"test@example.com"
password:@"password123"
completion:^(BOOL success, NSError *error) {
if (success) {
NSLog(@"Logged in");
} else {
NSLog(@"Error: %@", error.localizedDescription);
}
}];
This kind of code was very common before Swift async/await became the clean modern option.
If you maintain older apps or SDKs, you will still see it.
ARC and Memory: What You Need to Know
Old Objective-C used manual memory management:
[object retain];
[object release];
[object autorelease];
Modern Objective-C normally uses ARC, Automatic Reference Counting.
With ARC, you usually think in property attributes:
@property (nonatomic, strong) NSObject *ownerObject;
@property (nonatomic, weak) id delegate;
@property (nonatomic, copy) NSString *name;
@property (nonatomic, assign) BOOL enabled;
The practical version:
strongkeeps an object aliveweakavoids retain cycles, commonly used for delegatescopyis common for strings and blocksassignis for primitive values likeBOOL,NSInteger,CGFloat
Classic retain cycle example:
@interface Parent : NSObject
@property (nonatomic, strong) Child *child;
@end
@interface Child : NSObject
@property (nonatomic, strong) Parent *parent;
@end
If both objects strongly reference each other, neither may be released.
Better:
@interface Child : NSObject
@property (nonatomic, weak) Parent *parent;
@end
This is still relevant when debugging memory leaks in older iOS/macOS apps.
nil Messaging: Weird but Useful
In Objective-C, sending a message to nil does not crash.
UserSession *session = nil;
[session logout]; // No crash
This surprises people coming from many other languages.
It can be convenient:
NSString *name = session.userId;
NSLog(@"User ID: %@", name);
If session is nil, name becomes nil.
But it can also hide bugs. Code silently does nothing instead of failing loudly.
Example:
[self.analytics trackEvent:@"Login"];
If self.analytics is nil, nothing happens. No crash. No event. Maybe nobody notices until production analytics are missing.
That is Objective-C in one sentence:
Sometimes forgiving. Sometimes too forgiving.
Method Swizzling: Powerful and Dangerous
Method swizzling means replacing one method implementation with another at runtime.
Example:
#import <objc/runtime.h>
@implementation UIViewController (Tracking)
+ (void)load {
Method original = class_getInstanceMethod(self, @selector(viewDidAppear:));
Method swizzled = class_getInstanceMethod(self, @selector(tracked_viewDidAppear:));
method_exchangeImplementations(original, swizzled);
}
- (void)tracked_viewDidAppear:(BOOL)animated {
[self tracked_viewDidAppear:animated];
NSLog(@"Screen appeared: %@", NSStringFromClass([self class]));
}
@end
This looks insane until you understand what happened.
After swizzling:
viewDidAppear:
and:
tracked_viewDidAppear:
have swapped implementations.
The call inside the method:
[self tracked_viewDidAppear:animated];
actually calls the original viewDidAppear: after the swap.
This pattern was used by analytics SDKs, debugging tools, testing helpers, and sometimes very questionable production code.
It is useful.
It is also exactly the kind of thing that makes bugs feel haunted.
If you are doing QA or debugging and screens are being tracked, modified, or intercepted magically, swizzling may be involved.
Swift and Objective-C Interop in Real Projects
Many real projects are mixed.
A Swift file can call Objective-C code through a bridging header.
Example Objective-C header:
// LegacyAnalytics.h
#import <Foundation/Foundation.h>
@interface LegacyAnalytics : NSObject
+ (void)trackEvent:(NSString *)eventName;
+ (void)trackEvent:(NSString *)eventName properties:(NSDictionary *)properties;
@end
Bridging header:
// MyApp-Bridging-Header.h
#import "LegacyAnalytics.h"
Swift usage:
LegacyAnalytics.trackEvent("Login")
LegacyAnalytics.trackEvent("Purchase", properties: ["plan": "pro"])
The other direction also exists. Objective-C can use Swift classes if they are exposed correctly.
Swift:
import Foundation
@objcMembers
class PaymentValidator: NSObject {
func isValidAmount(_ amount: NSNumber) -> Bool {
return amount.doubleValue > 0
}
}
Objective-C:
#import "MyApp-Swift.h"
PaymentValidator *validator = [[PaymentValidator alloc] init];
BOOL valid = [validator isValidAmount:@49.99];
This is why Objective-C stays relevant even when teams mostly write Swift.
The migration is rarely clean. Companies mix both for years.
Objective-C in Crash Logs
Objective-C often appears in crash logs and stack traces.
You may see symbols like:
-[LoginViewController loginButtonTapped:]
+[AnalyticsManager trackEvent:]
-[UserSession logout]
objc_msgSend
The prefixes matter:
- instance method
+ class method
So:
- (void)logout;
is called on an instance:
[session logout];
And:
+ (void)trackEvent:(NSString *)name;
is called on the class:
[AnalyticsManager trackEvent:@"Login"];
If you do QA, crash triage, or mobile debugging, this is practical knowledge. It helps you read crashes faster instead of treating the native stack like random noise.
Objective-C for Reverse Engineering
Objective-C is especially important for iOS/macOS reverse engineering.
Because the runtime keeps class names, method names, and selectors, tools can often inspect them.
With Frida, the mental model often looks like this:
if (ObjC.available) {
const LoginManager = ObjC.classes.LoginManager;
const method = LoginManager["- isUserLoggedIn"];
Interceptor.attach(method.implementation, {
onLeave(retval) {
console.log("Original return:", retval);
retval.replace(1);
}
});
}
Or listing methods:
const klass = ObjC.classes.LoginManager;
klass.$ownMethods.forEach(function(methodName) {
console.log(methodName);
});
This is not Objective-C source code, but it uses Objective-C runtime concepts:
- classes
- instance methods
- class methods
- selectors
- implementations
- return values
You may hook a jailbreak/root detection method:
const SecurityChecker = ObjC.classes.SecurityChecker;
Interceptor.attach(SecurityChecker["- isJailbroken"].implementation, {
onLeave(retval) {
retval.replace(0);
}
});
This is why security people still care.
Swift exists. Swift is modern. Swift is everywhere.
But Objective-C runtime metadata is still extremely useful when analyzing Apple apps.
Objective-C in SDKs and Frameworks
Another place Objective-C survives: SDKs.
If a company ships an iOS SDK, it may still care about Objective-C compatibility because customers may have old apps.
A clean SDK interface may still look like this:
// PaymentSDK.h
#import <Foundation/Foundation.h>
NS_ASSUME_NONNULL_BEGIN
typedef void (^PaymentCompletion)(BOOL success, NSError *_Nullable error);
@interface PaymentSDK : NSObject
+ (instancetype)sharedInstance;
- (void)configureWithApiKey:(NSString *)apiKey;
- (void)startPaymentWithAmount:(NSNumber *)amount
currency:(NSString *)currency
completion:(PaymentCompletion)completion;
@end
NS_ASSUME_NONNULL_END
This API can be used from Objective-C apps and also imported into Swift.
That matters for SDK vendors because enterprise customers do not always move fast.
The bigger and older the customer, the more likely someone still has an Objective-C app in production.
What I Would Actually Learn in 2026
Do not learn Objective-C like you are trying to become a 2010 iOS developer.
Learn the useful parts.
Start with this:
// Class declaration
@interface MyClass : NSObject
@end
// Class implementation
@implementation MyClass
@end
// Instance method
- (void)doSomething;
// Class method
+ (instancetype)sharedInstance;
// Property
@property (nonatomic, strong) NSObject *object;
// Selector
@selector(doSomething)
// Protocol
@protocol MyDelegate <NSObject>
@end
// Category
@interface NSString (MyAdditions)
@end
// Block
void (^completion)(BOOL success);
// Runtime
NSClassFromString(@"SomeClass")
NSStringFromSelector(@selector(viewDidLoad))
You want recognition, not perfection.
The goal is to open old code and understand what is happening.
Should You Use Objective-C for a New App?
Usually no.
For a new app in 2026, use Swift.
Use Objective-C only if:
- you are maintaining an old codebase
- your team already has a large Objective-C app
- you are building a compatibility layer
- you are writing/maintaining an SDK
- you need low-level runtime behavior
- you have a very specific technical reason
Do not choose Objective-C because you want to be different.
That is not a strategy. That is self-harm with square brackets.
Where Objective-C Still Matters in 2026
Here is the realistic list:
- old iOS apps
- old macOS apps
- AppKit projects
- UIKit projects with long history
- enterprise mobile codebases
- banking/insurance/healthcare apps that move slowly
- SDKs that support old customers
- Swift migration projects
- crash log analysis
- XCTest helpers in older projects
- analytics and instrumentation
- method swizzling
- Frida scripts
- iOS/macOS reverse engineering
- runtime debugging
That is enough relevance to not call it dead.
But it is not enough to call it the future.
Final Verdict
Objective-C is still relevant in 2026, but not as the main language for new Apple development.
Swift won the main road.
Objective-C still owns part of the basement.
If you are building new iOS apps, learn Swift first. If you are serious about Apple platforms, learn enough Objective-C to read old code, understand delegates/selectors, recognize runtime behavior, and debug mixed projects.
If you do QA, mobile security, or reverse engineering, Objective-C is even more useful because crash logs, runtime metadata, method names, selectors, and Frida hooks still expose a lot of Objective-C-shaped reality.
Objective-C refuses to die because Apple software has history.
And history does not disappear just because Xcode gives you a nicer template.