Can an iOS Developer find their way in Web Dev? Here's my experience.
Recently, wanting to expand my skill set, I decided to take on web development. For a long time, friends had been asking me if I do this, or if I know someone who does. This motivated me to dive into the topic and see if it's for me. It turned out that web development is quite enjoyable, and in places surprisingly similar to what I do every day, which is programming iOS applications. Because of this, I wanted to compare these two seemingly different fields.
My stack on both sides
Before I get into the details, here's what I work with:
iOS: Swift, SwiftUI, Combine, MVVM + reducer pattern for state management, Clean Architecture
Web: React, Next.js, TypeScript, Tailwind CSS and the same Clean Architecture philosophy transferred to frontend
It turns out these two worlds have more in common than I thought.
TypeScript - Swift for the web world
Before I move on to UI and architecture, I need to mention TypeScript. Because it's what makes the transition from iOS to web bearable at all.
Plain JavaScript is a nightmare for a Swift developer. No types, undefined is not a function, variables that can be anything. TypeScript fixes this and does it in a surprisingly familiar way.
// Swift
struct User {
let id: String
let name: String
let email: String
var isActive: Bool
}
func fetchUser(id: String) async throws -> User {
// ...
}
let users: [User] = []
let activeUsers = users.filter { $0.isActive }
// TypeScript
interface User {
id: string;
name: string;
email: string;
isActive: boolean;
}
async function fetchUser(id: string): Promise<User> {
// ...
}
const users: User[] = [];
const activeUsers = users.filter((u) => u.isActive);
See that? Structs are interfaces, types look almost identical, async/await works the same. Even higher-order functions like filter, map, reduce have the same syntax.
A few differences worth knowing:
| Swift | TypeScript | Notes |
|---|---|---|
let / var | const / let | In TS let is the equivalent of Swift's var |
String? | string | null | Optionals through union types |
guard let | Early return + type narrowing | TS narrows types after checking |
enum with associated values | Discriminated unions | Different approach, similar effect |
protocol | interface | Almost the same |
Generics <T> | Generics <T> | Identical |
Optionals work a bit differently. In Swift you have ? and !, in TypeScript you use union types:
// Swift
var user: User? = nil
let name = user?.name ?? "Anonymous"
// TypeScript
let user: User | null = null;
const name = user?.name ?? "Anonymous";
Optional chaining (?.) and nullish coalescing (??) work the same. This is no coincidence, as JavaScript borrowed these operators from Swift, among others.
Enums with associated values are the only thing I miss. In Swift you do:
enum LoadingState {
case idle
case loading
case success(User)
case error(String)
}
In TypeScript you need to use discriminated unions:
type LoadingState =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; user: User }
| { status: "error"; message: string };
More typing, but it works the same. TypeScript correctly narrows types in switch and if, so you get full type safety.
After a month of writing TypeScript, I felt at home. Types, a compiler that yells when you do stupid things, autocomplete in the editor - everything we love about Swift, just in the browser.
Declarative UI - SwiftUI vs React + Tailwind
If you write in SwiftUI, you'll feel at home in React.
// SwiftUI
struct UserCard: View {
let user: User
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text(user.name)
.font(.headline)
Text(user.email)
.font(.subheadline)
.foregroundColor(.secondary)
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(12)
}
}
// React + Tailwind
function UserCard({ user }: { user: User }) {
return (
<div className="p-4 bg-white rounded-xl flex flex-col gap-2">
<h3 className="font-semibold text-lg">{user.name}</h3>
<p className="text-sm text-gray-500">{user.email}</p>
</div>
);
}
See the similarity? You declare what should be displayed, not how to do it. The component takes data, returns UI. No viewDidLoad, no manual label updating.
The differences are in the details. In SwiftUI, chained modifiers (.padding().background()), in React CSS classes or inline styles. Swift has strong typing out of the box, in React you need to use TypeScript to have similar comfort, but once you use it, it's really good.
Moreover, since React 19 + React Compiler, most manual optimizations (useMemo, useCallback, React.memo) have simply become unnecessary. The compiler automatically analyzes the code and memoizes exactly where needed, often more precisely than a human would. The result? Your component looks almost like pure SwiftUI - declarative, without unnecessary boilerplate, and performance is still great.
State management - this is where it gets interesting
On iOS I use the reducer pattern. I have actions, state, a reducer that produces new state. Predictable, testable, easy to debug.
// iOS - reducer pattern
enum AppAction {
case userLoaded(User)
case logout
case setLoading(Bool)
}
struct AppState {
var user: User?
var isLoading: Bool = false
}
func appReducer(state: inout AppState, action: AppAction) {
switch action {
case .userLoaded(let user):
state.user = user
state.isLoading = false
case .logout:
state.user = nil
case .setLoading(let loading):
state.isLoading = loading
}
}
In React you have exactly the same thing, built into the language:
// React - useReducer
type Action =
| { type: "userLoaded"; user: User }
| { type: "logout" }
| { type: "setLoading"; loading: boolean };
interface State {
user: User | null;
isLoading: boolean;
}
function appReducer(state: State, action: Action): State {
switch (action.type) {
case "userLoaded":
return { ...state, user: action.user, isLoading: false };
case "logout":
return { ...state, user: null };
case "setLoading":
return { ...state, isLoading: action.loading };
}
}
// usage in component
const [state, dispatch] = useReducer(appReducer, initialState);
dispatch({ type: "userLoaded", user: fetchedUser });
Familiar? When I was transitioning to React, this was the moment I thought "ok, this makes sense". Same patterns, different syntax.
For simpler cases, React has useState, which is like @State in SwiftUI. For global state you can use Context (equivalent of @EnvironmentObject) or libraries like Zustand or Redux. But honestly? In most projects I do, useState + useReducer + Context are enough.
Clean Architecture - works on both sides
My structure on iOS:
βββ Data/
β βββ Repositories/
β βββ DataSources/
β βββ DTOs/
βββ Domain/
β βββ Entities/
β βββ UseCases/
β βββ Interfaces/
βββ Features/
β βββ Home/
β βββ Profile/
β βββ Settings/
βββ Core/
βββ DI/
βββ Utils/
On the web I do practically the same:
βββ data/
β βββ repositories/
β βββ api/
βββ domain/
β βββ entities/
β βββ useCases/
βββ features/
β βββ home/
β βββ profile/
β βββ settings/
βββ shared/
βββ hooks/
βββ utils/
Use cases, repositories, entities - it all works the same. Domain logic doesn't depend on the framework. I can write a use case that validates a form and I don't care whether it runs on an iPhone or in a browser.
// domain/useCases/validateContactForm.ts
export function validateContactForm(data: ContactFormData): ValidationResult {
const errors: string[] = [];
if (!data.email.includes("@")) {
errors.push("Invalid email");
}
if (data.message.length < 10) {
errors.push("Message too short");
}
return {
isValid: errors.length === 0,
errors,
};
}
Zero dependencies on React, zero imports from Next.js. Pure TypeScript, testable in isolation.
Dependency management - SPM vs npm
On iOS we have Swift Package Manager. Simple, integrated with Xcode, does what it's supposed to do. Package.swift defines dependencies, Xcode fetches them, end of story.
// Package.swift
dependencies: [
.package(url: "https://github.com/Alamofire/Alamofire.git", from: "5.0.0"),
.package(url: "https://github.com/realm/realm-swift.git", from: "10.0.0")
]
In the JavaScript world you have npm (or yarn, or pnpm, or bun - because why not have five tools for the same thing). The package.json file looks familiar:
{
"dependencies": {
"next": "^14.0.0",
"react": "^18.2.0",
"tailwindcss": "^3.4.0"
},
"devDependencies": {
"typescript": "^5.0.0",
"eslint": "^8.0.0"
}
}
The main difference? Scale. The node_modules folder after installing a typical Next.js project weighs several hundred megabytes. Seriously. The first time I saw this, I thought something went wrong.
But there's also an advantage - npm has a package for everything - literally everything. Form validation, date handling, animations, UI components, API integrations. The ecosystem is gigantic and very mature.
A few practical differences:
| SPM | npm |
|---|---|
| Integrated with Xcode | Separate CLI tool |
| Semantic versioning | Semantic versioning + more options (^, ~, *) |
| Relatively few packages | Millions of packages |
| Packages usually stable | Quality varies greatly, need to be careful |
| Long resolution time for large projects | Fast install, slow the first time |
One thing to watch out for - in npm it's easy to fall into the trap of adding dependencies for everything. Need to check if a string is empty? There's a package for that. Don't do it. Same rule as on iOS - before you add a library, consider whether you really need it.
The lockfile (package-lock.json) works like Package.resolved in SPM - it guarantees that everyone on the team has the same versions. Always commit it to the repo.
What was the hardest?
CSS and styling
I won't hide that this was my biggest concern. CSS and I never got along, going back to computer science classes in high school. In SwiftUI you have a limited set of modifiers, but everything is consistent and predictable. In CSS you can do literally anything, which means you can also break everything.
Tailwind saved my life. Instead of writing CSS from scratch, I assemble classes like Lego blocks. flex, gap-4, rounded-lg, shadow-md - after a week I had it figured out. It's a bit like modifiers in SwiftUI, just in string form.
// instead of writing CSS
<div style={{
display: 'flex',
gap: '16px',
borderRadius: '8px',
boxShadow: '0 4px 6px rgba(0,0,0,0.1)'
}}>
// you write
<div className="flex gap-4 rounded-lg shadow-md">
Ecosystem and tools
The JavaScript world is the wild west. On iOS you have Xcode and that's it. On the web you have choices: Vite, Webpack, Turbopack, npm, yarn, pnpm, bun... Every project can look different.
Next.js solves most problems for you. Routing, bundling, SSR, image optimization - you get it right away. I recommend starting with Next.js instead of messing with pure React configuration.
Forms - this is where there's a gap
Forms in SwiftUI since iOS 16/17 with @Observable and @Bindable are really pleasant. Validation, binding, error handling - everything works cohesively.
In React, forms are a separate topic. You can do everything manually with useState, but with larger forms it quickly becomes unreadable. The industry standard is React Hook Form + Zod:
// validation schema with Zod
const contactSchema = z.object({
name: z.string().min(2, "Name too short"),
email: z.string().email("Invalid email"),
message: z.string().min(10, "Message too short"),
});
type ContactForm = z.infer<typeof contactSchema>;
// form component
function ContactForm() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<ContactForm>({
resolver: zodResolver(contactSchema),
});
const onSubmit = (data: ContactForm) => {
// data is already validated and typed
api.sendContact(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register("name")} />
{errors.name && <span>{errors.name.message}</span>}
<input {...register("email")} />
{errors.email && <span>{errors.email.message}</span>}
<textarea {...register("message")} />
{errors.message && <span>{errors.message.message}</span>}
<button type="submit">Send</button>
</form>
);
}
The combination of RHF + Zod + Tailwind gives probably the closest feeling to what we have in iOS. But honestly? It still requires more boilerplate. This is one area where iOS has the advantage.
Tests - good, but different
Unit tests for reducers in Swift with XCTest are a pure pleasure. The IDE shows green/red dots, coverage is built in, everything is cohesive.
In React you test with Vitest (or Jest) + Testing Library. The testing itself is pleasant:
// reducer test - almost identical to Swift
test("userLoaded action sets user and clears loading", () => {
const initialState = { user: null, isLoading: true };
const user = { id: "1", name: "Jan" };
const result = appReducer(initialState, { type: "userLoaded", user });
expect(result.user).toEqual(user);
expect(result.isLoading).toBe(false);
});
// component test
test("UserCard displays user name", () => {
render(
<UserCard user={{ id: "1", name: "Jan", email: "jan@example.com" }} />
);
expect(screen.getByText("Jan")).toBeInTheDocument();
});
The problem? No IDE support like in Xcode. You have to run tests from the terminal or configure integration. There's no "click and see the result" like in XCTest.
The biggest pain after a year - knowledge fragmentation
It's not the tooling, not CSS, not forms. The biggest problem with web dev is the pace of change and lack of one "true path".
Every few months a new "best way" to do something appears:
- Routing: App Router vs Pages Router - which to choose?
- Fetching: Server Actions vs API Routes vs tRPC vs Server Components + fetch
- Auth: NextAuth v4 vs v5 vs Auth.js vs Lucia vs Clerk vs custom solution
- Styling: CSS Modules vs Tailwind vs styled-components vs CSS-in-JS vs vanilla CSS
- State: Context vs Redux vs Zustand vs Jotai vs Recoil vs signals
On iOS, changes come once a year at WWDC. You have time to prepare, documentation is consistent, Apple says "use this". In web dev, every blog post from last year might already be outdated.
My advice? Pick a stack and stick with it. For me it's Next.js App Router + Server Components + TanStack Query + Tailwind + Zod. It works, it's well documented, has a large community. Don't chase every new framework, because that's a road to nowhere.
Asynchronicity and data fetching
In Swift you have async/await, in JavaScript you also have async/await. But in traditional React, components are synchronous, so for data fetching you use hooks like useEffect. This requires adjustment.
// classic approach in React - lots of boilerplate
function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
async function fetchUser() {
setLoading(true);
setError(null);
try {
const data = await api.getUser(userId);
setUser(data);
} catch (e) {
setError(e as Error);
} finally {
setLoading(false);
}
}
fetchUser();
}, [userId]);
if (loading) return <Spinner />;
if (error) return <ErrorView error={error} />;
if (!user) return <NotFound />;
return <UserCard user={user} />;
}
Ugly, right? Fortunately, there are better solutions.
Server Components - game changer
This is probably the biggest mindset shift for someone coming from mobile. In Next.js 13+ you can write like this:
// app/users/[id]/page.tsx - Server Component
async function UserPage({ params }: { params: { id: string } }) {
const user = await db.query("SELECT * FROM users WHERE id = $1", [params.id]);
if (!user) return <NotFound />;
return <UserCard user={user} />;
}
No useEffect. No useState for loading. No try/catch in the component. Just await and done.
The first time I saw this, I thought "oh damn, this is like SwiftUI + @Observable on steroids". A component that looks synchronous, but magic happens under the hood - HTML renders on the server, the finished result goes to the browser.
For interactive elements (forms, buttons, animations) you still need Client Components with "use client". But for most pages, e.g. product list, user profile, dashboard - Server Components are enough and are much simpler.
TanStack Query - Combine for the frontend
When you need to fetch data on the client side (because e.g. you're reacting to user actions), TanStack Query (formerly React Query) is an absolute must-have.
Many iOS developers after switching say it's their "new Combine on the frontend". And there's something to it:
// with TanStack Query
function UserProfile({ userId }: { userId: string }) {
const {
data: user,
isLoading,
error,
} = useQuery({
queryKey: ["user", userId],
queryFn: () => api.getUser(userId),
});
if (isLoading) return <Spinner />;
if (error) return <ErrorView error={error} />;
return <UserCard user={user} />;
}
But that's not all. You get:
- Automatic caching - like
URLCache, but better integrated, - Refetch on focus/mount - you return to the tab, data refreshes,
- Optimistic updates - UI reacts immediately, request goes in the background,
- Invalidation - changed data? Tell which queries should refresh,
- Retry logic - request failed? Try again
This all sounds familiar if you've used Combine + Repository + UseCase. Except with TanStack Query you get it out of the box, without writing your own infrastructure.
What was easier than I thought?
Hot reload that works
On iOS I use Injection 3, because SwiftUI previews are incredibly slow or don't work at all. In web dev, hot reload just works. You save a file, changes are visible immediately. No workarounds.
Deployment
Pushing an app to the App Store is an all-day process. Review, certificates, provisioning profiles, TestFlight...
Deploying a site to Vercel? git push and done. Seriously, that's it. Domain, HTTPS, CDN - everything works right away.
Community and resources
React has a huge community. Stack Overflow, blogs, tutorials, ready-made components. If you have a problem, someone has already solved it and written about it.
Was it worth it?
I firmly believe it is. Expanding my skills to include Web Dev gave me above all:
- New perspectives - I see how the same problems are solved in a different ecosystem,
- Additional work - I can make both an app and a website for a client,
- Faster prototyping - sometimes it's easier to show an idea as a web app than to build an iOS app from scratch,
Summary
If you're an iOS dev and wondering if it's worth trying web - try it. Your knowledge of architecture, design patterns, state management - it all transfers. You're not learning to program from scratch, just new syntax and a few specific concepts.
Start with a simple project in Next.js + TypeScript + Tailwind. Make your portfolio site, a landing page for some side project, or a simple dashboard. You'll see it's not as foreign as it might seem.

RafaΕ Dubiel
Senior iOS Developer with 8+ years of experience building mobile applications. Passionate about clean architecture, Swift, and sharing knowledge with the community.
Share this article: