Web Development

Czy iOS Developer odnajdzie się w Web Devie? Oto moje doświadczenia.

15 min readRafał Dubiel
#iOS#Web#React#Swift

Ostatnio chcąc rozwinąć swój wachlarz umiejętności postanowiłem zabrać się za strony internetowe. Już od dawna byłem pytany przez znajomych czy się tym zajmuję, albo czy kogoś znam. Zmotywowało mnie to żeby zagłębić się w temat i spróbować czy jest to dla mnie. Okazało się, że web development jest całkiem przyjemny, a miejscami wręcz zaskakująco podobny do tego czym się zajmuje na co dzień, czyli programowanie aplikacji na iOS. W związku z tym chciałem porównać te dwie na pierwszy rzut oka różne od siebie dziedziny.


Mój stack po obu stronach

Zanim przejdę do szczegółów, oto z czym pracuję:

iOS: Swift, SwiftUI, Combine, MVVM + reducer pattern do zarządzania stanem, Clean Architecture,

Web: React, Next.js, TypeScript, Tailwind CSS i ta sama filozofia Clean Architecture przeniesiona na frontend

Okazuje się, że te dwa światy mają więcej wspólnego niż myślałem.

TypeScript - Swift dla świata webowego

Zanim przejdę do UI i architektury, muszę wspomnieć o TypeScript. Bo to on sprawia, że przejście z iOS na web jest w ogóle znośne.

Czysty JavaScript to dla Swift developera koszmar. Brak typów, undefined is not a function, zmienne które mogą być czymkolwiek. TypeScript to naprawia i robi to w sposób zaskakująco znajomy.

// 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);

Widzisz to? Struktury to interfejsy, typy wyglądają prawie identycznie, async/await działa tak samo. Nawet higher-order functions jak filter, map, reduce mają tę samą składnię.

Kilka różnic które warto znać:

SwiftTypeScriptUwagi
let / varconst / letW TS let to odpowiednik var ze Swifta
String?string | nullOptionale przez union types
guard letEarly return + type narrowingTS sam zawęża typy po sprawdzeniu
enum z associated valuesDiscriminated unionsInne podejście, podobny efekt
protocolinterfacePrawie to samo
Generics <T>Generics <T>Identyczne

Optionale działają trochę inaczej. W Swift masz ? i !, w TypeScript robisz union type:

// Swift
var user: User? = nil
let name = user?.name ?? "Anonim"
// TypeScript
let user: User | null = null;
const name = user?.name ?? "Anonim";

Optional chaining (?.) i nullish coalescing (??) działają tak samo. To nie przypadek, bo JavaScript zapożyczył te operatory m.in. ze Swifta.

Enumy z associated values to jedyna rzecz której mi brakuje. W Swift robisz:

enum LoadingState {
    case idle
    case loading
    case success(User)
    case error(String)
}

W TypeScript musisz użyć discriminated unions:

type LoadingState =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; user: User }
  | { status: "error"; message: string };

Więcej pisania, ale działa tak samo. TypeScript prawidłowo zawęża typy w switch i if, więc masz pełne type safety.

Po miesiącu pisania w TypeScript czułem się jak w domu. Typy, kompilator który krzyczy gdy robisz głupoty, autocomplete w edytorze, wszystko to co kochamy w Swift, tylko w przeglądarce.

Deklaratywne UI - SwiftUI vs React + Tailwind

Jeśli piszesz w SwiftUI, to w React poczujesz się jak w domu.

// 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>
  );
}

Widzisz podobieństwo? Deklarujesz co ma się wyświetlić, nie jak to zrobić. Komponent przyjmuje dane, zwraca UI. Żadnych viewDidLoad, żadnego ręcznego aktualizowania etykiet.

Różnice są w szczegółach. W SwiftUI modyfikatory łańcuchowe (.padding().background()), w React klasy CSS albo inline styles. Swift ma silne typowanie z miejsca, w React musisz użyć TypeScript żeby mieć podobny komfort, ale jak już go użyjesz, jest naprawdę dobrze.

Ponadtwo od React 19 + React Compiler większość manualnych optymalizacji (useMemo, useCallback, React.memo) stała się po prostu niepotrzebna. Kompilator automatycznie analizuje kod i memoizuje dokładnie tam, gdzie trzeba, często precyzyjniej niż zrobiłby to człowiek. Efekt? Twój komponent wygląda prawie jak czysty SwiftUI - deklaratywny, bez zbędnego boilerplate’u, a wydajność i tak jest świetna.

Zarządzanie stanem - tu jest ciekawie

Na iOS siedzę w reducer pattern. Mam akcje, stan, reducer który produkuje nowy stan. Przewidywalne, testowalne, łatwe do debugowania.

// 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
    }
}

W React masz dokładnie to samo, wbudowane w język:

// 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 };
  }
}

// użycie w komponencie
const [state, dispatch] = useReducer(appReducer, initialState);
dispatch({ type: "userLoaded", user: fetchedUser });

Znajomo? Jak przenosiłem się na React, to był moment w którym pomyślałem "ok, to ma sens". Te same wzorce, inna składnia.

Dla prostszych przypadków React ma useState, który jest jak @State w SwiftUI. Dla globalnego stanu możesz użyć Context (odpowiednik @EnvironmentObject) albo biblioteki typu Zustand czy Redux. Ale szczerze? W większości projektów jakie robię, useState + useReducer + Context wystarczają.

Clean Architecture - działa po obu stronach

Moja struktura na iOS:

├── Data/
│   ├── Repositories/
│   ├── DataSources/
│   └── DTOs/
├── Domain/
│   ├── Entities/
│   ├── UseCases/
│   └── Interfaces/
├── Features/
│   ├── Home/
│   ├── Profile/
│   └── Settings/
└── Core/
    ├── DI/
    └── Utils/

Na webie robię praktycznie to samo:

├── data/
│   ├── repositories/
│   └── api/
├── domain/
│   ├── entities/
│   └── useCases/
├── features/
│   ├── home/
│   ├── profile/
│   └── settings/
└── shared/
    ├── hooks/
    └── utils/

Use case'y, repozytoria, encje - to wszystko działa tak samo. Logika domenowa nie zależy od frameworka. Mogę pisać use case który waliduje formularz i jest mi obojętne czy uruchomi się na iPhonie czy w przeglądarce.

// domain/useCases/validateContactForm.ts
export function validateContactForm(data: ContactFormData): ValidationResult {
  const errors: string[] = [];

  if (!data.email.includes("@")) {
    errors.push("Nieprawidłowy email");
  }
  if (data.message.length < 10) {
    errors.push("Wiadomość za krótka");
  }

  return {
    isValid: errors.length === 0,
    errors,
  };
}

Zero zależności od React, zero importów z Next.js. Czysty TypeScript, testowalny w izolacji.

Zarządzanie zależnościami - SPM vs npm

Na iOS mamy Swift Package Manager. Prosty, zintegrowany z Xcode, robi co ma robić. Package.swift definiuje zależności, Xcode je pobiera, koniec historii.

// 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")
]

W świecie JavaScript masz npm (albo yarn, albo pnpm, albo bun - bo czemu nie mieć pięciu narzędzi do tego samego). Plik package.json wygląda znajomo:

{
  "dependencies": {
    "next": "^14.0.0",
    "react": "^18.2.0",
    "tailwindcss": "^3.4.0"
  },
  "devDependencies": {
    "typescript": "^5.0.0",
    "eslint": "^8.0.0"
  }
}

Główna różnica? Skala. Folder node_modules po instalacji typowego projektu Next.js waży kilkaset megabajtów. Serio. Pierwszy raz jak to zobaczyłem, myślałem że coś poszło nie tak.

Ale jest też zaleta - npm ma paczkę na wszystko - dosłownie wszystko. Walidacja formularzy, obsługa dat, animacje, komponenty UI, integracje z API. Ekosystem jest gigantyczny i bardzo dojrzały.

Kilka praktycznych różnic:

SPMnpm
Zintegrowany z XcodeOddzielne narzędzie CLI
Wersjonowanie semanticWersjonowanie semantic + więcej opcji (^, ~, *)
Stosunkowo mało paczekMiliony paczek
Paczki zazwyczaj stabilneJakość bardzo różna, trzeba uważać
Długi czas resolution przy dużych projektachSzybki install, wolny przy pierwszym razie

Jedna rzecz na którą trzeba uważać - w npm łatwo wpaść w pułapkę dodawania zależności na wszystko. Potrzebujesz sprawdzić czy string jest pusty? Jest na to paczka. Nie rób tego. Ta sama zasada co na iOS - zanim dodasz bibliotekę, zastanów się czy naprawdę jej potrzebujesz.

Lockfile (package-lock.json) działa jak Package.resolved w SPM, gwarantuje że wszyscy w zespole mają te same wersje. Zawsze commituj go do repo.


