SR Modul Belajar

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.

Level Pemula Durasi ± 60 menit Node 18+ File 12 file
Mulai dari nol

Alur data saat mode pengembangan.

01 — Pendahuluan

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.

Tambah dataFormulir dengan validasi kolom wajib.
Cari dataFilter nama, tipe, dan deskripsi secara langsung.
Ubah dataFormulir terisi otomatis dengan data yang dipilih.
Hapus dataKonfirmasi sebelum data benar-benar dihapus.
Harga sewaNominal ditampilkan otomatis dalam format Rupiah.
Status ketersediaanTandai armada tersedia atau tidak tersedia.

Urutan yang akan kita tempuh

  1. Siapkan Node.js dan MySQL.
  2. Buat folder backend dan frontend.
  3. Tulis 3 file backend: package.json, db.js, server.js.
  4. Jalankan backend dan uji API-nya.
  5. Tulis 9 file frontend: konfigurasi, halaman, CSS, komponen, dan App.jsx.
  6. Jalankan frontend dan coba semua fitur CRUD.
  7. Pelajari cara build dan mempublikasikannya.
Cara membaca modul

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.

02 — Persiapan

Pasang alat yang dibutuhkan

Sebelum menulis kode, pastikan Node.js dan MySQL sudah terpasang. Keduanya gratis dan berjalan di komputer sendiri.

  1. Pasang Node.js 18 atau lebih baru

    Unduh installer LTS dari nodejs.org, jalankan dengan opsi bawaan, lalu buka terminal baru.

    terminal
    node -v
    npm -v

    Angka pertama harus 18 atau lebih, misalnya v20.11.0. Kalau perintah tidak dikenali, tutup lalu buka ulang terminal.

  2. 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.

  3. Siapkan terminal dan editor

    Windows: Git Bash atau PowerShell. macOS/Linux: Terminal. Editor: VS Code atau editor lain. Modul ini memakai perintah format bash.

Penting

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 -v menampilkan versi 18 atau lebih.
  • npm -v menampilkan versi npm.
  • MySQL menyala dan kamu tahu user/password-nya.
  • Kamu punya folder kosong untuk project ini.
03 — Struktur folder

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

struktur project
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.md

Perintah membuat folder

Jalankan dari folder tempat kamu menyimpan project:

terminal
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.
Dua aplikasi, dua terminal

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.

04 — Backend · package.json

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:

backend/package.json
{
  "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

terminal
cd backend
npm install

Perintah ini membaca package.json lalu mengunduh Express, CORS, dan mysql2 ke folder node_modules.

Arti scripts

npm run dev menjalankan node --watch server.js (restart otomatis saat file disimpan). npm start menjalankan node server.js tanpa watch.

Jangan salah folder

npm install harus dijalankan di folder yang berisi package.json. Menjalankannya di root project akan membuat folder node_modules yang tidak dipakai.

05 — Backend · db.js

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:

backend/db.js
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 bisa await. Versi mysql2 biasa 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 harga sebagai angka Rupiah dan tersedia sebagai boolean.
  • SHOW COLUMNS lalu ALTER TABLE menambahkan kolom baru ke database versi lama tanpa menghapus data.
  • Password default kosong; bisa diganti lewat environment variable DB_PASSWORD tanpa mengedit file.
VariableDefaultKeterangan
DB_HOSTlocalhostLokasi server MySQL
DB_USERrootUser database
DB_PASSWORDkosongPassword database
DB_NAMEsukarobotNama database

Contoh menjalankan backend dengan password MySQL (pilih sesuai shell):

terminal
# 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 dev
Kalau mengubah struktur tabel

CREATE 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.

06 — Backend · server.js

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:

backend/server.js
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 sebagai req.body.
  • validasi() memotong spasi, menolak kolom kosong, mewajibkan harga bilangan bulat 0 atau lebih, dan memastikan tersedia berupa boolean (default true bila tidak dikirim).
  • keObjek() mengubah tersedia dari angka 1/0 milik MySQL menjadi true/false di 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.
Path harus /api

Frontend memanggil /api/armada dan Vite meneruskan path itu apa adanya. Karena itu semua route di backend wajib diawali /api.

07 — Backend · jalankan

Nyalakan server dan uji API

Saat pertama dijalankan, backend membuat database dan tabel secara otomatis. Kalau berhasil, kamu akan melihat pesan alamat server.

  1. Pastikan MySQL menyala

    Buka Laragon lalu Start All, atau jalankan service MySQL.

  2. Jalankan backend
    terminal
    cd backend
    npm run dev

    Output yang diharapkan:

    output
    api berjalan di http://localhost:5000
  3. 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 dengan id dan timestamp.

Database terbentuk otomatis

Kamu tidak perlu membuat database lewat phpMyAdmin. Cukup pastikan MySQL menyala dan kredensial di db.js benar.

Port 5000 dipakai?

Jalankan PORT=5001 npm run dev, lalu ubah proxy di frontend/vite.config.js menjadi http://localhost:5001. Keduanya harus sama.

08 — Frontend · package.json & Vite

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:

frontend/package.json
{
  "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:

frontend/vite.config.js
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:

terminal
cd frontend
npm install
Kenapa ada proxy?

Dengan 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.

09 — Frontend · index.html

Halaman pembungkus

frontend/index.html

Ini satu-satunya file HTML. Isinya elemen #root tempat React menggambar seluruh tampilan, plus pemuatan font dan favicon.

frontend/index.html
<!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.

10 — Frontend · 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.

frontend/src/main.jsx
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>
);
StrictMode

Di mode pengembangan, React.StrictMode menjalankan efek dua kali untuk membantu menemukan bug. Ini normal dan tidak terjadi di build produksi.

11 — Frontend · styles.css

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.

frontend/src/styles.css
: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.

12 — Frontend · api.js

Pemanggil API

frontend/src/api.js

Semua fetch dikumpulkan di satu tempat. Kalau nanti alamat API berubah, kamu hanya perlu mengedit satu baris BASE.

frontend/src/api.js
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/json tidak perlu diulang di setiap komponen.
  • Error dari backend ({ error: '...' }) otomatis dilempar sebagai Error.
  • Komponen cukup memanggil api.list(), api.create(), dan seterusnya.
13 — Frontend · komponen

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.

frontend/src/components/armadaList.jsx
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.

frontend/src/components/armadaForm.jsx
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>
  );
}
Satu komponen, dua mode

