Actions — form dan mutasi yang tervalidasi
Di CI3, validasi adalah sesuatu yang kamu ingat untuk menulis. Di Actions, ia bagian dari definisi fungsinya — dan kode yang melewatkannya tidak akan dikompilasi.
Intisari
- Action adalah fungsi server yang dipanggil dari klien dengan tipe yang terjaga di kedua sisi.
inputdivalidasi Zod sebelumhandlerjalan. Tidak ada jalan masuk yang melewatinya.accept: "form"membuatnya bekerja dengan<form>biasa — termasuk saat JavaScript mati.- Error dikembalikan sebagai nilai (
{ data, error }), bukan dilempar. Kamu dipaksa menanganinya. - Astro memasang perlindungan CSRF untuk action berbasis form; endpoint
.tsbuatanmu sendiri tidak dapat itu.
Mendefinisikan action
// src/actions/index.ts
import { defineAction, ActionError } from "astro:actions";
import { z } from "astro:schema";
import { daftarkanNewsletter } from "@/lib/newsletter";
export const server = {
langganNewsletter: defineAction({
accept: "form",
input: z.object({
email: z.string().email("Format email tidak valid"),
kategori: z.enum(["ekonomi", "pasar", "teknologi"]).default("ekonomi"),
setuju: z.literal("on", { message: "Kamu harus menyetujui ketentuan" }),
}),
handler: async ({ email, kategori }, context) => {
const ip = context.locals.ipPembaca;
if (await terlaluSering(ip)) {
throw new ActionError({
code: "TOO_MANY_REQUESTS",
message: "Terlalu banyak percobaan. Coba lagi beberapa menit lagi.",
});
}
await daftarkanNewsletter(email, kategori);
return { pesan: "Cek emailmu untuk konfirmasi." };
},
}),
};
Saat handler berjalan, email sudah dipastikan string berformat email dan
kategori sudah dipastikan salah satu dari tiga nilai itu. Tidak ada cabang eksekusi yang
melewati validasi — bukan karena kamu disiplin, tapi karena tidak ada jalan lain masuk ke sana.
Bandingkan dengan CI3
public function langganan()
{
$email = $this->input->post('email');
$kategori = $this->input->post('kategori');
// Semuanya opsional. Semuanya bisa lupa.
// $email bisa null, array, atau string 10 MB.
// $kategori bisa string apa pun yang langsung masuk ke query.
$this->newsletter_model->daftar($email, $kategori);
redirect('/terima-kasih');
}
Di CI3, $this->input->post('kategori') bisa berisi apa saja — termasuk array, yang akan
membuat perbandingan string berperilaku aneh, atau nilai yang tidak ada di daftar yang kamu harapkan.
z.enum([...]) menutup seluruh kelas masalah itu dalam satu baris.
Memakainya dari form biasa
---
import { actions } from "astro:actions";
const hasil = Astro.getActionResult(actions.langganNewsletter);
---
{hasil?.error && (
<div class="galat" role="alert">{hasil.error.message}</div>
)}
{hasil?.data && (
<div class="sukses" role="status">{hasil.data.pesan}</div>
)}
<form method="POST" action={actions.langganNewsletter}>
<label>Email <input type="email" name="email" required /></label>
<label>Kategori
<select name="kategori">
<option value="ekonomi">Ekonomi</option>
<option value="pasar">Pasar</option>
<option value="teknologi">Teknologi</option>
</select>
</label>
<label><input type="checkbox" name="setuju" /> Saya setuju</label>
<button type="submit">Berlangganan</button>
</form>
Form ini bekerja tanpa JavaScript sama sekali. action={actions.…} menghasilkan URL
biasa, dan browser mengirim POST seperti form HTML tahun 1997. Untuk portal berita ini penting: sebagian
pembacamu memakai koneksi lambat, peramban dalam aplikasi media sosial, atau perangkat lama di mana
JavaScript gagal dimuat. Form yang hanya bekerja setelah bundel JS berhasil diunduh adalah form yang
kehilangan pendaftar.
Memakainya dari island Vue
import { actions, isInputError } from "astro:actions";
async function kirim() {
const { data, error } = await actions.langganNewsletter({
email: email.value,
kategori: "ekonomi",
setuju: "on",
});
if (isInputError(error)) {
// Error validasi per field
galat.value = error.fields.email?.[0] ?? "Periksa lagi isianmu";
return;
}
if (error) {
galat.value = error.message;
return;
}
pesan.value = data.pesan;
}
data di sini bertipe — TypeScript tahu ia punya field pesan,
karena tipenya disimpulkan dari nilai kembalian handler. Tidak ada as, tidak ada
tebakan. Ini keuntungan yang tidak bisa diberikan endpoint REST buatan sendiri tanpa alat pembangkit tipe
terpisah.
Error sebagai nilai, bukan lemparan
const { data, error } = await actions.langganNewsletter(input);
Kamu tidak bisa mengakses data tanpa melihat bahwa error ada — TypeScript
menandainya sebagai mungkin undefined. Ini kebalikan dari try/catch yang mudah
dilupakan. Kode yang mengabaikan error tidak lolos astro check.
Kode ActionError | Status HTTP | Pakai untuk |
|---|---|---|
BAD_REQUEST | 400 | Input tidak masuk akal secara bisnis |
UNAUTHORIZED | 401 | Belum login |
FORBIDDEN | 403 | Login, tapi bukan haknya |
NOT_FOUND | 404 | Sumber daya tidak ada |
CONFLICT | 409 | Email sudah terdaftar |
TOO_MANY_REQUESTS | 429 | Rate limit |
Kapan Actions, kapan endpoint .ts
| Pakai Actions kalau | Pakai endpoint .ts kalau |
|---|---|
| Dipanggil dari halamanmu sendiri | Dipanggil pihak luar — webhook Midtrans |
| Ingin tipe otomatis di klien | Bentuk request ditentukan pihak lain |
| Ingin form yang jalan tanpa JS | Butuh kendali penuh atas status dan header |
| Ingin CSRF ditangani untukmu | Butuh membaca raw body untuk verifikasi tanda tangan |
Webhook Midtrans wajib endpoint .ts, bukan action. Verifikasi tanda tangannya
dihitung dari body mentah, dan Actions sudah mem-parsing body sebelum kodemu melihatnya. Selain itu
Midtrans tidak mengirim token CSRF-mu. Ini dibahas tuntas di Fase 6 — untuk sekarang, cukup ingat
batasnya.
CSRF: yang kamu dapat dan yang tidak
Astro memeriksa header Origin untuk action berbasis form. Itu menutup kasus umum CSRF tanpa
kamu menulis satu baris pun. Tapi perlindungan itu tidak berlaku untuk endpoint
.ts buatanmu — di sana kamu bertanggung jawab sendiri. Kalau kamu menulis
src/pages/api/hapus-akun.ts yang menerima POST dan bertindak berdasarkan cookie sesi, kamu
baru saja membuat lubang CSRF. Fase 7 membahas cara menutupnya.
Latihan: buat action langganNewsletter lengkap dengan validasi Zod, lalu form yang
memakainya. Uji empat jalur: (1) email valid → sukses; (2) email bukan-email → pesan error
muncul di halaman; (3) matikan JavaScript di browser dan kirim lagi — form harus tetap
bekerja; (4) kirim POST dari curl tanpa header Origin dan amati apa yang
terjadi.
Rangkuman ini sengaja dipangkas ke bagian yang dipakai di roadmap. Buka sumber aslinya saat kamu butuh detail lengkap atau referensi parameter.