Web Development

Can an iOS Developer find their way in Web Dev? Here's my experience.

β€’16 min readβ€’RafaΕ‚ Dubiel
#iOS#Web#React#Swift

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:

SwiftTypeScriptNotes
let / varconst / letIn TS let is the equivalent of Swift's var
String?string | nullOptionals through union types
guard letEarly return + type narrowingTS narrows types after checking
enum with associated valuesDiscriminated unionsDifferent approach, similar effect
protocolinterfaceAlmost 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:

SPMnpm
Integrated with XcodeSeparate CLI tool
Semantic versioningSemantic versioning + more options (^, ~, *)
Relatively few packagesMillions of packages
Packages usually stableQuality varies greatly, need to be careful
Long resolution time for large projectsFast 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:

  1. New perspectives - I see how the same problems are solved in a different ecosystem,
  2. Additional work - I can make both an app and a website for a client,
  3. 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

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: