Nuxt.js CRUD dalam Satu Komponen
Nuxt.js / Vue.js adalah salah satu framework yang selalu digunakan untuk membantu developer membina website. Ianya dikategorikan sebagai frontend builder, sama seperti Next.js/React.js dan Svelte.js. Nuxt.js adalah go-to saya dalam pembinaan website kerana ianya sangatlah mudah untuk digunakan dan difahami.
Nuxt.js menggunakan Single File Component (SFC) yang mempunyai extension .vue
. SFC ini terdiri daripada 3 bahagian utama iaitu:
- Script - untuk JavaScript logic
- Template - untuk HTML structure
- Style - untuk CSS styling
Dengan cara ini, sesuatu rangka SFC file boleh difahami dengan mudah dan kod lebih tersusun.
CRUD, Masalah dengan Pendekatan Konvensional
Untuk operasi CRUD, sesetengah developer akan membina satu per satu komponen untuk setiap operasi. Contohnya:
1
2
3
ContactCreate.vue
ContactRead.vue
ContactUpdate.vue
Kelemahan pendekatan ini:
- Setiap komponen mempunyai sedikit sahaja perubahan UI dan logic
- Susah untuk maintain - jika satu komponen diubah, semua komponen perlu diubah
- Kod berulang (code duplication)
- Jika terlepas pandang, setiap komponen akan hilang keserasiannya
Penyelesaian: Satu Komponen untuk Semua CRUD
Jadi dalam blog ini, saya akan kongsikan cara membina satu komponen untuk semua operasi CRUD yang lebih efficient dan mudah dimaintain.
Struktur Projek
File structure untuk projek ini (Nuxt.js V4):
1
2
3
4
App/
├── components/
│ └── Contact.vue
└── app.vue
Implementasi
1. Memanggil Komponen dalam app.vue
Dalam app.vue
, kita akan panggil Contact.vue
dan pass dua props:
- mode - untuk menentukan mode operasi (create/read/update)
- model-value - data untuk dipaparkan (jika bukan mode ‘new’)
1
2
3
4
5
<Contact
:mode="mode"
:model-value="mode !== 'new' ? client[0] : null"
v-if="client"
/>
2. Setup Props dalam Contact Component
Dalam Contact Component, kita mulakan dengan defineProps()
:
1
2
3
4
5
6
7
const props = defineProps({
mode: { type: String, default: "" },
modelValue: {
type: Object,
default: null,
},
});
3. Reactive Data Management
Seterusnya kita akan define dan load data daripada props ke component variable:
1
2
3
4
5
6
7
8
9
const form = ref({ ...props.modelValue });
// Watch untuk perubahan pada props
watch(
() => props.modelValue,
(val) => {
form.value = { ...val };
}
);
4. Mode Helpers
Load mode helper untuk memudahkan conditional rendering:
1
2
3
const isView = computed(() => props.mode === "view");
const isEdit = computed(() => props.mode === "edit");
const isNew = computed(() => props.mode === "new");
Kelebihan Pendekatan Ini
✅ DRY (Don’t Repeat Yourself) - Kod tidak berulang
✅ Mudah dimaintain - Satu tempat untuk semua logic
✅ Konsisten - UI dan UX yang seragam
✅ Flexible - Mudah untuk add mode baru
✅ Efficient - Kurang file untuk diurus
5. Contoh Lengkap File Contact.vue
Berikut adalah implementasi lengkap untuk Contact.vue
yang menggabungkan semua konsep di atas:
5.1 Script Section
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
<script setup>
// Props definition
const props = defineProps({
mode: { type: String, default: "" },
modelValue: {
type: Object,
default: null,
},
});
// PocketBase instance
const pb = useNuxtApp().$pb;
// Reactive form data
const form = ref({ ...props.modelValue });
// Watch untuk reload form value jika props berubah
watch(
() => props.modelValue,
(val) => {
form.value = { ...val };
}
);
// Mode helpers untuk conditional rendering
const isView = computed(() => props.mode === "view");
const isEdit = computed(() => props.mode === "edit");
const isNew = computed(() => props.mode === "new");
// File upload handler
const handleFileChange = (event, colName) => {
const files = event.target.files;
form.value[colName] = files[0];
};
// Main submit handler
const handleSubmit = async () => {
if (isNew.value) {
await handleCreate();
} else if (isEdit.value) {
await handleUpdate();
}
};
// Create operation
const handleCreate = async () => {
const formData = new FormData();
Object.entries(form.value).forEach(([key, value]) => {
formData.append(key, value);
});
try {
await pb.collection("contact").create(formData);
reloadNuxtApp();
} catch (err) {
console.error("Create error:", err);
}
};
// Update operation
const handleUpdate = async () => {
const formData = new FormData();
Object.entries(form.value).forEach(([key, value]) => {
formData.append(key, value);
});
try {
await pb.collection("contact").update(form.value.id, formData);
reloadNuxtApp();
} catch (err) {
console.error("Update error:", err);
}
};
</script>
5.2 Template Section
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
<template>
<div>
<form @submit.prevent="handleSubmit">
<!-- Dynamic Title -->
<h2>
</h2>
<!-- Name Field -->
<div>
<label for="name">Name</label>
<input
type="text"
id="name"
placeholder="Enter your name"
v-model="form.name"
:readonly="isView"
required
/>
</div>
<!-- Photo Field -->
<div>
<label for="photo">Photo</label>
<!-- View Mode: Show link to existing photo -->
<div v-if="isView">
<NuxtLink
:to="`${pb.baseURL}/api/files/${props.modelValue.collectionId}/${props.modelValue.id}/${props.modelValue.photo}`"
target="_blank"
>
</NuxtLink>
</div>
<!-- Edit/Create Mode: File input -->
<div v-else>
<input
type="file"
id="photo"
accept="image/*"
@change="handleFileChange($event, 'photo')"
/>
<!-- Show existing file in edit mode -->
<div v-if="isEdit && props.modelValue?.photo">
Current file:
<NuxtLink
:to="`${pb.baseURL}/api/files/${props.modelValue.collectionId}/${props.modelValue.id}/${props.modelValue.photo}`"
target="_blank"
>
</NuxtLink>
</div>
</div>
</div>
<!-- Submit Button (hanya untuk Create/Edit) -->
<button v-if="isEdit || isNew" type="submit">
</button>
</form>
</div>
</template>