Co było najtrudniejsze?

CSS i stylowanie

Nie będę ukrywał, że to była moja największa obawa. Z CSS nigdy nie było mi po drodze, jeszcze od czasów lekcji informatyki w szkole średniej. W SwiftUI masz ograniczony zestaw modyfikatorów, ale wszystko jest spójne i przewidywalne. W CSS możesz zrobić dosłownie wszystko, co oznacza że możesz też wszystko zepsuć.

Tailwind uratował mi życie. Zamiast pisać CSS od zera, składam klasy jak klocki Lego. flex, gap-4, rounded-lg, shadow-md - po tygodniu miałem to ogarnięte. To trochę jak modyfikatory w SwiftUI, tylko w formie stringów.

// zamiast pisać CSS
<div style={{
    display: 'flex',
    gap: '16px',
    borderRadius: '8px',
    boxShadow: '0 4px 6px rgba(0,0,0,0.1)'
}}>

// piszesz
<div className="flex gap-4 rounded-lg shadow-md">

Ekosystem i narzędzia

Świat JavaScript to dziki zachód. Na iOS masz Xcode i tyle. Na webie masz wybór: Vite, Webpack, Turbopack, npm, yarn, pnpm, bun... Każdy projekt może wyglądać inaczej.

Next.js rozwiązuje większość problemów za Ciebie. Routing, bundling, SSR, optymalizacja obrazków - dostajesz to na dzień dobry. Polecam zacząć od Next.js zamiast bawić się w konfigurację czystego Reacta.

Formularze - tu jest przepaść

Formularze w SwiftUI od iOS 16/17 z @Observable i @Bindable są naprawdę przyjemne. Walidacja, binding, error handling - wszystko działa spójnie.

W React formularze to osobny temat. Możesz robić wszystko ręcznie z useState, ale przy większych formularzach to szybko robi się nieczytelne. Standard w branży to React Hook Form + Zod:

// schemat walidacji z Zod
const contactSchema = z.object({
  name: z.string().min(2, "Imię za krótkie"),
  email: z.string().email("Nieprawidłowy email"),
  message: z.string().min(10, "Wiadomość za krótka"),
});

type ContactForm = z.infer<typeof contactSchema>;

// komponent formularza
function ContactForm() {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<ContactForm>({
    resolver: zodResolver(contactSchema),
  });

  const onSubmit = (data: ContactForm) => {
    // data jest już zwalidowana i typowana
    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">Wyślij</button>
    </form>
  );
}

Połączenie RHF + Zod + Tailwind daje chyba najbliższy feeling do tego co mamy w iOS. Ale szczerze? Nadal wymaga więcej boilerplate'u. To jeden z obszarów gdzie iOS ma przewagę.

Testy - dobre, ale jednak inne

Unit testy reducerów w Swift z XCTest to czysta przyjemność. IDE pokazuje zielone/czerwone kropki, coverage jest wbudowany, wszystko jest spójne.

W React testujesz z Vitest (albo Jest) + Testing Library. Sam testing jest przyjemny:

// test reducera - prawie identyczny jak w 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);
});

// test komponentu
test("UserCard displays user name", () => {
  render(
    <UserCard user={{ id: "1", name: "Jan", email: "jan@example.com" }} />
  );

  expect(screen.getByText("Jan")).toBeInTheDocument();
});

Problem? Brak takiego wsparcia IDE jak w Xcode. Musisz odpalać testy z terminala albo konfigurować integrację. Nie ma tego "klik i widzisz wynik" co w XCTest.

Największy ból po roku - fragmentacja wiedzy

To nie tooling, nie CSS, nie formularze. Największy problem z webdevem to tempo zmian i brak jednej "prawdziwej drogi".

Co kilka miesięcy pojawia się nowy "najlepszy sposób" na robienie czegoś:

  • Routing: App Router vs Pages Router - który wybrać?
  • 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 własne rozwiązanie
  • 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

Na iOS zmiany przychodzą raz w roku na WWDC. Masz czas się przygotować, dokumentacja jest spójna, Apple mówi "użyj tego". W webdev każdy blog post z zeszłego roku może być już nieaktualny.

Moja rada? Wybierz stack i się go trzymaj. Dla mnie to Next.js App Router + Server Components + TanStack Query + Tailwind + Zod. Działa, jest dobrze udokumentowany, ma dużą społeczność. Nie goń za każdym nowym frameworkiem, bo to droga donikąd.

Asynchroniczność i pobieranie danych

