Tujuan
Di proyek ini kamu menggabungkan semua library dari Fase 3 menjadi satu aplikasi utuh: Dashboard Manajemen Tugas. User bisa melihat daftar tugas dari API, menambah tugas lewat dialog dengan validasi, mencentang tugas selesai, menghapus tugas, memfilter dan mencari, melihat statistik progres, serta mengganti tema gelap/terang yang diingat setelah refresh. Hasil akhirnya di-deploy ke Vercel atau Netlify sehingga punya URL publik untuk portofolio.
Setiap library punya peran yang jelas, sesuai prinsip yang kamu pelajari:
| Bagian | Library | Peran |
|---|---|---|
| Data tugas dari server | TanStack Query 5.104 | Cache, loading/error, mutation, optimistic update |
| Komunikasi HTTP | axios 1.20 | Instance terpusat, baseURL dari env, interceptor |
| Filter, pencarian, tema | Zustand 5.0 | Client state + persist ke localStorage |
| Form tambah tugas | React Hook Form 7.90 + Zod 4.6 | Validasi skema dengan pesan bahasa Indonesia |
| Tampilan | shadcn/ui + Tailwind CSS 4.3 | Button, Input, Label, Dialog, Checkbox, Card |
| Fondasi | React 19.3 + Vite 8.3 | Build dan dev server |
API yang dipakai adalah jsonplaceholder (/todos?userId=1, 20 tugas). Ingat sifatnya: POST/PATCH/DELETE membalas sukses tapi tidak menyimpan apa pun. Karena itu aplikasi ini memperbarui cache TanStack Query secara langsung (setQueryData dan optimistic update) dan mengatur staleTime: Infinity supaya refetch otomatis tidak "menghapus" perubahan user. Dengan backend sungguhan, kamu tinggal menambahkan invalidateQueries di onSettled.
Fitur wajib
- Daftar tugas dari
GET /todos?userId=1dengan status memuat dan pesan error yang ramah, plus tombol "Coba lagi". - Tambah tugas lewat Dialog shadcn berisi form React Hook Form + Zod: judul 3-80 karakter (di-trim) dan prioritas (
rendah/sedang/tinggi). Pesan error berbahasa Indonesia. Dialog tertutup dan form direset setelah sukses. - Centang selesai dengan optimistic update dan rollback kalau request gagal.
- Hapus tugas dengan optimistic update dan rollback.
- Filter status (semua/aktif/selesai) dan pencarian judul yang disimpan di Zustand; status filter dan tema bertahan setelah refresh (persist), sedangkan teks pencarian tidak.
- Statistik (total, selesai, persen progres) sebagai nilai turunan, bukan state terpisah.
- Tema gelap/terang memakai class
darkdi<html>. - Konfigurasi lewat env:
VITE_API_URLdanVITE_APP_NAME, tidak ada URL API yang ditulis langsung di komponen. - Deploy ke Vercel atau Netlify dengan URL publik yang bisa dibuka.
Langkah bertahap
Langkah 1, Scaffold proyek.
npm create vite@latest dashboard-tugas -- --template react
cd dashboard-tugas
npm install
npm install tailwindcss @tailwindcss/vite
Langkah 2, Alias @ dan Tailwind. Isi vite.config.js dan jsconfig.json seperti di modul shadcn/ui, lalu ganti seluruh isi src/index.css dengan @import "tailwindcss";. Hapus src/App.css dan import-nya.
Langkah 3, shadcn/ui.
npx shadcn@latest init -b radix
npx shadcn@latest add button input label dialog checkbox card
CLI otomatis menambahkan @custom-variant dark (&:is(.dark *)); ke src/index.css, jadi class dark di <html> langsung mengaktifkan tema gelap shadcn.
Langkah 4, Library data, state, dan form.
echo "legacy-peer-deps=true" > .npmrc # solusi sementara ERESOLVE @hookform/resolvers (issue #882)
npm install @tanstack/react-query axios zustand react-hook-form zod @hookform/resolvers
npm install -D @tanstack/react-query-devtools
Langkah 5, Environment variable. Buat .env (untuk lokal) dan .env.example (untuk dokumentasi, di-commit):
VITE_API_URL=https://jsonplaceholder.typicode.com
VITE_APP_NAME=Dashboard Tugas
Langkah 6, Lapisan data. Tulis src/lib/api.js, src/lib/queryClient.js, src/features/tasks/taskApi.js, dan useTasks.js (lihat kode kerangka). Uji dulu dengan menampilkan JSON.stringify(data) sebelum membuat UI.
Langkah 7, Client state. Tulis src/stores/useUiStore.js dengan persist + devtools. Cek di DevTools > Application > Local Storage bahwa key dashboard-ui muncul setelah kamu mengganti filter atau tema.
Langkah 8, Form dan UI. Tulis taskSchema.js, TaskFormDialog.jsx, TaskToolbar.jsx, TaskStats.jsx, TaskList.jsx, lalu rakit di App.jsx dan main.jsx.
Langkah 9, Uji manual. Jalankan npm run dev dan cek semua kriteria lulus. Lalu npm run build dan npm run preview untuk memastikan versi produksi juga jalan.
Langkah 10, Deploy.
- Push proyek ke GitHub (pastikan
.envtidak ikut;.env.exampledan.npmrcikut). - Vercel: buka vercel.com → Add New → Project → pilih repo. Framework terdeteksi sebagai Vite (build
npm run build, outputdist). Di bagian Environment Variables, isiVITE_API_URLdanVITE_APP_NAME, lalu Deploy. - Netlify: Add new site → Import an existing project → pilih repo. Build command
npm run build, publish directorydist, isi environment variables yang sama, lalu Deploy. - Karena nilai
VITE_ditanam saat build, setiap kali mengubah env di dashboard hosting kamu harus melakukan deploy ulang.
Kode kerangka
Kode di bawah sudah diuji: npm run build berhasil, dan di browser alur tambah (validasi "Judul minimal 3 karakter" muncul untuk input "ab"), centang, statistik, dan hapus berjalan dengan benar. Struktur foldernya:
src/
├── components/ui/ ← dibuat oleh CLI shadcn
├── features/tasks/
│ ├── taskApi.js
│ ├── taskSchema.js
│ ├── useTasks.js
│ ├── TaskFormDialog.jsx
│ ├── TaskList.jsx
│ ├── TaskStats.jsx
│ └── TaskToolbar.jsx
├── lib/
│ ├── api.js
│ ├── queryClient.js
│ └── utils.js ← dibuat oleh CLI shadcn
├── stores/useUiStore.js
├── App.jsx
├── main.jsx
└── index.css
// vite.config.js
import { fileURLToPath, URL } from "node:url";
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: { alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) } },
});
// src/lib/api.js
import axios from "axios";
export const api = axios.create({
baseURL: import.meta.env.VITE_API_URL,
timeout: 10000,
});
api.interceptors.request.use((config) => {
const token = localStorage.getItem("token");
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});
api.interceptors.response.use(
(res) => res,
(error) => {
if (axios.isAxiosError(error)) {
const status = error.response?.status;
if (error.code === "ECONNABORTED") error.message = "Server terlalu lama merespons.";
else if (!error.response) error.message = "Tidak bisa terhubung ke server.";
else if (status === 401) error.message = "Sesi berakhir, silakan login lagi.";
else if (status === 404) error.message = "Data tidak ditemukan.";
else if (status >= 500) error.message = "Server sedang bermasalah.";
}
return Promise.reject(error);
}
);
// src/lib/queryClient.js
import { QueryClient } from "@tanstack/react-query";
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
// jsonplaceholder tidak menyimpan perubahan, jadi refetch akan menghapus
// tugas yang kita tambah. Data dianggap segar selamanya selama sesi.
staleTime: Infinity,
refetchOnWindowFocus: false,
retry: 1,
},
},
});
// src/stores/useUiStore.js
import { create } from "zustand";
import { devtools, persist } from "zustand/middleware";
export const useUiStore = create(
devtools(
persist(
(set) => ({
status: "semua", // "semua" | "aktif" | "selesai"
search: "",
theme: "light",
setStatus: (status) => set({ status }),
setSearch: (search) => set({ search }),
toggleTheme: () => set((s) => ({ theme: s.theme === "light" ? "dark" : "light" })),
}),
{
name: "dashboard-ui",
partialize: (s) => ({ status: s.status, theme: s.theme }), // search tidak disimpan
}
),
{ name: "UiStore" }
)
);
// src/features/tasks/taskSchema.js
import { z } from "zod";
export const PRIORITAS = ["rendah", "sedang", "tinggi"];
export const taskSchema = z.object({
title: z
.string()
.trim()
.min(3, "Judul minimal 3 karakter")
.max(80, "Judul maksimal 80 karakter"),
priority: z.enum(PRIORITAS, { error: "Pilih prioritas yang tersedia" }),
});
// src/features/tasks/taskApi.js
import { api } from "@/lib/api";
export const taskKeys = {
all: ["tasks"],
list: () => ["tasks", "list"],
};
const USER_ID = 1;
export async function fetchTasks(signal) {
const { data } = await api.get("/todos", { params: { userId: USER_ID }, signal });
// data asli tidak punya prioritas; beri nilai awal supaya bentuknya seragam
return data.map((t) => ({ ...t, priority: "sedang" }));
}
export async function createTask(input) {
const { data } = await api.post("/todos", { ...input, completed: false, userId: USER_ID });
// jsonplaceholder selalu membalas id 201, jadi buat id unik sendiri
return { ...data, id: Date.now() };
}
export async function updateTask({ id, ...perubahan }) {
await api.patch(`/todos/${id}`, perubahan);
return { id, ...perubahan };
}
export async function deleteTask(id) {
await api.delete(`/todos/${id}`);
return id;
}
// src/features/tasks/useTasks.js
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { createTask, deleteTask, fetchTasks, taskKeys, updateTask } from "./taskApi";
export function useTasks() {
return useQuery({
queryKey: taskKeys.list(),
queryFn: ({ signal }) => fetchTasks(signal),
});
}
export function useCreateTask() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: createTask,
onSuccess: (baru) => {
queryClient.setQueryData(taskKeys.list(), (lama = []) => [baru, ...lama]);
},
});
}
// pola optimistic update yang dipakai bersama oleh update dan delete
function useOptimisticTaskMutation(mutationFn, ubahCache) {
const queryClient = useQueryClient();
return useMutation({
mutationFn,
onMutate: async (variabel) => {
await queryClient.cancelQueries({ queryKey: taskKeys.list() });
const sebelumnya = queryClient.getQueryData(taskKeys.list());
queryClient.setQueryData(taskKeys.list(), (lama = []) => ubahCache(lama, variabel));
return { sebelumnya };
},
onError: (_err, _variabel, hasil) => {
queryClient.setQueryData(taskKeys.list(), hasil?.sebelumnya);
},
});
}
export function useUpdateTask() {
return useOptimisticTaskMutation(updateTask, (lama, { id, ...perubahan }) =>
lama.map((t) => (t.id === id ? { ...t, ...perubahan } : t))
);
}
export function useDeleteTask() {
return useOptimisticTaskMutation(deleteTask, (lama, id) => lama.filter((t) => t.id !== id));
}
// src/features/tasks/TaskFormDialog.jsx
import { useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { PRIORITAS, taskSchema } from "./taskSchema";
import { useCreateTask } from "./useTasks";
export function TaskFormDialog() {
const [open, setOpen] = useState(false);
const createTask = useCreateTask();
const {
register,
handleSubmit,
reset,
setError,
formState: { errors, isSubmitting },
} = useForm({
resolver: zodResolver(taskSchema),
defaultValues: { title: "", priority: "sedang" },
});
async function onValid(data) {
try {
await createTask.mutateAsync(data);
reset();
setOpen(false);
} catch (err) {
setError("root.server", { message: err.message });
}
}
return (
<Dialog
open={open}
onOpenChange={(o) => {
setOpen(o);
if (!o) reset();
}}
>
<DialogTrigger asChild>
<Button>+ Tugas baru</Button>
</DialogTrigger>
<DialogContent>
<form onSubmit={handleSubmit(onValid)} noValidate className="space-y-4">
<DialogHeader>
<DialogTitle>Tambah tugas</DialogTitle>
<DialogDescription>Isi judul dan prioritas tugas.</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="title">Judul</Label>
<Input id="title" aria-invalid={!!errors.title} {...register("title")} />
{errors.title && <p className="text-sm text-destructive">{errors.title.message}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="priority">Prioritas</Label>
<select
id="priority"
className="h-8 w-full rounded-lg border border-input bg-transparent px-2 text-sm"
{...register("priority")}
>
{PRIORITAS.map((p) => (
<option key={p} value={p}>
{p}
</option>
))}
</select>
{errors.priority && <p className="text-sm text-destructive">{errors.priority.message}</p>}
</div>
{errors.root?.server && (
<p className="text-sm text-destructive">{errors.root.server.message}</p>
)}
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
Batal
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Menyimpan..." : "Simpan"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
// src/features/tasks/TaskToolbar.jsx
import { useShallow } from "zustand/react/shallow";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useUiStore } from "@/stores/useUiStore";
const STATUS = ["semua", "aktif", "selesai"];
export function TaskToolbar() {
const { status, search, setStatus, setSearch } = useUiStore(
useShallow((s) => ({
status: s.status,
search: s.search,
setStatus: s.setStatus,
setSearch: s.setSearch,
}))
);
return (
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<Input
placeholder="Cari tugas..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="sm:max-w-xs"
/>
<div className="flex gap-1">
{STATUS.map((st) => (
<Button
key={st}
size="sm"
variant={st === status ? "default" : "outline"}
onClick={() => setStatus(st)}
>
{st}
</Button>
))}
</div>
</div>
);
}
// src/features/tasks/TaskStats.jsx
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
export function TaskStats({ tasks }) {
// nilai turunan: dihitung dari tasks, tidak disimpan di state
const total = tasks.length;
const selesai = tasks.filter((t) => t.completed).length;
const persen = total === 0 ? 0 : Math.round((selesai / total) * 100);
const kartu = [
{ label: "Total tugas", nilai: total },
{ label: "Selesai", nilai: selesai },
{ label: "Progres", nilai: `${persen}%` },
];
return (
<div className="grid gap-3 sm:grid-cols-3">
{kartu.map((k) => (
<Card key={k.label}>
<CardHeader>
<CardTitle className="text-sm text-muted-foreground">{k.label}</CardTitle>
</CardHeader>
<CardContent className="text-2xl font-bold">{k.nilai}</CardContent>
</Card>
))}
</div>
);
}
// src/features/tasks/TaskList.jsx
import { useMemo } from "react";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { useUiStore } from "@/stores/useUiStore";
import { useDeleteTask, useUpdateTask } from "./useTasks";
const warnaPrioritas = {
rendah: "bg-slate-100 text-slate-700",
sedang: "bg-amber-100 text-amber-800",
tinggi: "bg-red-100 text-red-700",
};
export function TaskList({ tasks }) {
const status = useUiStore((s) => s.status);
const search = useUiStore((s) => s.search);
const updateTask = useUpdateTask();
const deleteTask = useDeleteTask();
const terfilter = useMemo(() => {
const q = search.trim().toLowerCase();
return tasks
.filter((t) => (status === "aktif" ? !t.completed : status === "selesai" ? t.completed : true))
.filter((t) => t.title.toLowerCase().includes(q));
}, [tasks, status, search]);
if (terfilter.length === 0) {
return <p className="py-8 text-center text-muted-foreground">Tidak ada tugas yang cocok.</p>;
}
return (
<ul className="divide-y rounded-lg border">
{terfilter.map((t) => (
<li key={t.id} className="flex items-center gap-3 p-3">
<Checkbox
checked={t.completed}
onCheckedChange={(v) => updateTask.mutate({ id: t.id, completed: v === true })}
aria-label={`Tandai "${t.title}" selesai`}
/>
<span className={`flex-1 ${t.completed ? "text-muted-foreground line-through" : ""}`}>
{t.title}
</span>
<span className={`rounded px-2 py-0.5 text-xs ${warnaPrioritas[t.priority]}`}>
{t.priority}
</span>
<Button variant="destructive" size="sm" onClick={() => deleteTask.mutate(t.id)}>
Hapus
</Button>
</li>
))}
</ul>
);
}
// src/App.jsx
import { useEffect } from "react";
import { Button } from "@/components/ui/button";
import { useUiStore } from "@/stores/useUiStore";
import { TaskFormDialog } from "@/features/tasks/TaskFormDialog";
import { TaskList } from "@/features/tasks/TaskList";
import { TaskStats } from "@/features/tasks/TaskStats";
import { TaskToolbar } from "@/features/tasks/TaskToolbar";
import { useTasks } from "@/features/tasks/useTasks";
export default function App() {
const theme = useUiStore((s) => s.theme);
const toggleTheme = useUiStore((s) => s.toggleTheme);
const { data: tasks = [], isPending, isError, error, refetch } = useTasks();
useEffect(() => {
document.documentElement.classList.toggle("dark", theme === "dark");
}, [theme]);
return (
<div className="min-h-svh bg-background text-foreground">
<main className="mx-auto max-w-3xl space-y-6 p-4 sm:p-8">
<header className="flex items-center justify-between gap-2">
<h1 className="text-2xl font-bold">{import.meta.env.VITE_APP_NAME}</h1>
<div className="flex gap-2">
<Button variant="outline" onClick={toggleTheme}>
{theme === "dark" ? "Terang" : "Gelap"}
</Button>
<TaskFormDialog />
</div>
</header>
{isPending && <p>Memuat tugas...</p>}
{isError && (
<div className="space-y-2">
<p className="text-destructive">{error.message}</p>
<Button variant="outline" onClick={() => refetch()}>Coba lagi</Button>
</div>
)}
{!isPending && !isError && (
<>
<TaskStats tasks={tasks} />
<TaskToolbar />
<TaskList tasks={tasks} />
</>
)}
</main>
</div>
);
}
// src/main.jsx
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { queryClient } from "@/lib/queryClient";
import App from "./App.jsx";
import "./index.css";
createRoot(document.getElementById("root")).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<App />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
</StrictMode>
);
# Hasil yang diharapkan setelah npm run dev lalu membuka http://localhost:5173
# → Kartu statistik: Total tugas 20 | Selesai 11 | Progres 55%
# → Tambah "Rapat tim mingguan" → muncul di paling atas, Total tugas 21
# → Centang tugas → Selesai bertambah seketika (optimistic update)
# → Pilih filter "selesai" + mode gelap, refresh → keduanya tetap
Beberapa keputusan desain yang perlu kamu pahami (dan bisa kamu jelaskan saat wawancara):
- Kenapa tugas tidak disimpan di Zustand? Karena tugas adalah server state. Zustand hanya memegang filter, pencarian, dan tema.
- Kenapa statistik dihitung di
TaskStats? Karena nilainya turunan daritasks. Menyimpannya terpisah berisiko tidak sinkron. - Kenapa
useShallowdiTaskToolbar? Komponen itu memilih empat nilai sekaligus dalam satu object; tanpauseShallow, object baru di setiap perubahan store memicu render berlebihan. - Kenapa
staleTime: Infinity? Khusus untuk jsonplaceholder yang tidak menyimpan perubahan. Dengan backend sungguhan, kembalikan ke nilai wajar (misalnya 30-60 detik) dan pakaiinvalidateQueries. - Kenapa prioritas memakai
<select>biasa denganregister? Elemen native cocok langsung denganregister. Kalau kamu menggantinya dengan komponen Select shadcn (controlled), gunakanController.
Kriteria lulus
- [ ]
npm run buildberhasil tanpa error. - [ ] Saat memuat pertama kali tampil "Memuat tugas...", lalu 20 tugas user 1.
- [ ] Mematikan internet (DevTools > Network > Offline) lalu refresh memunculkan pesan error yang ramah dan tombol "Coba lagi".
- [ ] Judul kosong, kurang dari 3 karakter, atau lebih dari 80 karakter ditolak dengan pesan bahasa Indonesia di bawah input.
- [ ] Tugas baru muncul di atas daftar, dialog tertutup, dan form kosong lagi saat dibuka kembali.
- [ ] Centang dan hapus terasa instan; kalau URL API sengaja dirusak, perubahan dikembalikan (rollback).
- [ ] Statistik total, selesai, dan persen selalu cocok dengan daftar.
- [ ] Filter dan pencarian bekerja bersamaan; filter status dan tema bertahan setelah refresh, teks pencarian tidak.
- [ ] Tidak ada URL API yang ditulis langsung di komponen; semuanya lewat
VITE_API_URL. - [ ] Panel React Query DevTools tampil saat development dan tidak ada di build produksi.
- [ ] Aplikasi bisa dibuka lewat URL Vercel/Netlify dan berfungsi sama seperti di lokal.
- [ ] Tampilan rapi di layar HP (lebar sekitar 375 px) tanpa scroll horizontal.
Pengembangan lanjutan
- Edit tugas: pakai Dialog yang sama untuk mengedit judul dan prioritas (
resetdengan data tugas saat dialog dibuka, mutationuseUpdateTask). - Konfirmasi hapus: bungkus tombol Hapus dengan
ConfirmDeleteDialogdari latihan modul shadcn/ui. - Backend sungguhan: ganti jsonplaceholder dengan json-server lokal atau Supabase, ubah
VITE_API_URL, kembalikanstaleTimeke nilai wajar, lalu tambahkanonSettled: () => queryClient.invalidateQueries({ queryKey: taskKeys.all }). - Tenggat waktu: tambahkan field
deadlinedengan validasi Zod "tidak boleh di masa lalu" dan urutkan daftar berdasarkan tenggat. - Pagination atau infinite scroll: ambil semua 200 todos dengan
useInfiniteQuerydan tombol "Muat lebih banyak". - Notifikasi: tambahkan komponen toast (misalnya
npx shadcn@latest add sonner) untuk pesan "Tugas ditambahkan" dan "Gagal menyimpan". - Code splitting: build menampilkan peringatan chunk di atas 500 kB. Coba pisahkan Dialog dengan
React.lazydan bandingkan ukuran hasil build.