Modul Belajar · Node.js & React
Belajar Node.js dan React dari Nol
Panduan paling awal sampai aplikasi berjalan. Kita mulai dari membuat folder, menulis setiap file beserta kodenya — backend Express dulu, lalu frontend React — sampai dua server menyala dan data armada bisa dikelola lengkap dengan harga dan status ketersediaan.
Alur data saat mode pengembangan.
Apa yang akan kita bangun?
Sukarobot adalah aplikasi pengelolaan data armada robot. Satu halaman berisi daftar armada lengkap dengan harga dan status ketersediaan, kolom pencarian, formulir tambah/ubah, dan tombol hapus dengan konfirmasi. Semua data disimpan di MySQL melalui API.
Urutan yang akan kita tempuh
- Siapkan Node.js dan MySQL.
- Buat folder
backenddanfrontend. - Tulis 3 file backend:
package.json,db.js,server.js. - Jalankan backend dan uji API-nya.
- Tulis 9 file frontend: konfigurasi, halaman, CSS, komponen, dan
App.jsx. - Jalankan frontend dan coba semua fitur CRUD.
- Pelajari cara build dan mempublikasikannya.
Ikuti berurutan. Setiap file ditampilkan lengkap dan punya tombol Salin. Buat filenya di folder yang disebutkan, tempel kodenya, lalu lanjut ke file berikutnya. Jangan menyalin setengah bagian.
Pasang alat yang dibutuhkan
Sebelum menulis kode, pastikan Node.js dan MySQL sudah terpasang. Keduanya gratis dan berjalan di komputer sendiri.
-
Pasang Node.js 18 atau lebih baru
Unduh installer LTS dari nodejs.org, jalankan dengan opsi bawaan, lalu buka terminal baru.
terminalnode -v npm -vAngka pertama harus 18 atau lebih, misalnya
v20.11.0. Kalau perintah tidak dikenali, tutup lalu buka ulang terminal. -
Pasang MySQL
Pilih MySQL Community Server, MariaDB, atau Laragon (Windows). Ingat host, user, dan password yang kamu set. MySQL harus menyala setiap kali backend dijalankan.
-
Siapkan terminal dan editor
Windows: Git Bash atau PowerShell. macOS/Linux: Terminal. Editor: VS Code atau editor lain. Modul ini memakai perintah format bash.
Kalau MySQL belum menyala, backend akan berhenti dengan pesan gagal terhubung ke mysql. Nyalakan MySQL lebih dulu (Laragon: Start All), baru jalankan backend.
Cek cepat sebelum lanjut
node -vmenampilkan versi 18 atau lebih.npm -vmenampilkan versi npm.- MySQL menyala dan kamu tahu user/password-nya.
- Kamu punya folder kosong untuk project ini.
Buat kerangka project
Project terdiri dari dua aplikasi terpisah. Keduanya punya package.json
sendiri, jadi nanti npm install dijalankan dua kali.
Folder yang akan dibuat
sukarobot/
├─ backend/
│ ├─ package.json
│ ├─ db.js
│ └─ server.js
├─ frontend/
│ ├─ package.json
│ ├─ vite.config.js
│ ├─ index.html
│ └─ src/
│ ├─ main.jsx
│ ├─ App.jsx
│ ├─ api.js
│ ├─ styles.css
│ └─ components/
│ ├─ armadaList.jsx
│ └─ armadaForm.jsx
├─ index.php
└─ README.mdPerintah membuat folder
Jalankan dari folder tempat kamu menyimpan project:
mkdir sukarobot
cd sukarobot
mkdir backend
mkdir -p frontend/src/components
Di PowerShell, mkdir -p tidak dikenal. Gunakan
mkdir frontend\src\components satu per satu, atau tetap pakai Git Bash.
Siapa melakukan apa?
backend/db.js— menyambung ke MySQL dan membuat database/tabel.backend/server.js— menyediakan route API/api/armada.frontend/src/api.js— satu tempat untuk semua pemanggilan API.frontend/src/App.jsx— mengatur daftar, pencarian, dan formulir.frontend/src/components/— kartu daftar dan form.
Backend dan frontend berjalan sebagai proses terpisah. Nanti kamu akan membuka dua terminal: satu untuk API di port 5000, satu untuk Vite di port 5173.
Daftar dependency backend
backend/package.json
File ini memberi tahu npm apa saja yang dibutuhkan backend. Kita memakai tiga paket:
express untuk server, cors agar frontend boleh memanggil API,
dan mysql2 untuk koneksi database.
Langkah 1 — buat filenya
Di dalam folder backend, buat file bernama package.json, lalu isi dengan kode berikut:
{
"name": "siswa-backend-mysql",
"version": "1.0.0",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "node --watch server.js"
},
"dependencies": {
"cors": "^2.8.6",
"express": "^5.3.0",
"mysql2": "^3.24.6"
}
}Langkah 2 — pasang dependency
cd backend
npm installPerintah ini membaca package.json lalu mengunduh Express, CORS, dan mysql2 ke folder node_modules.
npm run dev menjalankan node --watch server.js (restart otomatis saat file disimpan). npm start menjalankan node server.js tanpa watch.
npm install harus dijalankan di folder yang berisi package.json. Menjalankannya di root project akan membuat folder node_modules yang tidak dipakai.
Menyambung ke MySQL
backend/db.js
File ini menyiapkan koneksi, membuat database sukarobot bila belum ada,
membuat tabel armada, lalu menyediakan fungsi query() yang
dipakai oleh route API.
Buat file backend/db.js dan isi lengkapnya:
const mysql = require('mysql2/promise');
const config = {
host: process.env.DB_HOST || 'localhost',
user: process.env.DB_USER || 'root',
password: process.env.DB_PASSWORD || '',
database: process.env.DB_NAME || 'sukarobot'
};
let pool;
// buat database + tabel, lalu siapkan pool koneksi
async function init() {
// koneksi awal tanpa database, supaya database bisa dibuat
const awal = await mysql.createConnection({
host: config.host,
user: config.user,
password: config.password
});
await awal.query(`CREATE DATABASE IF NOT EXISTS \`${config.database}\``);
await awal.end();
pool = mysql.createPool({
...config,
dateStrings: true,
connectionLimit: 10,
});
await pool.query(`
CREATE TABLE IF NOT EXISTS armada (
id INT AUTO_INCREMENT PRIMARY KEY,
nama VARCHAR(255) NOT NULL,
tipe VARCHAR(255) NOT NULL,
deskripsi VARCHAR(255) NOT NULL,
harga INT NOT NULL DEFAULT 0,
tersedia BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) CHARACTER SET utf8mb4
`);
// database dari versi lama: tambahkan kolom baru tanpa menghapus data
const [kolom] = await pool.query('SHOW COLUMNS FROM armada');
const namaKolom = kolom.map((item) => item.Field);
if (!namaKolom.includes('harga')) {
await pool.query('ALTER TABLE armada ADD COLUMN harga INT NOT NULL DEFAULT 0 AFTER deskripsi');
}
if (!namaKolom.includes('tersedia')) {
await pool.query('ALTER TABLE armada ADD COLUMN tersedia BOOLEAN NOT NULL DEFAULT TRUE AFTER harga');
}
}
const query = async (sql, params) => {
if (!pool) throw new Error('Database belum diinisialisasi');
return pool.query(sql, params);
};
module.exports = { init, query };Hal penting di file ini
require('mysql2/promise')dipakai agar bisaawait. Versimysql2biasa memakai callback.init()membuat koneksi awal tanpa nama database, karena database-nya belum ada.- Setelah database dibuat, barulah pool koneksi dan tabel disiapkan.
- Tabel menyimpan
hargasebagai angka Rupiah dantersediasebagai boolean. SHOW COLUMNSlaluALTER TABLEmenambahkan kolom baru ke database versi lama tanpa menghapus data.- Password default kosong; bisa diganti lewat environment variable
DB_PASSWORDtanpa mengedit file.
| Variable | Default | Keterangan |
|---|---|---|
DB_HOST | localhost | Lokasi server MySQL |
DB_USER | root | User database |
DB_PASSWORD | kosong | Password database |
DB_NAME | sukarobot | Nama database |
Contoh menjalankan backend dengan password MySQL (pilih sesuai shell):
# Git Bash, macOS, Linux
DB_PASSWORD=rahasia npm run dev
# PowerShell (Windows)
$env:DB_PASSWORD="rahasia"; npm run dev
# Command Prompt (Windows)
set DB_PASSWORD=rahasia && npm run devCREATE TABLE IF NOT EXISTS tidak mengubah tabel yang sudah ada. Kalau menambah kolom baru lagi, tambahkan pemeriksaan SHOW COLUMNS + ALTER TABLE seperti pada harga dan tersedia. Untuk mengubah tipe kolom yang sudah ada, gunakan ALTER TABLE.
Membuat route API
backend/server.js
Inilah inti backend: validasi input, lima route CRUD, penanganan error, dan menyalakan server setelah database siap.
Buat file backend/server.js dan isi lengkapnya:
const express = require('express');
const cors = require('cors');
const db = require('./db');
const app = express();
const PORT = process.env.PORT || 5000;
app.use(cors());
app.use(express.json());
function validasi(body) {
const hargaMentah = String(body?.harga ?? '').trim();
const data = {
nama: String(body?.nama ?? '').trim(),
tipe: String(body?.tipe ?? '').trim(),
deskripsi: String(body?.deskripsi ?? '').trim(),
harga: Number(hargaMentah),
tersedia: body?.tersedia ?? true
};
if (!data.nama) return { error: 'Nama tidak boleh kosong' };
if (!data.tipe) return { error: 'Tipe tidak boleh kosong' };
if (!data.deskripsi) return { error: 'Deskripsi tidak boleh kosong' };
if (hargaMentah === '' || !Number.isInteger(data.harga) || data.harga < 0) {
return { error: 'Harga harus berupa angka bulat 0 atau lebih' };
}
if (typeof data.tersedia !== 'boolean') {
return { error: 'Tersedia harus berupa boolean' };
}
return { data };
}
function tanganiError(err, res) {
if (err.code === 'ER_DUP_ENTRY') {
return res.status(400).json({ error: 'Data sudah ada' });
}
console.error(err);
return res.status(500).json({ error: 'Terjadi kesalahan pada server' });
}
const ambilSatu = async (id) => {
const [rows] = await db.query('SELECT * FROM armada WHERE id = ?', [id]);
return rows[0];
};
// mysql menyimpan boolean sebagai 1/0, ubah kembali menjadi true/false
const keObjek = (row) => row ? { ...row, tersedia: Boolean(row.tersedia) } : row;
// read semua armada (bisa dicari lewat ?q=)
app.get('/api/armada', async (req, res) => {
try {
const q = String(req.query.q ?? '').trim();
const [rows] = await db.query(
'SELECT * FROM armada WHERE nama LIKE ? OR tipe LIKE ? OR deskripsi LIKE ? ORDER BY id DESC',
[`%${q}%`, `%${q}%`, `%${q}%`]
);
res.json(rows.map(keObjek));
} catch (err) {
tanganiError(err, res);
}
});
// read satu armada
app.get('/api/armada/:id', async (req, res) => {
try {
const row = await ambilSatu(req.params.id);
if (!row) return res.status(404).json({ error: 'Data tidak ditemukan' });
res.json(keObjek(row));
} catch (err) {
tanganiError(err, res);
}
});
// create armada
app.post('/api/armada', async (req, res) => {
const { data, error } = validasi(req.body);
if (error) return res.status(400).json({ error });
try {
const [hasil] = await db.query(
'INSERT INTO armada (nama, tipe, deskripsi, harga, tersedia) VALUES (?, ?, ?, ?, ?)',
[data.nama, data.tipe, data.deskripsi, data.harga, data.tersedia]
);
const row = await ambilSatu(hasil.insertId);
res.status(201).json(keObjek(row));
} catch (err) {
tanganiError(err, res);
}
});
// update armada
app.put('/api/armada/:id', async (req, res) => {
const { data, error } = validasi(req.body);
if (error) return res.status(400).json({ error });
try {
if (!await ambilSatu(req.params.id)) return res.status(404).json({ error: 'Data tidak ditemukan' });
await db.query(
'UPDATE armada SET nama = ?, tipe = ?, deskripsi = ?, harga = ?, tersedia = ? WHERE id = ?',
[data.nama, data.tipe, data.deskripsi, data.harga, data.tersedia, req.params.id]
);
const row = await ambilSatu(req.params.id);
res.json(keObjek(row));
} catch (err) {
tanganiError(err, res);
}
});
// delete armada
app.delete('/api/armada/:id', async (req, res) => {
try {
if (!await ambilSatu(req.params.id)) return res.status(404).json({ error: 'Data tidak ditemukan' });
await db.query('DELETE FROM armada WHERE id = ?', [req.params.id]);
res.json({ message: 'Data berhasil dihapus' });
} catch (err) {
tanganiError(err, res);
}
});
db.init()
.then(() => {
app.listen(PORT, () => console.log(`api berjalan di http://localhost:${PORT}`));
})
.catch((err) => {
console.error('gagal terhubung ke mysql', err.message);
console.error('Pastikan MySQL sudah menyala dan user/password di backend/db.js benar.');
process.exit(1);
});Bagian yang perlu dipahami
app.use(express.json())membuat body JSON bisa dibaca sebagaireq.body.validasi()memotong spasi, menolak kolom kosong, mewajibkanhargabilangan bulat 0 atau lebih, dan memastikantersediaberupa boolean (defaulttruebila tidak dikirim).keObjek()mengubahtersediadari angka 1/0 milik MySQL menjaditrue/falsedi JSON.- Semua query memakai tanda
?(prepared statement), bukan menyambung teks SQL. tanganiError()mengubah error duplikat menjadi pesan Data sudah ada, sisanya 500.db.init()dijalankan dulu; server baru menyala setelah database siap.
Frontend memanggil /api/armada dan Vite meneruskan path itu apa adanya. Karena itu semua route di backend wajib diawali /api.
Nyalakan server dan uji API
Saat pertama dijalankan, backend membuat database dan tabel secara otomatis. Kalau berhasil, kamu akan melihat pesan alamat server.
-
Pastikan MySQL menyala
Buka Laragon lalu Start All, atau jalankan service MySQL.
-
Jalankan backend
terminal
cd backend npm run devOutput yang diharapkan:
outputapi berjalan di http://localhost:5000 -
Uji dari terminal lain
Biarkan server tetap berjalan, buka terminal baru, lalu jalankan:
terminal# Ambil semua armada (awalnya array kosong) curl http://localhost:5000/api/armada # Tambah satu armada curl -X POST http://localhost:5000/api/armada \ -H "Content-Type: application/json" \ -d '{"nama":"Robot Alpha","tipe":"Otonom","deskripsi":"Robot pengantar barang","harga":150000,"tersedia":true}'GET pertama mengembalikan
[]bila tabel masih kosong. POST mengembalikan objek denganiddan timestamp.
Kamu tidak perlu membuat database lewat phpMyAdmin. Cukup pastikan MySQL menyala dan kredensial di db.js benar.
Jalankan PORT=5001 npm run dev, lalu ubah proxy di frontend/vite.config.js menjadi http://localhost:5001. Keduanya harus sama.
Menyiapkan React + Vite
Frontend memakai React 18 dan Vite 5. Dua file berikut mengatur dependency dan
cara Vite meneruskan request /api ke backend.
frontend/package.json
frontend/package.json
Buat file ini di dalam folder frontend:
{
"name": "frontend",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs",
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.7.0",
"vite": "^5.4.21"
}
}frontend/vite.config.js
frontend/vite.config.js
File ini menyalakan plugin React, mengatur port 5173, dan meneruskan /api ke port 5000:
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
'/api': 'http://localhost:5000',
},
},
});Pasang dependency frontend
Buka terminal baru (biarkan backend tetap jalan), lalu:
cd frontend
npm installDengan proxy, kode frontend cukup memanggil /api/armada. Saat mode pengembangan, Vite yang meneruskan ke http://localhost:5000. Tanpa proxy, browser akan mencari API di port 5173 dan mendapat 404.
Halaman pembungkus
frontend/index.html
Ini satu-satunya file HTML. Isinya elemen #root tempat React menggambar
seluruh tampilan, plus pemuatan font dan favicon.
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Belajar Node Js dan Reactt</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect width='100' height='100' rx='22' fill='%231f6f5c'/><rect x='26' y='34' width='48' height='34' rx='10' fill='%23f4f3ee'/><circle cx='39' cy='51' r='5' fill='%231f6f5c'/><circle cx='61' cy='51' r='5' fill='%231f6f5c'/></svg>" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,500;12..96,700&family=Figtree:wght@400;500;600&display=swap" rel="stylesheet" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>Tiga hal yang penting: <div id="root"> harus ada, script memakai type="module", dan alamatnya menunjuk ke /src/main.jsx.
Titik masuk React
frontend/src/main.jsx
File ini mengambil elemen #root, memuat komponen App, dan
memuat CSS global. Tidak perlu diubah lagi setelah dibuat.
import React from 'react';
import { createRoot } from 'react-dom/client';
import App from './App.jsx';
import './styles.css';
createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>
);Di mode pengembangan, React.StrictMode menjalankan efek dua kali untuk membantu menemukan bug. Ini normal dan tidak terjadi di build produksi.
Tampilan aplikasi
frontend/src/styles.css
Satu file CSS berisi semua gaya: layout, tombol, form, kartu, dan responsif. Tidak ada framework CSS. Salin seluruhnya — file ini memang panjang.
:root {
--bg: #f4f3ee;
--surface: #ffffff;
--ink: #1b1c1a;
--muted: #6a6b64;
--line: #e1dfd6;
--accent: #1f6f5c;
--accent-dark: #175446;
--danger: #a13a2c;
--success-bg: #e7f3ec;
--success-ink: #1c5b34;
--error-bg: #fbe9e6;
--error-ink: #8c2d20;
--radius: 12px;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: var(--bg);
color: var(--ink);
font-family: 'Figtree', system-ui, -apple-system, sans-serif;
line-height: 1.55;
-webkit-font-smoothing: antialiased;
}
h1,
h2,
h3 {
font-family: 'Bricolage Grotesque', 'Figtree', sans-serif;
line-height: 1.15;
margin: 0;
}
.page {
max-width: 960px;
margin: 0 auto;
padding: 56px 20px 80px;
}
.header {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 24px;
flex-wrap: wrap;
margin-bottom: 28px;
}
.eyebrow {
margin: 0 0 8px;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--accent);
}
h1 {
font-size: clamp(30px, 5vw, 44px);
letter-spacing: -0.02em;
}
.subtitle {
margin: 10px 0 0;
max-width: 46ch;
color: var(--muted);
}
.toolbar {
display: flex;
gap: 12px;
margin-bottom: 24px;
}
.search {
flex: 1;
padding: 12px 14px;
font: inherit;
color: var(--ink);
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
outline: none;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.search:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(31, 111, 92, 0.14);
}
.alert {
margin: 0 0 20px;
padding: 12px 16px;
border-radius: var(--radius);
font-size: 14px;
}
.alert-success {
background: var(--success-bg);
color: var(--success-ink);
}
.alert-error {
background: var(--error-bg);
color: var(--error-ink);
}
/* Form */
.panel {
display: grid;
gap: 16px;
margin-bottom: 28px;
padding: 24px;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
}
.panel h2 {
font-size: 20px;
}
.panel label {
display: grid;
gap: 6px;
font-size: 14px;
font-weight: 600;
color: var(--muted);
}
.panel input,
.panel textarea {
width: 100%;
padding: 11px 13px;
font: inherit;
color: var(--ink);
background: var(--bg);
border: 1px solid var(--line);
border-radius: 10px;
outline: none;
resize: vertical;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.panel input:focus,
.panel textarea:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(31, 111, 92, 0.14);
}
.panel input[type="checkbox"] {
width: auto;
padding: 0;
background: transparent;
border: 0;
box-shadow: none;
accent-color: var(--accent);
}
.panel .checkbox {
display: flex;
align-items: center;
gap: 10px;
font-size: 14.5px;
font-weight: 500;
color: var(--ink);
cursor: pointer;
}
.panel-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
}
/* Buttons */
.btn {
padding: 11px 18px;
font: inherit;
font-weight: 600;
border: 1px solid transparent;
border-radius: 10px;
cursor: pointer;
transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-primary {
background: var(--accent);
color: #fff;
}
.btn-primary:hover:not(:disabled) {
background: var(--accent-dark);
}
.btn-ghost {
background: transparent;
color: var(--ink);
border-color: var(--line);
}
.btn-ghost:hover:not(:disabled) {
border-color: var(--ink);
}
.btn-danger {
background: transparent;
color: var(--danger);
border-color: transparent;
padding: 8px 10px;
}
.btn-danger:hover:not(:disabled) {
background: var(--error-bg);
}
/* List */
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 16px;
}
.card {
display: flex;
flex-direction: column;
gap: 10px;
padding: 20px;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
transition: border-color 0.15s ease, transform 0.15s ease;
}
.card:hover {
border-color: #c9c6ba;
transform: translateY(-2px);
}
.badges {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.badge {
padding: 3px 10px;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: #55564e;
background: #efeee8;
border-radius: 999px;
}
.badge-tersedia {
color: var(--success-ink);
background: var(--success-bg);
}
.badge-kosong {
color: var(--error-ink);
background: var(--error-bg);
}
.card h3 {
font-size: 19px;
}
.deskripsi {
margin: 0;
color: var(--muted);
font-size: 14.5px;
}
.harga {
margin: 0;
font-family: 'Bricolage Grotesque', 'Figtree', sans-serif;
font-size: 18px;
font-weight: 600;
letter-spacing: -0.01em;
color: var(--accent-dark);
}
.card-actions {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
margin-top: auto;
padding-top: 8px;
}
.card-actions .btn-ghost {
padding: 8px 14px;
font-size: 14px;
}
.status {
margin: 0;
padding: 32px;
text-align: center;
color: var(--muted);
background: var(--surface);
border: 1px dashed var(--line);
border-radius: var(--radius);
}
@media (max-width: 520px) {
.page {
padding: 40px 16px 64px;
}
.header {
align-items: flex-start;
}
}
Warna utama didefinisikan sebagai variable di :root (misalnya
--accent). Kalau ingin mengganti tema, cukup ubah nilai di sana.
Pemanggil API
frontend/src/api.js
Semua fetch dikumpulkan di satu tempat. Kalau nanti alamat API berubah,
kamu hanya perlu mengedit satu baris BASE.
const BASE = '/api/armada';
async function request(url, options = {}) {
const res = await fetch(url, {
headers: { 'Content-Type': 'application/json' },
...options,
});
const body = res.status === 204 ? null : await res.json();
if (!res.ok) throw new Error(body?.error || 'Terjadi kesalahan.');
return body;
}
export const api = {
list: (q = '') => request(`${BASE}?q=${encodeURIComponent(q)}`),
create: (data) => request(BASE, { method: 'POST', body: JSON.stringify(data) }),
update: (id, data) => request(`${BASE}/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
remove: (id) => request(`${BASE}/${id}`, { method: 'DELETE' }),
};Kenapa dibuat pembungkus?
- Header
Content-Type: application/jsontidak perlu diulang di setiap komponen. - Error dari backend (
{ error: '...' }) otomatis dilempar sebagaiError. - Komponen cukup memanggil
api.list(),api.create(), dan seterusnya.
Dua komponen utama
ArmadaList menampilkan kartu, harga, dan status ketersediaan, sedangkan
ArmadaForm dipakai untuk tambah maupun ubah data.
frontend/src/components/armadaList.jsx
frontend/src/components/armadaList.jsx
Menerima daftar dari App dan menampilkan tiga kondisi: memuat, kosong, atau berisi kartu. Harga diformat dengan Intl.NumberFormat, lalu status ditampilkan sebagai badge.
const rupiah = new Intl.NumberFormat('id-ID', {
style: 'currency',
currency: 'IDR',
maximumFractionDigits: 0,
});
export default function ArmadaList({ armadas, loading, onEdit, onDelete }) {
if (loading) {
return <p className="status">Memuat data armada...</p>;
}
if (!armadas.length) {
return <p className="status">Belum ada armada. Tambahkan data baru untuk memulai.</p>;
}
return (
<div className="grid">
{armadas.map((item) => (
<article key={item.id} className="card">
<div className="badges">
<span className="badge">{item.tipe}</span>
<span className={item.tersedia ? 'badge badge-tersedia' : 'badge badge-kosong'}>
{item.tersedia ? 'Tersedia' : 'Tidak tersedia'}
</span>
</div>
<h3>{item.nama}</h3>
<p className="deskripsi">{item.deskripsi}</p>
<p className="harga">{rupiah.format(item.harga ?? 0)}</p>
<div className="card-actions">
<button type="button" className="btn btn-ghost" onClick={() => onEdit(item)}>
Ubah
</button>
<button type="button" className="btn btn-danger" onClick={() => onDelete(item)}>
Hapus
</button>
</div>
</article>
))}
</div>
);
}frontend/src/components/armadaForm.jsx
frontend/src/components/armadaForm.jsx
Formulir terkontrol dengan validasi di sisi klien: nama, tipe, deskripsi, dan harga wajib diisi, sedangkan status diatur lewat checkbox. Kalau prop initial berisi data, judulnya berubah menjadi Ubah Armada.
import { useEffect, useState } from 'react';
const KOSONG = { nama: '', tipe: '', deskripsi: '', harga: '', tersedia: true };
export default function ArmadaForm({ initial, onSubmit, onCancel }) {
const [form, setForm] = useState(KOSONG);
const [error, setError] = useState('');
const [saving, setSaving] = useState(false);
useEffect(() => {
setForm(
initial
? {
nama: initial.nama,
tipe: initial.tipe,
deskripsi: initial.deskripsi,
harga: initial.harga,
tersedia: Boolean(initial.tersedia),
}
: KOSONG
);
setError('');
}, [initial]);
function ubah(e) {
setForm({ ...form, [e.target.name]: e.target.value });
}
function ubahTersedia(e) {
setForm({ ...form, tersedia: e.target.checked });
}
async function simpan(e) {
e.preventDefault();
const harga = Number(form.harga);
if (!form.nama.trim() || !form.tipe.trim() || !form.deskripsi.trim()) {
setError('Semua kolom wajib diisi.');
return;
}
if (String(form.harga).trim() === '' || !Number.isInteger(harga) || harga < 0) {
setError('Harga harus berupa angka bulat 0 atau lebih.');
return;
}
try {
setSaving(true);
setError('');
await onSubmit({
nama: form.nama.trim(),
tipe: form.tipe.trim(),
deskripsi: form.deskripsi.trim(),
harga,
tersedia: form.tersedia,
});
} catch (err) {
setError(err.message || 'Gagal menyimpan data.');
} finally {
setSaving(false);
}
}
return (
<form className="panel" onSubmit={simpan}>
<h2>{initial ? 'Ubah Armada' : 'Tambah Armada'}</h2>
<label>
Nama
<input
name="nama"
value={form.nama}
onChange={ubah}
placeholder="Contoh: Robot Pengantar"
autoFocus
/>
</label>
<label>
Tipe
<input
name="tipe"
value={form.tipe}
onChange={ubah}
placeholder="Contoh: Otonom"
/>
</label>
<label>
Deskripsi
<textarea
name="deskripsi"
value={form.deskripsi}
onChange={ubah}
rows={3}
placeholder="Keterangan singkat armada"
/>
</label>
<label>
Harga sewa (Rupiah)
<input
name="harga"
type="number"
min="0"
step="1"
inputMode="numeric"
value={form.harga}
onChange={ubah}
placeholder="Contoh: 150000"
/>
</label>
<label className="checkbox">
<input
name="tersedia"
type="checkbox"
checked={form.tersedia}
onChange={ubahTersedia}
/>
<span>Tersedia untuk disewa</span>
</label>
{error && <p className="alert alert-error">{error}</p>}
<div className="panel-actions">
<button type="button" className="btn btn-ghost" onClick={onCancel} disabled={saving}>
Batal
</button>
<button type="submit" className="btn btn-primary" disabled={saving}>
{saving ? 'Menyimpan...' : 'Simpan'}
</button>
</div>
</form>
);
}Form yang sama dipakai untuk tambah dan ubah. Bedanya hanya prop initial: null berarti tambah, berisi objek berarti mengubah data tersebut.
Menyatukan semuanya
frontend/src/App.jsx
Komponen induk ini menyimpan state daftar, kata pencarian, status form, serta pesan sukses dan error. Semua aksi CRUD bermuara di sini.
import { useEffect, useState } from 'react';
import { api } from './api.js';
import ArmadaList from './components/armadaList.jsx';
import ArmadaForm from './components/armadaForm.jsx';
export default function App() {
const [armadas, setArmadas] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [pesan, setPesan] = useState('');
const [q, setQ] = useState('');
const [editing, setEditing] = useState(null);
const [formOpen, setFormOpen] = useState(false);
const [versi, setVersi] = useState(0);
useEffect(() => {
let aktif = true;
async function muat() {
setLoading(true);
setError('');
try {
const data = await api.list(q);
if (aktif) setArmadas(data);
} catch (err) {
if (aktif) setError(err.message || 'Gagal memuat data armada.');
} finally {
if (aktif) setLoading(false);
}
}
muat();
return () => {
aktif = false;
};
}, [q, versi]);
function tambah() {
setEditing(null);
setFormOpen(true);
setPesan('');
}
function ubah(item) {
setEditing(item);
setFormOpen(true);
setPesan('');
}
async function simpan(data) {
if (editing) {
await api.update(editing.id, data);
setPesan('Data armada berhasil diperbarui.');
} else {
await api.create(data);
setPesan('Armada baru berhasil ditambahkan.');
}
setFormOpen(false);
setEditing(null);
setVersi((v) => v + 1);
}
async function hapus(item) {
const setuju = window.confirm(`Hapus armada "${item.nama}"?`);
if (!setuju) return;
try {
await api.remove(item.id);
setPesan('Data armada berhasil dihapus.');
setError('');
setVersi((v) => v + 1);
} catch (err) {
setError(err.message || 'Gagal menghapus data armada.');
}
}
return (
<main className="page">
<header className="header">
<div>
<p className="eyebrow">Belajar Node Js & React</p>
<h1>Daftar Armada</h1>
<p className="subtitle">
Kelola data armada robot lengkap dengan harga dan status ketersediaannya.
</p>
</div>
<button type="button" className="btn btn-primary" onClick={tambah}>
Tambah Armada
</button>
</header>
<div className="toolbar">
<input
className="search"
id="cari"
name="q"
type="search"
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Cari nama, tipe, atau deskripsi..."
aria-label="Cari armada"
/>
</div>
{pesan && <p className="alert alert-success">{pesan}</p>}
{error && <p className="alert alert-error">{error}</p>}
{formOpen && (
<ArmadaForm
initial={editing}
onSubmit={simpan}
onCancel={() => {
setFormOpen(false);
setEditing(null);
}}
/>
)}
<ArmadaList armadas={armadas} loading={loading} onEdit={ubah} onDelete={hapus} />
</main>
);
}Alur state yang perlu diingat
- Saat halaman dibuka,
useEffectmemanggilapi.list(q). - Mengetik di kolom cari mengubah
q, sehingga daftar dimuat ulang. - Saat menyimpan, form mengirim
hargasebagai angka dantersediasebagai boolean. - Simpan atau hapus menaikkan
versi, yang juga memicu muat ulang. loading,error, danpesanmengatur tampilan umpan balik.
Variabel aktif di dalam efek mencegah respons lama menimpa hasil pencarian yang lebih baru saat pengguna mengetik cepat.
Dua terminal, satu aplikasi
Backend dan frontend harus berjalan bersamaan. Urutannya: backend dulu, lalu frontend.
| Terminal | Folder | Perintah | URL |
|---|---|---|---|
| 1 | backend | npm run dev | localhost:5000 |
| 2 | frontend | npm run dev | localhost:5173 |
cd backend
npm run devcd frontend
npm run devSetelah keduanya menyala, buka http://localhost:5173 dan lakukan pemeriksaan berikut:
- Halaman menampilkan judul Daftar Armada dan tombol Tambah Armada.
- Klik Tambah Armada, isi nama, tipe, deskripsi, dan harga, atur status tersedia, lalu Simpan. Kartu baru, harga Rupiah, dan badge status muncul.
- Ketik kata kunci di kolom pencarian. Daftar menyempit sesuai nama, tipe, atau deskripsi.
- Klik Ubah, ganti harga atau status ketersediaannya, lalu simpan. Perubahan langsung terlihat.
- Klik Hapus, setujui konfirmasi, dan kartu tersebut hilang.
Alur satu kali simpan
- Form memanggil
api.create(data). api.jsmengirimPOST /api/armada.- Vite meneruskan request ke Express di port 5000.
server.jsmemvalidasi, menyimpan ke MySQL, dan mengembalikan baris baru.App.jsxmenaikkanversisehingga daftar dimuat ulang.
Buka http://localhost:5000/api/armada langsung di browser. Kalau alamat itu gagal, masalahnya di backend atau MySQL — bukan di React.
Kontrak data
Semua endpoint berada di bawah /api/armada. Request dan respons selalu JSON.
| Method | Path | Fungsi |
|---|---|---|
| GET | /api/armada?q= | Daftar armada, bisa difilter kata kunci |
| GET | /api/armada/:id | Ambil satu armada |
| POST | /api/armada | Tambah armada |
| PUT | /api/armada/:id | Ubah armada |
| DELETE | /api/armada/:id | Hapus armada |
Body request
{
"nama": "Robot Alpha",
"tipe": "Otonom",
"deskripsi": "Robot pengantar barang",
"harga": 150000,
"tersedia": true
}harga diisi angka bulat Rupiah tanpa titik (contoh 150000). tersedia boleh dikosongkan (otomatis true), tetapi kalau diisi harus true atau false.
Contoh respons sukses
{
"id": 1,
"nama": "Robot Alpha",
"tipe": "Otonom",
"deskripsi": "Robot pengantar barang",
"harga": 150000,
"tersedia": true,
"created_at": "2026-11-11 09:01:52",
"updated_at": "2026-11-11 09:01:52"
}Format error
| Status | Respons | Penyebab |
|---|---|---|
400 | {"error":"Nama tidak boleh kosong"} | Kolom wajib kosong |
400 | {"error":"Harga harus berupa angka bulat 0 atau lebih"} | Harga kosong, desimal, atau negatif |
400 | {"error":"Tersedia harus berupa boolean"} | Status dikirim sebagai teks/angka |
400 | {"error":"Data sudah ada"} | Pelanggaran unique key |
404 | {"error":"Data tidak ditemukan"} | ID tidak ada di tabel |
500 | {"error":"Terjadi kesalahan pada server"} | Query gagal; detail di terminal backend |
Menaikkan ke domain sendiri
Yang dipublikasikan ada dua hal berbeda: halaman modul ini
(index.php) dan aplikasinya (frontend + backend + MySQL).
Build frontend
cd frontend
npm run buildHasilnya folder frontend/dist berisi index.html dan aset statis siap unggah.
Sesuaikan alamat API
Proxy Vite hanya ada saat npm run dev. Di produksi, pilih salah satu:
- Reverse proxy satu domain. Arahkan
/api/*ke server Node (misalnya Nginx).BASE = '/api/armada'tetap dipakai. - Subdomain API. Ubah
BASEmenjadi alamat lengkap, lalu build ulang. CORS sudah aktif di backend.
// opsi 2: backend di subdomain terpisah
const BASE = 'https://api.domainmu.com/api/armada';Hosting backend
| Jenis hosting | Cocok untuk | Catatan |
|---|---|---|
| VPS (Ubuntu) | Belajar deployment penuh | Node + MySQL + Nginx + PM2 |
| Platform Node (Railway, Render) | Cepat online | Set env DB_*; sediakan MySQL terkelola |
| cPanel dengan Node.js | Hosting yang mendukung | Cek fitur “Setup Node.js App” dan MySQL |
index.php hanya halaman dokumentasi. Hosting yang hanya mendukung PHP tidak bisa menjalankan server.js atau MySQL aplikasi. Frontend boleh diunggah sebagai file statis, tetapi API tetap butuh server Node.
Unggah dokumentasi
Unggah index.php ke folder publik domain, misalnya public_html/modul/index.php. File ini mandiri — tanpa database, build, atau file lain.
Checklist produksi
- Password MySQL diganti dan disimpan sebagai environment variable di server.
BASEdisrc/api.jsmenunjuk alamat API produksi, atau reverse proxy sudah diatur.- Frontend di-build ulang setelah mengubah
BASE. - Backend berjalan permanen (PM2/systemd/platform), bukan hanya
npm run dev. - Semua endpoint diuji lagi setelah online.
Error yang sering muncul
Sebagian besar masalah berasal dari tiga hal: MySQL belum siap, backend belum jalan, atau alamat API tidak cocok.
Backend berhenti dengan “gagal terhubung ke mysql”
MySQL tidak menerima koneksi. Nyalakan service MySQL, lalu jalankan ulang backend. Pastikan host di db.js benar.
“Access denied for user 'root'@'localhost'”
Password tidak cocok. Set DB_PASSWORD sesuai password MySQL-mu, atau kosongkan bila memang tanpa password.
“EADDRINUSE: address already in use :::5000”
Port 5000 dipakai proses lain. Hentikan prosesnya, atau jalankan PORT=5001 npm run dev lalu sesuaikan proxy di vite.config.js.
Frontend menampilkan “Gagal memuat data armada”
Buka http://localhost:5000/api/armada di browser. Jika gagal, backend bermasalah. Jika berhasil, periksa proxy dan BASE di src/api.js.
Halaman blank / layar putih
Buka konsol browser (F12). Biasanya ada import file yang salah nama atau komponen yang belum dibuat. Pastikan semua file di src/ ada.
“Cannot find module ...” saat npm run dev
Dependency belum terpasang atau terminal berada di folder yang salah. Jalankan npm install di folder yang punya package.json.
Perubahan di db.js tidak berpengaruh ke tabel
CREATE TABLE IF NOT EXISTS tidak mengubah tabel lama. Hapus tabel armada atau jalankan ALTER TABLE, lalu restart backend.
API jalan di localhost tetapi gagal setelah online
Biasanya BASE masih /api/armada padahal backend ada di domain lain, atau server Node tidak berjalan. Periksa tab Network di DevTools.
Semua file sudah lengkap
Kalau 12 file di atas sudah dibuat dan kedua server menyala, aplikasi CRUD-mu selesai. Berikut ringkasan urutan pembuatannya.
backend/package.json→npm installbackend/db.jsbackend/server.js→npm run devfrontend/package.json→npm installfrontend/vite.config.jsfrontend/index.htmlfrontend/src/main.jsxfrontend/src/styles.cssfrontend/src/api.jsfrontend/src/components/armadaList.jsxfrontend/src/components/armadaForm.jsxfrontend/src/App.jsx→npm run dev
Latihan setelah berhasil
-
Bangun ulang tanpa menyalin
Ulangi dari folder kosong. Fokus pada alur request, bukan hafalan.
-
Tambahkan filter ketersediaan
Buat pilihan “Semua / Tersedia / Tidak tersedia” di frontend, lalu tambahkan query
?tersedia=di backend. -
Tambahkan pagination
Tambahkan
?page=dan?limit=di backend, lalu tombol navigasi di frontend. -
Publikasikan
Ikuti bagian Build & publikasi. Error deployment adalah bagian dari belajar.
Kamu sudah punya aplikasi CRUD lengkap: database, API, antarmuka, dan dokumentasi. Selamat membangun.