W Swift masz async/await, w JavaScript też masz async/await. Ale w tradycyjnym React komponenty są synchroniczne, więc do pobierania danych używasz hooków jak useEffect. To wymaga przestawienia się.

// klasyczne podejście w React - dużo boilerplate'u
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} />;
}

Brzydkie, prawda? Na szczęście są lepsze rozwiązania.

Server Components - game changer

To jest chyba największa zmiana myślenia dla kogoś przychodzącego z mobile. W Next.js 13+ możesz pisać tak:

// 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} />;
}

Żadnego useEffect. Żadnego useState dla loading. Żadnego try/catch w komponencie. Po prostu await i gotowe.

Pierwszy raz jak to zobaczyłem, pomyślałem "o kurde, to jest jak SwiftUI + @Observable na sterydach". Komponent który wygląda synchronicznie, ale pod spodem dzieje się magia - HTML renderuje się na serwerze, do przeglądarki leci gotowy wynik.

Dla interaktywnych elementów (formularze, przyciski, animacje) nadal potrzebujesz Client Components z "use client". Ale dla większości stron, np. lista produktów, profil użytkownika, dashboard - Server Components wystarczają i są znacznie prostsze.

TanStack Query - Combine dla frontendu

Gdy potrzebujesz pobierać dane po stronie klienta (bo np. reagujesz na akcje użytkownika), TanStack Query (dawniej React Query) to absolutny must-have.

Wielu iOS-owców po przesiadce mówi, że to ich "nowy Combine na frontendzie". I coś w tym jest:

// z 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} />;
}

Ale to nie wszystko. Dostajesz:

  • Automatyczne cache'owanie - jak URLCache, tylko lepiej zintegrowane,
  • Refetch on focus/mount - wracasz do zakładki, dane się odświeżają,
  • Optimistic updates - UI reaguje natychmiast, request leci w tle,
  • Invalidation - zmieniłeś dane? Powiedz które queries mają się odświeżyć,
  • Retry logic - request się nie udał? Spróbuj ponownie

To wszystko brzmi znajomo jeśli używałeś Combine + Repository + UseCase. Tylko że w TanStack Query dostajesz to out of the box, bez pisania własnej infrastruktury.


Co było łatwiejsze niż myślałem?

Hot reload który działa

Na iOS używam Injection 3, bo SwiftUI previews są niewiarygodnie wolne albo w ogóle nie działają. W webdev hot reload po prostu działa. Zapisujesz plik, zmiany są widoczne natychmiast. Żadnych workaroundów.

Deployment

Wrzucenie aplikacji do App Store to proces na cały dzień. Review, certyfikaty, provisioning profile, TestFlight...

Deployment strony na Vercel? git push i gotowe. Serio, tyle. Domena, HTTPS, CDN to wszystko działa od razu.

Społeczność i zasoby

React ma ogromną społeczność. Stack Overflow, blogi, tutoriale, gotowe komponenty. Jak masz problem, ktoś już go rozwiązał i opisał.


Czy było warto?

Z pełnym przekonaniem uważam, że tak. Rozszerzenie swoich umiejętności o Web Dev dało mi przede wszystkim:

  1. Nowe perspektywy - widzę jak te same problemy rozwiązuje się w innym ekosystemie,
  2. Dodatkowe zlecenia - mogę zrobić klientowi i aplikację i stronę,
  3. Szybsze prototypowanie - czasem łatwiej pokazać pomysł jako aplikacje webową, niż budować aplikacje iOS od zera,

Podsumowanie

Jeśli jesteś iOS devem i zastanawiasz się czy warto spróbować webu - spróbuj. Twoja wiedza o architekturze, wzorcach projektowych, zarządzaniu stanem - to wszystko się przenosi. Nie uczysz się programować od zera, tylko nowej składni i kilku specyficznych konceptów.

Zacznij od prostego projektu w Next.js + TypeScript + Tailwind. Zrób swoją stronę portfolio, landing page dla jakiegoś pobocznego projektu lub prosty dashboard. Zobaczysz, że nie jest tak obco jak mogłoby się wydawać.

Rafał Dubiel

Rafał Dubiel

Senior iOS Developer z ponad 8-letnim doświadczeniem w tworzeniu aplikacji mobilnych. Pasjonat czystej architektury, Swift i dzielenia się wiedzą ze społecznością.

Udostępnij artykuł:

Czy iOS Developer odnajdzie się w Web Devie? Oto moje doświadczenia. | FractalDev Blog