Form yang sama dipakai untuk tambah dan ubah. Bedanya hanya prop initial: null berarti tambah, berisi objek berarti mengubah data tersebut.

14 — Frontend · App.jsx

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.

frontend/src/App.jsx
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

  1. Saat halaman dibuka, useEffect memanggil api.list(q).
  2. Mengetik di kolom cari mengubah q, sehingga daftar dimuat ulang.
  3. Saat menyimpan, form mengirim harga sebagai angka dan tersedia sebagai boolean.
  4. Simpan atau hapus menaikkan versi, yang juga memicu muat ulang.
  5. loading, error, dan pesan mengatur tampilan umpan balik.
Penanda aktif

Variabel aktif di dalam efek mencegah respons lama menimpa hasil pencarian yang lebih baru saat pengguna mengetik cepat.

15 — Menjalankan

Dua terminal, satu aplikasi

Backend dan frontend harus berjalan bersamaan. Urutannya: backend dulu, lalu frontend.

TerminalFolderPerintahURL
1backendnpm run devlocalhost:5000
2frontendnpm run devlocalhost:5173
terminal — backend
cd backend
npm run dev
terminal — frontend (baru)
cd frontend
npm run dev

Setelah 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

  1. Form memanggil api.create(data).
  2. api.js mengirim POST /api/armada.
  3. Vite meneruskan request ke Express di port 5000.
  4. server.js memvalidasi, menyimpan ke MySQL, dan mengembalikan baris baru.
  5. App.jsx menaikkan versi sehingga daftar dimuat ulang.
Kalau daftar kosong terus

Buka http://localhost:5000/api/armada langsung di browser. Kalau alamat itu gagal, masalahnya di backend atau MySQL — bukan di React.

16 — Endpoint API

Kontrak data

Semua endpoint berada di bawah /api/armada. Request dan respons selalu JSON.

MethodPathFungsi
GET/api/armada?q=Daftar armada, bisa difilter kata kunci
GET/api/armada/:idAmbil satu armada
POST/api/armadaTambah armada
PUT/api/armada/:idUbah armada
DELETE/api/armada/:idHapus armada

Body request

POST / PUT — application/json
{
  "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

201 Created
{
  "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

StatusResponsPenyebab
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
17 — Build & publikasi

Menaikkan ke domain sendiri

Yang dipublikasikan ada dua hal berbeda: halaman modul ini (index.php) dan aplikasinya (frontend + backend + MySQL).

Build frontend

terminal
cd frontend
npm run build

Hasilnya 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 BASE menjadi alamat lengkap, lalu build ulang. CORS sudah aktif di backend.
frontend/src/api.js
// opsi 2: backend di subdomain terpisah
const BASE = 'https://api.domainmu.com/api/armada';

Hosting backend

Jenis hostingCocok untukCatatan
VPS (Ubuntu)Belajar deployment penuhNode + MySQL + Nginx + PM2
Platform Node (Railway, Render)Cepat onlineSet env DB_*; sediakan MySQL terkelola
cPanel dengan Node.jsHosting yang mendukungCek fitur “Setup Node.js App” dan MySQL
Penting soal hosting PHP

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.
  • BASE di src/api.js menunjuk 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.
18 — Troubleshooting

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.

19 — Penutup

Semua file sudah lengkap

Kalau 12 file di atas sudah dibuat dan kedua server menyala, aplikasi CRUD-mu selesai. Berikut ringkasan urutan pembuatannya.

  1. backend/package.json → npm install
  2. backend/db.js
  3. backend/server.js → npm run dev
  4. frontend/package.json → npm install
  5. frontend/vite.config.js
  6. frontend/index.html
  7. frontend/src/main.jsx
  8. frontend/src/styles.css
  9. frontend/src/api.js
  10. frontend/src/components/armadaList.jsx
  11. frontend/src/components/armadaForm.jsx
  12. frontend/src/App.jsx → npm run dev

Latihan setelah berhasil

  1. Bangun ulang tanpa menyalin

    Ulangi dari folder kosong. Fokus pada alur request, bukan hafalan.

  2. Tambahkan filter ketersediaan

    Buat pilihan “Semua / Tersedia / Tidak tersedia” di frontend, lalu tambahkan query ?tersedia= di backend.

  3. Tambahkan pagination

    Tambahkan ?page= dan ?limit= di backend, lalu tombol navigasi di frontend.

  4. Publikasikan

    Ikuti bagian Build & publikasi. Error deployment adalah bagian dari belajar.

Selesai

Kamu sudah punya aplikasi CRUD lengkap: database, API, antarmuka, dan dokumentasi. Selamat membangun.