Kenapa ini penting
Di React, bug yang paling sering muncul berkaitan dengan props: lupa mengirim props wajib, salah nama props, mengirim string padahal komponen butuh angka, atau memanggil onSelect(id) padahal komponen induk mengharapkan object. Tanpa TypeScript, kesalahan ini baru ketahuan saat halaman dibuka. Dengan TypeScript, setiap komponen menjadi kontrak yang jelas, dan editor menunjukkan props apa saja yang tersedia begitu kamu mengetik <Tombol.
Bayangkan komponen React seperti colokan listrik. Colokan tiga kaki tidak bisa dimasukkan ke stopkontak dua lubang, dan kamu langsung tahu sebelum menyalakan apa pun. Tipe props adalah bentuk colokan itu. Hampir semua proyek React profesional (termasuk Next.js yang akan kamu pelajari berikutnya) memakai file .tsx, jadi pola-pola di modul ini akan kamu pakai setiap hari.
Konsep inti
File .tsx dan tipe React
File yang berisi JSX memakai ekstensi .tsx. Tipe React disediakan oleh paket @types/react dan @types/react-dom (versi 19.3 saat materi ini ditulis), yang sudah terpasang di template Vite react-ts. Kamu bisa mengimpor tipe dengan import type { ReactNode } from "react" atau memakai namespace React.ReactNode setelah import React from "react". Di materi ini kita memakai import type.
Props type
type KartuProdukProps = {
nama: string;
harga: number;
diskon?: number; // opsional
onBeli: (nama: string) => void; // callback
};
function KartuProduk({ nama, harga, diskon = 0, onBeli }: KartuProdukProps) {
return <button onClick={() => onBeli(nama)}>{nama} - Rp{harga - diskon}</button>;
}
Tidak perlu menulis return type komponen; TypeScript menyimpulkannya. Hindari React.FC untuk komponen baru: ia tidak memberi manfaat tambahan dan dulu menambahkan children secara diam-diam. Function biasa dengan props bertipe sudah cukup.
children
children adalah props biasa. Tipe yang paling umum: ReactNode, yang mencakup elemen JSX, string, angka, null, array, dan lain-lain.
import type { ReactNode } from "react";
type KotakProps = { judul: string; children: ReactNode };
Ada juga PropsWithChildren<P> yang menambahkan children?: ReactNode ke props P.
Mewarisi props elemen HTML
Untuk komponen yang membungkus elemen bawaan (tombol, input), jangan tulis ulang onClick, disabled, type, dan puluhan atribut lain. Pakai ComponentProps<"button">:
import type { ComponentProps } from "react";
type TombolProps = ComponentProps<"button"> & { varian?: "utama" | "bahaya" };
Tipe event
| Event | Tipe di @types/react 19.3 |
Contoh |
|---|---|---|
| Klik | MouseEvent<HTMLButtonElement> |
onClick |
| Ketik di input | ChangeEvent<HTMLInputElement> |
onChange pada <input> |
| Pilih di select | ChangeEvent<HTMLSelectElement> |
onChange pada <select> |
| Submit form | SubmitEvent<HTMLFormElement> |
onSubmit |
| Tombol keyboard | KeyboardEvent<HTMLInputElement> |
onKeyDown |
| Fokus | FocusEvent<HTMLInputElement> |
onBlur |
Catatan versi: di @types/react 19.3, tipe FormEvent sudah ditandai deprecated dengan keterangan "FormEvent doesn't actually exist". Untuk onSubmit, pakai SubmitEvent<HTMLFormElement>. Tutorial lama yang memakai React.FormEvent masih jalan, tapi editor akan mencoretnya.
Trik termudah: kalau handler ditulis langsung di JSX (onChange={(e) => ...}), kamu tidak perlu menulis tipe event sama sekali karena sudah disimpulkan. Arahkan kursor ke e di editor untuk melihat tipenya, lalu salin kalau handler perlu dipindah ke function terpisah.
Event di sini adalah tipe milik React (diimpor dari "react"), bukan MouseEvent bawaan DOM. Kalau kamu lupa mengimpornya, TypeScript akan memakai MouseEvent global milik DOM dan muncul error yang membingungkan.
Hooks dengan tipe
useState: tipe disimpulkan dari nilai awal. Tulis manual kalau nilai awal tidak mewakili semua kemungkinan:useState<User | null>(null),useState<string[]>([]).useRefuntuk elemen DOM:useRef<HTMLInputElement>(null). HasilnyaRefObject<HTMLInputElement | null>, sehingga kamu harus memeriksaref.currentsebelum dipakai. Di React 19,useRefwajib diberi nilai awal.useRefuntuk nilai yang bisa diubah (misalnya id timer):useRef<number | undefined>(undefined).useReducer: beri tipe padastatedanaction(pakai discriminated union untuk action), TypeScript menyimpulkan sisanya.useContext: buat context dengan tipecreateContext<NilaiContext | null>(null)lalu bungkus dengan custom hook yang melempar error kalau dipakai di luar Provider.
Generic component
Komponen juga bisa generic, misalnya daftar yang bisa menampilkan item apa saja:
import type { ReactNode } from "react";
type DaftarProps<T> = {
items: T[];
render: (item: T) => ReactNode;
ambilKey: (item: T) => string | number;
};
function Daftar<T>({ items, render, ambilKey }: DaftarProps<T>) {
return <ul>{items.map((item) => <li key={ambilKey(item)}>{render(item)}</li>)}</ul>;
}
Saat dipakai dengan items={produk}, TypeScript tahu item di render adalah Produk.
Contoh kode
// Komponen.tsx, props, children, event, dan props HTML
import { useState } from "react";
import type { ChangeEvent, ComponentProps, ReactNode, SubmitEvent } from "react";
// 1. Membungkus <button> tanpa menulis ulang atributnya
type TombolProps = ComponentProps<"button"> & {
varian?: "utama" | "bahaya";
};
export function Tombol({ varian = "utama", className = "", ...sisa }: TombolProps) {
const warna = varian === "utama" ? "bg-blue-600" : "bg-red-600";
return <button className={`${warna} rounded px-4 py-2 text-white ${className}`} {...sisa} />;
}
// 2. children
type KartuProps = { judul: string; children: ReactNode };
export function Kartu({ judul, children }: KartuProps) {
return (
<section className="rounded border p-4">
<h2 className="font-bold">{judul}</h2>
{children}
</section>
);
}
// 3. Form dengan event bertipe
type FormLoginProps = { onLogin: (email: string) => void };
export function FormLogin({ onLogin }: FormLoginProps) {
const [email, setEmail] = useState(""); // string, disimpulkan
function ubahEmail(e: ChangeEvent<HTMLInputElement>) {
setEmail(e.target.value);
}
function kirim(e: SubmitEvent<HTMLFormElement>) {
e.preventDefault();
onLogin(email.trim());
}
return (
<form onSubmit={kirim}>
<input type="email" value={email} onChange={ubahEmail} />
<Tombol type="submit" disabled={email === ""}>Masuk</Tombol>
</form>
);
}
// 4. Pemakaian dan kesalahan yang ditangkap
export function Halaman() {
return (
<Kartu judul="Login">
<FormLogin onLogin={(email) => console.log("Login:", email)} />
<Tombol varian="hapus">Batal</Tombol> {/* โ error TS2322: Type '"hapus"' is not assignable to type '"bahaya" | "utama" | undefined'. */}
</Kartu>
);
}
export function HalamanLain() {
return <Kartu>Isi tanpa judul</Kartu>; // โ error TS2741: Property 'judul' is missing in type '{ children: string; }' but required in type 'KartuProps'.
}
Hooks dengan tipe: useState, useRef, dan useReducer:
// Keranjang.tsx
import { useReducer, useRef, useState } from "react";
type User = { id: number; nama: string };
type Item = { id: number; nama: string; harga: number; jumlah: number };
type Aksi =
| { type: "tambah"; item: Omit<Item, "jumlah"> }
| { type: "kurangi"; id: number }
| { type: "kosongkan" };
function reducer(state: Item[], aksi: Aksi): Item[] {
switch (aksi.type) {
case "tambah": {
const ada = state.find((i) => i.id === aksi.item.id);
if (ada) return state.map((i) => (i.id === aksi.item.id ? { ...i, jumlah: i.jumlah + 1 } : i));
return [...state, { ...aksi.item, jumlah: 1 }];
}
case "kurangi":
return state
.map((i) => (i.id === aksi.id ? { ...i, jumlah: i.jumlah - 1 } : i))
.filter((i) => i.jumlah > 0);
case "kosongkan":
return [];
}
}
export function Keranjang() {
const [items, dispatch] = useReducer(reducer, []);
const [user, setUser] = useState<User | null>(null); // nilai awal null, nanti User
const inputCatatan = useRef<HTMLInputElement>(null); // RefObject<HTMLInputElement | null>
const total = items.reduce((t, i) => t + i.harga * i.jumlah, 0);
function fokusCatatan() {
inputCatatan.current?.focus(); // ?. karena current bisa null
}
return (
<div>
<p>{user ? `Halo, ${user.nama}` : "Belum login"}</p>
<button onClick={() => setUser({ id: 1, nama: "Sari" })}>Login demo</button>
<button onClick={() => dispatch({ type: "tambah", item: { id: 7, nama: "Kopi", harga: 25000 } })}>
Tambah kopi
</button>
<button onClick={() => dispatch({ type: "kosongkan" })}>Kosongkan</button>
<button onClick={() => dispatch({ type: "hapus", id: 7 })}>Hapus</button> {/* โ error TS2322: Type '"hapus"' is not assignable to type '"kosongkan" | "kurangi" | "tambah"'. */}
<input ref={inputCatatan} placeholder="Catatan" />
<button onClick={fokusCatatan}>Tulis catatan</button>
<p>Total: Rp{total.toLocaleString("id-ID")}</p>
</div>
);
}
Perhatikan reducer tidak butuh default karena semua tipe aksi ditangani dan TypeScript tahu function selalu mengembalikan Item[]. Kalau kamu menambah aksi baru tanpa menanganinya, muncul error "Function lacks ending return statement".
Generic component dan context bertipe:
// DaftarGenerik.tsx
import { createContext, useContext, useState } from "react";
import type { ReactNode } from "react";
// 1. Generic component
type DaftarProps<T> = {
items: T[];
ambilKey: (item: T) => string | number;
render: (item: T) => ReactNode;
kosong?: ReactNode;
};
export function Daftar<T>({ items, ambilKey, render, kosong = "Tidak ada data" }: DaftarProps<T>) {
if (items.length === 0) return <p>{kosong}</p>;
return <ul>{items.map((item) => <li key={ambilKey(item)}>{render(item)}</li>)}</ul>;
}
type Produk = { kode: string; nama: string; stok: number };
const produk: Produk[] = [
{ kode: "P1", nama: "Pulpen", stok: 12 },
{ kode: "P2", nama: "Map", stok: 0 },
];
export function HalamanProduk() {
return (
<Daftar
items={produk}
ambilKey={(p) => p.kode}
render={(p) => `${p.nama} (${p.stok > 0 ? `stok ${p.stok}` : "habis"})`} // p: Produk
/>
);
}
// 2. Context bertipe + custom hook pengaman
type Tema = "terang" | "gelap";
type NilaiTema = { tema: Tema; ganti: () => void };
const TemaContext = createContext<NilaiTema | null>(null);
export function TemaProvider({ children }: { children: ReactNode }) {
const [tema, setTema] = useState<Tema>("terang");
const ganti = () => setTema((t) => (t === "terang" ? "gelap" : "terang"));
return <TemaContext value={{ tema, ganti }}>{children}</TemaContext>;
}
export function useTema(): NilaiTema {
const nilai = useContext(TemaContext);
if (!nilai) throw new Error("useTema harus dipakai di dalam <TemaProvider>");
return nilai; // bertipe NilaiTema, bukan NilaiTema | null
}
Di React 19, <TemaContext value={...}> bisa dipakai langsung sebagai provider (tidak perlu .Provider). Custom hook useTema membuat semua komponen pemakai terbebas dari pemeriksaan null.
Kesalahan umum
- Memakai
anyuntuk props atau event. Kamu kehilangan autocomplete dan pemeriksaan. Tulis tipe props, dan untuk event biarkan disimpulkan dari JSX atau salin tipe dari hover editor. useState(null)tanpa tipe. Tipe state menjadinullselamanya, dansetUser(dataUser)error. TulisuseState<User | null>(null).- Mengakses
ref.currenttanpa memeriksanull. Saat render pertama dan setelah elemen dilepas,currentbernilainull. Pakairef.current?.focus()atau periksa dulu. - Menulis ulang atribut HTML secara manual.
type TombolProps = { onClick: ...; disabled: ...; type: ... }melelahkan dan pasti ada yang terlewat. PakaiComponentProps<"button">. - Memakai
React.FormEventdari tutorial lama. Di@types/react19.3 tipe ini deprecated. UntukonSubmitpakaiSubmitEvent<HTMLFormElement>.
Latihan
- Mudah, Buat komponen
Badgedengan propsteks: stringdanwarna?: "hijau" | "merah" | "abu"(default"abu"). - Sedang, Buat komponen
InputAngkayang menerimanilai: number,onUbah: (n: number) => void, dan semua props<input>lain kecualivalue,onChange, dantype(pakaiOmit<ComponentProps<"input">, ...>). - Menantang, Buat komponen generic
Pilihan<T>(dropdown) dengan propsopsi: T[],nilai: T,onPilih: (v: T) => void,label: (v: T) => string, danambilKey: (v: T) => string.
Lihat solusi
import type { ChangeEvent, ComponentProps } from "react";
// 1. Mudah
type BadgeProps = { teks: string; warna?: "hijau" | "merah" | "abu" };
export function Badge({ teks, warna = "abu" }: BadgeProps) {
const kelas = { hijau: "bg-green-100 text-green-800", merah: "bg-red-100 text-red-800", abu: "bg-gray-100 text-gray-800" };
return <span className={`rounded px-2 py-1 text-xs ${kelas[warna]}`}>{teks}</span>;
}
// 2. Sedang
type InputAngkaProps = Omit<ComponentProps<"input">, "value" | "onChange" | "type"> & {
nilai: number;
onUbah: (n: number) => void;
};
export function InputAngka({ nilai, onUbah, ...sisa }: InputAngkaProps) {
function ubah(e: ChangeEvent<HTMLInputElement>) {
const n = e.target.valueAsNumber;
onUbah(Number.isNaN(n) ? 0 : n);
}
return <input type="number" value={nilai} onChange={ubah} {...sisa} />;
}
// 3. Menantang
type PilihanProps<T> = {
opsi: T[];
nilai: T;
onPilih: (v: T) => void;
label: (v: T) => string;
ambilKey: (v: T) => string;
};
export function Pilihan<T>({ opsi, nilai, onPilih, label, ambilKey }: PilihanProps<T>) {
function ubah(e: ChangeEvent<HTMLSelectElement>) {
const dipilih = opsi.find((o) => ambilKey(o) === e.target.value);
if (dipilih !== undefined) onPilih(dipilih);
}
return (
<select value={ambilKey(nilai)} onChange={ubah}>
{opsi.map((o) => (
<option key={ambilKey(o)} value={ambilKey(o)}>{label(o)}</option>
))}
</select>
);
}
// Pemakaian
type Kota = { id: string; nama: string };
const daftarKota: Kota[] = [{ id: "bdg", nama: "Bandung" }, { id: "jkt", nama: "Jakarta" }];
export function Contoh() {
return (
<>
<Badge teks="Aktif" warna="hijau" />
<InputAngka nilai={3} onUbah={(n) => console.log(n)} min={0} placeholder="Jumlah" />
<Pilihan
opsi={daftarKota}
nilai={daftarKota[0]!}
onPilih={(k) => console.log(k.nama)}
label={(k) => k.nama}
ambilKey={(k) => k.id}
/>
</>
);
}
Ringkasan
- Tulis tipe props dengan
typeatauinterface; tidak perluReact.FCmaupun return type komponen. childrenbertipeReactNode; untuk membungkus elemen HTML pakaiComponentProps<"button">dan sejenisnya.- Event bertipe dari
"react":ChangeEvent<HTMLInputElement>,MouseEvent<...>, danSubmitEvent<HTMLFormElement>(bukanFormEventyang kini deprecated). useState<T | null>(null)untuk state yang awalnya kosong,useRef<HTMLInputElement>(null)untuk elemen DOM, dan discriminated union untuk actionuseReducer.- Komponen generic (
function Daftar<T>) membuat komponen daftar/dropdown aman untuk tipe data apa pun.