Typing Hooks di React
useState, useRef, useReducer dengan TypeScript: generic yang wajib diisi.
Kenapa Hooks Butuh Typing Eksplisit
Hooks React adalah fungsi generic: useState harus menebak tipe state dari nilai awal. Tebakannya sering benar, tapi ada dua kasus di mana tebakan itu salah total dan menghasilkan error yang membingungkan: nilai awal null/undefined, dan ref yang belum terpasang. Modul ini membahas pola yang benar untuk tiap hook plus error klasiknya, supaya kamu tidak menghabiskan sore hari untuk satu baris merah.
Contoh 1: Komponen Profil dengan Fetch Nyata
import { useEffect, useState } from "react";
interface User {
id: string;
nama: string;
email: string;
}
function ProfilPengguna({ userId }: { userId: string }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let batal = false;
setLoading(true);
fetch(`https://api.example.com/users/${userId}`)
.then((res) => res.json())
.then((data: User) => {
if (!batal) setUser(data);
})
.catch(() => {
if (!batal) setError("gagal memuat");
})
.finally(() => {
if (!batal) setLoading(false);
});
return () => {
batal = true;
};
}, [userId]);
if (loading) return <p>Memuat...</p>;
if (error) return <p>{error}</p>;
if (!user) return <p>User tidak ditemukan</p>;
return <h1>{user.nama}</h1>;
}Tiga state di-type eksplisit karena nilai awalnya tidak mewakili tipe akhirnya: user mulai null tapi akan menjadi User, error mulai null tapi akan menjadi string. Cleanup dengan flag batal mencegah set state setelah unmount atau setelah userId berganti.
Contoh 2: Error Klasik useState(null)
const [user, setUser] = useState(null);
// user: null, selamanya
function muat(u: User) {
setUser(u);
// Error: Argument of type 'User' is not assignable to
// parameter of type 'SetStateAction<null>'. (TS2345)
}Tanpa generic, TypeScript mengunci tipe state sebagai null dan menolak semua nilai lain. Ini error React+TS nomor satu yang dialami pemula, dan pesannya (SetStateAction<null>) memang membingungkan. Perbaikannya selalu sama dan wajib dihafal: useState<User | null>(null).
Contoh 3: useRef untuk DOM dan Error Possibly Null
import { useEffect, useRef } from "react";
function FormCari() {
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
inputRef.current.focus();
// Error: 'inputRef.current' is possibly 'null'. (TS18047)
}, []);
function fokus(): void {
inputRef.current?.focus(); // benar: optional chaining
}
return <input ref={inputRef} placeholder="cari..." />;
}useRef<HTMLInputElement>(null) menghasilkan current: HTMLInputElement | null karena ref belum terpasang saat pertama render. Di dalam useEffect setelah mount kamu "tahu" element sudah ada, tapi compiler tidak, jadi pakai ?. atau guard if (inputRef.current). Untuk nilai mutable biasa (bukan DOM), polanya beda: const hitungRef = useRef(0) menghasilkan ref yang current-nya bisa ditulis langsung tanpa null check.
Contoh 4: useReducer dengan Aksi yang Type-Safe
import { useReducer } from "react";
interface State {
jumlah: number;
terakhirDiubah: string | null;
}
type Aksi =
| { type: "tambah"; nilai: number }
| { type: "reset" };
function reducer(state: State, aksi: Aksi): State {
switch (aksi.type) {
case "tambah":
return {
jumlah: state.jumlah + aksi.nilai,
terakhirDiubah: new Date().toISOString(),
};
case "reset":
return { jumlah: 0, terakhirDiubah: null };
}
}
function Counter() {
const [state, dispatch] = useReducer(reducer, {
jumlah: 0,
terakhirDiubah: null,
});
return (
<button onClick={() => dispatch({ type: "tambah", nilai: 1 })}>
{state.jumlah}
</button>
);
}Discriminated union pada Aksi membuat dispatch menolak aksi yang salah bentuk saat kamu menulisnya, bukan saat reducer-nya crash di runtime. Lupa field nilai pada aksi "tambah" langsung merah.
Catatan teknis: Jangan pernah membuat callback
useEffectmenjadiasynclangsung.useEffect(async () => {...})error karena effect harus mengembalikan cleanup function atau void, bukan Promise. Polanya: definisikan fungsi async di dalam effect lalu panggil, atau pakai.then()seperti contoh pertama di atas.
Tantangan
Form dengan state bertipe
Buat komponen FormLogin dengan useState<{ email: string; sandi: string }>({ email: "", sandi: "" }) dan useState<string | null>(null) untuk error. Fungsi kirim() validasi: email wajib mengandung "@", sandi minimal 6 karakter, set error bila gagal.
function FormLogin() {
const [form, setForm] = useState({ email: "", sandi: "" });
const [error, setError] = useState<string | null>(null);
function kirim(): void {
// validasi di sini
}
return null; // UI tidak dinilai
}