kodingup.camp

Beranda/TypeScript Mastery

Proyek Akhir TypeScript

Modul 12 dari 126 menit bacaMigrasi Aplikasi React ke TypeScript

Isi modul

    Tujuan

    Di proyek ini kamu memigrasikan aplikasi Daftar Tugas (todo) React yang ditulis dengan JavaScript menjadi TypeScript dengan mode strict, secara bertahap, tanpa pernah membuat aplikasi berhenti berjalan. Ini persis situasi yang akan kamu hadapi di tempat kerja: jarang ada tim yang menulis ulang aplikasi dari nol hanya demi TypeScript. Yang terjadi adalah migrasi file demi file, sambil fitur baru tetap dikerjakan.

    Kamu boleh memakai aplikasi todo atau katalog produk yang kamu buat di materi React, atau mulai dari kerangka di bawah. Hasil akhirnya:

    Bagian Teknik TypeScript yang dipakai
    Model data Skema Zod + z.infer, Omit, indexed access Todo["prioritas"]
    Ambil data API fetch + validasi runtime, mapping dari bentuk API ke bentuk aplikasi
    State useReducer dengan discriminated union untuk state dan aksi
    Komponen Props bertipe, event ChangeEvent/SubmitEvent, generic component
    Konfigurasi tsconfig mode strict + allowJs untuk masa transisi
    Fondasi React 19.3, Vite 8.3 (template react-ts), TypeScript 7.0.2, Zod 4.6.7

    Strategi migrasi yang kita pakai disebut "strict dari awal, JS dibiarkan sementara": konfigurasi langsung strict: true, file .js/.jsx lama tetap boleh ada berkat allowJs, lalu file dipindahkan ke .ts/.tsx satu per satu, dimulai dari yang paling sedikit ketergantungannya (utilitas dan model data), baru kemudian hooks dan komponen. Dengan cara ini setiap file yang sudah dimigrasi langsung mendapat perlindungan penuh, dan tidak ada momen "mematikan strict lalu lupa menyalakannya lagi".

    Fitur wajib

    1. Konfigurasi TypeScript: typescript 7.x terpasang, tsconfig.app.json dengan strict: true, noUncheckedIndexedAccess: true, verbatimModuleSyntax: true, dan erasableSyntaxOnly: true. Skrip npm run typecheck menjalankan tsc -b.
    2. Model data tunggal: tipe Todo diturunkan dari skema Zod (z.infer), bukan ditulis dua kali. Turunan seperti TodoBaru dibuat dengan utility types.
    3. Data API tervalidasi: daftar tugas diambil dari https://jsonplaceholder.typicode.com/todos?_limit=8, divalidasi dengan Zod, lalu dipetakan ke bentuk Todo milik aplikasi. Tidak ada as pada hasil res.json().
    4. State bertipe: useReducer dengan discriminated union untuk status (memuat / gagal / siap) dan untuk aksi (tambah, toggle, hapus, ...).
    5. Form tambah tugas: validasi judul 3-80 karakter dan prioritas rendah | sedang | tinggi memakai skema yang sama; pesan error berbahasa Indonesia.
    6. Komponen bertipe: setiap komponen punya tipe props; minimal satu generic component (misalnya FilterBar<T>).
    7. Filter semua | aktif | selesai dengan function saring yang menangani semua kasus (switch tanpa celah).
    8. Masa transisi: setidaknya satu file .js lama tetap berjalan bersama file TypeScript (dengan allowJs + checkJs dan anotasi JSDoc), lalu dimigrasi di langkah terakhir.
    9. Nol pelarian: tidak ada any, // @ts-ignore, atau as yang membungkam error di kode akhir (pengecualian: document.getElementById("root")! di main.tsx).

    Langkah bertahap

    Langkah 1, Siapkan fondasi. Kalau mulai dari proyek JavaScript Vite yang sudah ada, pasang TypeScript dan tipe React:

    npm install -D typescript@latest @types/react @types/react-dom
    npm install zod
    npx tsc --version
    # → Version 7.0.2
    

    Cara termudah mendapatkan konfigurasi yang benar adalah menyalin tsconfig.json, tsconfig.app.json, dan tsconfig.node.json dari proyek baru npm create vite@latest contoh -- --template react-ts. Ganti vite.config.js menjadi vite.config.ts (isinya biasanya tidak perlu diubah) dan ubah skrip build:

    {
      "scripts": {
        "dev": "vite",
        "typecheck": "tsc -b",
        "build": "tsc -b && vite build",
        "preview": "vite preview"
      }
    }
    

    Langkah 2, Izinkan JavaScript selama transisi. Tambahkan opsi berikut ke compilerOptions di tsconfig.app.json:

    {
      "compilerOptions": {
        "strict": true,
        "noUncheckedIndexedAccess": true,
        "allowJs": true,
        "checkJs": true
      }
    }
    

    allowJs membuat file .js/.jsx boleh diimpor dari file TypeScript. Tanpa opsi ini, tsc -b menolak dengan error:

    npx tsc -b
    # → src/App.tsx(9,31): error TS7016: Could not find a declaration file for module './lib/format'.
    #   '.../src/lib/format.js' implicitly has an 'any' type.
    

    checkJs membuat TypeScript juga memeriksa file JavaScript dan membaca komentar JSDoc sebagai tipe. Kalau file JS lama terlalu banyak error, matikan checkJs sementara dan nyalakan lagi setelah sebagian besar file dimigrasi.

    Langkah 3, Migrasi model data dulu. Buat src/lib/todo.ts: skema Zod, tipe turunan, function ambilTodos, dan saring. File ini tidak bergantung pada file lain, jadi aman dipindah paling awal, dan setelah itu semua file lain langsung mendapat tipe Todo yang benar.

    Langkah 4, Ganti nama file satu per satu. Ubah useTodos.js menjadi useTodos.ts, lalu jalankan npm run typecheck. Error yang paling sering muncul pertama kali:

    # → src/lib/lama.ts(1,31): error TS7006: Parameter 'selesai' implicitly has an 'any' type.
    

    Perbaiki dengan memberi tipe parameter. Lanjutkan ke komponen: TodoItem.jsx → TodoItem.tsx, TodoForm.jsx → TodoForm.tsx, dan seterusnya. Commit setiap kali satu file selesai dan typecheck hijau, supaya mudah mundur kalau ada yang salah.

    Langkah 5, Ketatkan state. Ganti state yang berupa beberapa boolean (loading, error, data) menjadi discriminated union. Ini biasanya perubahan dengan manfaat terbesar: keadaan mustahil (memuat sekaligus error) tidak bisa lagi terjadi.

    Langkah 6, Validasi di pintu masuk. Pastikan satu-satunya tempat data mentah dari API disentuh adalah ambilTodos. Komponen hanya menerima Todo[] yang sudah tervalidasi.

    Langkah 7, Migrasi file JavaScript terakhir. Ubah format.js menjadi format.ts, pindahkan tipe dari JSDoc ke anotasi TypeScript, lalu hapus allowJs dan checkJs dari tsconfig. Jalankan npm run build untuk memastikan semuanya bersih.

    Langkah 8, Audit pelarian. Cari sisa jalan pintas:

    grep -rnE "\bany\b|@ts-ignore|@ts-expect-error| as [A-Z]" src
    

    Setiap hasil harus punya alasan yang kuat, atau diganti dengan narrowing, generics, atau validasi Zod.

    Kode kerangka

    Kerangka berikut sudah diuji pada proyek Vite react-ts (create-vite 9.3.1) dengan TypeScript 7.0.2, strict: true, dan noUncheckedIndexedAccess: true: tsc -b tanpa error dan vite build berhasil.

    src/lib/todo.ts

    // src/lib/todo.ts, satu sumber kebenaran: skema Zod + tipe turunan
    import { z } from "zod";
    
    export const PRIORITAS = ["rendah", "sedang", "tinggi"] as const;
    
    export const SkemaTodo = z.object({
      id: z.number().int(),
      judul: z.string().trim().min(3, "Judul minimal 3 karakter").max(80, "Judul maksimal 80 karakter"),
      selesai: z.boolean(),
      prioritas: z.enum(PRIORITAS),
    });
    export type Todo = z.infer<typeof SkemaTodo>;
    export type Prioritas = Todo["prioritas"];
    export type TodoBaru = Omit<Todo, "id" | "selesai">;
    
    export type Filter = "semua" | "aktif" | "selesai";
    
    // Skema respons API jsonplaceholder (bentuknya beda dengan Todo kita)
    const SkemaTodoApi = z.object({
      id: z.number(),
      title: z.string(),
      completed: z.boolean(),
    });
    
    export async function ambilTodos(signal?: AbortSignal): Promise<Todo[]> {
      const res = await fetch("https://jsonplaceholder.typicode.com/todos?_limit=8", { signal });
      if (!res.ok) throw new Error(`Gagal memuat tugas (HTTP ${res.status})`);
      const data = z.array(SkemaTodoApi).parse(await res.json());
      return data.map((t) => ({ id: t.id, judul: t.title, selesai: t.completed, prioritas: "sedang" }));
    }
    
    export function saring(todos: Todo[], filter: Filter): Todo[] {
      switch (filter) {
        case "semua":
          return todos;
        case "aktif":
          return todos.filter((t) => !t.selesai);
        case "selesai":
          return todos.filter((t) => t.selesai);
      }
    }
    

    src/hooks/useTodos.ts

    // src/hooks/useTodos.ts, state todo dengan useReducer bertipe
    import { useEffect, useReducer } from "react";
    import { ambilTodos } from "../lib/todo";
    import type { Todo, TodoBaru } from "../lib/todo";
    
    type State =
      | { status: "memuat" }
      | { status: "gagal"; pesan: string }
      | { status: "siap"; todos: Todo[] };
    
    type Aksi =
      | { type: "dimuat"; todos: Todo[] }
      | { type: "gagal"; pesan: string }
      | { type: "tambah"; todo: TodoBaru }
      | { type: "toggle"; id: number }
      | { type: "hapus"; id: number };
    
    function reducer(state: State, aksi: Aksi): State {
      switch (aksi.type) {
        case "dimuat":
          return { status: "siap", todos: aksi.todos };
        case "gagal":
          return { status: "gagal", pesan: aksi.pesan };
      }
      // Aksi di bawah hanya masuk akal kalau data sudah siap
      if (state.status !== "siap") return state;
      switch (aksi.type) {
        case "tambah": {
          const idBaru = Math.max(0, ...state.todos.map((t) => t.id)) + 1;
          return { ...state, todos: [{ ...aksi.todo, id: idBaru, selesai: false }, ...state.todos] };
        }
        case "toggle":
          return { ...state, todos: state.todos.map((t) => (t.id === aksi.id ? { ...t, selesai: !t.selesai } : t)) };
        case "hapus":
          return { ...state, todos: state.todos.filter((t) => t.id !== aksi.id) };
      }
    }
    
    export function useTodos() {
      const [state, dispatch] = useReducer(reducer, { status: "memuat" });
    
      useEffect(() => {
        const controller = new AbortController();
        ambilTodos(controller.signal)
          .then((todos) => dispatch({ type: "dimuat", todos }))
          .catch((err: unknown) => {
            if (controller.signal.aborted) return;
            dispatch({ type: "gagal", pesan: err instanceof Error ? err.message : "Terjadi kesalahan" });
          });
        return () => controller.abort();
      }, []);
    
      return {
        state,
        tambah: (todo: TodoBaru) => dispatch({ type: "tambah", todo }),
        toggle: (id: number) => dispatch({ type: "toggle", id }),
        hapus: (id: number) => dispatch({ type: "hapus", id }),
      };
    }
    

    src/components/TodoForm.tsx

    // src/components/TodoForm.tsx, form bertipe + validasi Zod
    import { useState } from "react";
    import type { ChangeEvent, SubmitEvent } from "react";
    import { PRIORITAS, SkemaTodo } from "../lib/todo";
    import type { Prioritas, TodoBaru } from "../lib/todo";
    
    type TodoFormProps = { onTambah: (todo: TodoBaru) => void };
    
    const SkemaForm = SkemaTodo.pick({ judul: true, prioritas: true });
    
    export function TodoForm({ onTambah }: TodoFormProps) {
      const [judul, setJudul] = useState("");
      const [prioritas, setPrioritas] = useState<Prioritas>("sedang");
      const [error, setError] = useState<string | null>(null);
    
      function ubahPrioritas(e: ChangeEvent<HTMLSelectElement>) {
        const hasil = SkemaForm.shape.prioritas.safeParse(e.target.value);
        if (hasil.success) setPrioritas(hasil.data);
      }
    
      function kirim(e: SubmitEvent<HTMLFormElement>) {
        e.preventDefault();
        const hasil = SkemaForm.safeParse({ judul, prioritas });
        if (!hasil.success) {
          setError(hasil.error.issues[0]?.message ?? "Input tidak valid");
          return;
        }
        onTambah(hasil.data);
        setJudul("");
        setError(null);
      }
    
      return (
        <form onSubmit={kirim}>
          <input value={judul} onChange={(e) => setJudul(e.target.value)} placeholder="Tugas baru" />
          <select value={prioritas} onChange={ubahPrioritas}>
            {PRIORITAS.map((p) => (
              <option key={p} value={p}>{p}</option>
            ))}
          </select>
          <button type="submit">Tambah</button>
          {error && <p role="alert">{error}</p>}
        </form>
      );
    }
    

    src/components/TodoItem.tsx

    // src/components/TodoItem.tsx
    import type { Todo } from "../lib/todo";
    
    type TodoItemProps = {
      todo: Todo;
      onToggle: (id: number) => void;
      onHapus: (id: number) => void;
    };
    
    const WARNA: Record<Todo["prioritas"], string> = { rendah: "#16a34a", sedang: "#ca8a04", tinggi: "#dc2626" };
    
    export function TodoItem({ todo, onToggle, onHapus }: TodoItemProps) {
      return (
        <li>
          <label style={{ textDecoration: todo.selesai ? "line-through" : "none" }}>
            <input type="checkbox" checked={todo.selesai} onChange={() => onToggle(todo.id)} />
            {todo.judul}
          </label>
          <span style={{ color: WARNA[todo.prioritas] }}> ({todo.prioritas})</span>
          <button onClick={() => onHapus(todo.id)} aria-label={`Hapus ${todo.judul}`}>×</button>
        </li>
      );
    }
    

    src/components/FilterBar.tsx

    // src/components/FilterBar.tsx, generic component
    type FilterBarProps<T extends string> = {
      opsi: readonly T[];
      aktif: T;
      onPilih: (nilai: T) => void;
    };
    
    export function FilterBar<T extends string>({ opsi, aktif, onPilih }: FilterBarProps<T>) {
      return (
        <div role="group">
          {opsi.map((o) => (
            <button key={o} onClick={() => onPilih(o)} aria-pressed={o === aktif}>
              {o}
            </button>
          ))}
        </div>
      );
    }
    

    src/App.tsx

    // src/App.tsx
    import { useState } from "react";
    import { FilterBar } from "./components/FilterBar";
    import { TodoForm } from "./components/TodoForm";
    import { TodoItem } from "./components/TodoItem";
    import { useTodos } from "./hooks/useTodos";
    import { saring } from "./lib/todo";
    import type { Filter } from "./lib/todo";
    import { formatProgres } from "./lib/format";
    
    const FILTER = ["semua", "aktif", "selesai"] as const satisfies readonly Filter[];
    
    export default function App() {
      const { state, tambah, toggle, hapus } = useTodos();
      const [filter, setFilter] = useState<Filter>("semua");
    
      if (state.status === "memuat") return <p>Memuat tugas...</p>;
      if (state.status === "gagal") return <p role="alert">Error: {state.pesan}</p>;
    
      const tampil = saring(state.todos, filter);
      const selesai = state.todos.filter((t) => t.selesai).length;
    
      return (
        <main>
          <h1>Daftar Tugas</h1>
          <p>{formatProgres(selesai, state.todos.length)}</p>
          <TodoForm onTambah={tambah} />
          <FilterBar opsi={FILTER} aktif={filter} onPilih={setFilter} />
          <ul>
            {tampil.map((t) => (
              <TodoItem key={t.id} todo={t} onToggle={toggle} onHapus={hapus} />
            ))}
          </ul>
        </main>
      );
    }
    

    File JavaScript lama yang dibiarkan selama transisi. Perhatikan komentar JSDoc: dengan checkJs, TypeScript memakainya sebagai tipe, sehingga formatProgres(String(selesai), total) dari App.tsx akan ditolak dengan error TS2345: Argument of type 'string' is not assignable to parameter of type 'number'.

    src/lib/format.js

    // src/lib/format.js, file lama yang BELUM dimigrasi
    /**
     * @param {number} selesai
     * @param {number} total
     * @returns {string}
     */
    export function formatProgres(selesai, total) {
      if (total === 0) return "Belum ada tugas";
      return `${selesai}/${total} selesai (${Math.round((selesai / total) * 100)}%)`;
    }
    

    Beberapa hal yang perlu kamu perhatikan dari kerangka ini:

    Kriteria lulus

    Pengembangan lanjutan

    1. Simpan ke localStorage dengan validasi Zod saat membaca, seperti latihan di modul Data API. Data rusak di penyimpanan tidak boleh membuat aplikasi crash.
    2. Ganti useEffect + useReducer untuk data server dengan TanStack Query (useQuery, useMutation) dan amati bagaimana tipe data mengalir otomatis dari queryFn.
    3. Gunakan React Hook Form + @hookform/resolvers/zod untuk form tambah tugas, memakai z.input untuk tipe nilai form.
    4. Tambahkan pemeriksaan di CI: workflow GitHub Actions yang menjalankan npm ci && npm run typecheck di setiap pull request.
    5. Ketatkan lagi: nyalakan exactOptionalPropertyTypes dan noImplicitOverride, lalu perbaiki error yang muncul. Catat berapa bug yang tertangkap.
    6. Bersiap ke Next.js: pindahkan lib/todo.ts apa adanya ke proyek Next.js di materi berikutnya. Skema dan tipe yang sama bisa dipakai di Server Component, Route Handler, dan Server Action.

    Video pendamping

    Kalau penjelasan di atas belum masuk, tonton ini. Video dari kanal YouTube pihak lain.

    Converting JSX to TSX in ReactCosden Solutions · Bahasa InggrisBuka di YouTube

    Kuis pemahaman

    Pilih satu jawaban per soal. Penjelasan muncul setelah kamu menjawab.