Kenapa ini penting
Proyek latihan biasanya cukup dengan App.jsx dan beberapa file di src/. Tapi ketika aplikasi tumbuh menjadi 80 komponen, 15 custom hook, dan beberapa context, folder yang berantakan mulai memperlambat kerja: kamu lupa file ada di mana, komponen tombol ada tiga versi berbeda, dan logika fetch tersalin di sepuluh tempat. Struktur folder dan cara memecah komponen bukan soal estetika, melainkan soal kecepatan tim saat menambah fitur dan keberanian saat mengubah kode. React sendiri tidak mewajibkan struktur tertentu, jadi kamu perlu konvensi yang masuk akal.
Konsep inti
Struktur per jenis vs per fitur
Ada dua pendekatan utama:
| Per jenis file | Per fitur |
|---|---|
components/, hooks/, pages/, utils/ |
features/produk/, features/keranjang/, features/auth/ |
| Mudah di awal | Sedikit lebih banyak folder di awal |
| Saat besar, satu fitur tersebar di 4 folder | Semua file satu fitur berdekatan |
| Menghapus fitur = cari ke mana-mana | Menghapus fitur = hapus satu folder |
Untuk aplikasi menengah ke atas, struktur per fitur biasanya lebih nyaman. Analogi: dapur yang menyimpan semua pisau di satu laci, semua panci di laci lain (per jenis) terasa rapi, tapi kalau kamu mau membuat kopi, kamu harus buka lima laci. Pojok kopi khusus (per fitur) yang berisi gelas, kopi, dan gula di satu tempat lebih praktis untuk aktivitas itu.
Contoh struktur yang umum dipakai:
src/
├── app/ # titik masuk: App.jsx, provider global, routing
├── components/ui/ # komponen UI generik: Button, Input, Modal, Card
├── features/
│ ├── produk/
│ │ ├── components/ # ProductCard, ProductList (khusus fitur produk)
│ │ ├── hooks/ # useProducts
│ │ ├── api.js # fungsi fetch khusus produk
│ │ └── index.js # "pintu depan" yang diekspor ke luar fitur
│ └── keranjang/
│ ├── components/
│ ├── CartContext.jsx
│ └── index.js
├── hooks/ # hook umum lintas fitur: useDebounce, useLocalStorage
└── lib/ # helper umum: formatRupiah, apiClient
Reusable UI components
Folder components/ui/ berisi komponen yang tidak tahu apa-apa tentang bisnis: Button, Input, Spinner. Mereka menerima props, meneruskan atribut HTML lainnya (...rest), dan punya tampilan konsisten. Komponen fitur seperti ProductCard boleh memakai Button, tapi Button tidak boleh meng-import apa pun dari features/. Arah ketergantungan dijaga satu arah: fitur bergantung ke UI generik, bukan sebaliknya.
Ciri komponen UI yang baik:
- Props sedikit dan jelas (varian, ukuran).
- Meneruskan ...rest agar onClick, type, aria-* bisa dipakai.
- Mendukung children untuk isi.
- Tidak melakukan fetch atau membaca context fitur.
Custom hook untuk logika
Kalau komponen berisi banyak useState dan useEffect yang terkait satu urusan, misalnya mengelola form atau mengambil data, pindahkan ke custom hook: fungsi biasa yang namanya diawali use dan boleh memanggil hook lain. Komponen jadi pendek dan fokus pada tampilan, sedangkan hook bisa dipakai ulang dan dites terpisah. Ini versi modern dari pola container/presentational di modul 4.
Aturan hook tetap berlaku: panggil hanya di level teratas komponen atau custom hook lain, tidak di dalam if, loop, atau fungsi biasa.
File index.js sebagai pintu depan fitur
Setiap fitur boleh punya index.js yang hanya mengekspor bagian yang boleh dipakai fitur lain. Komponen internal tetap "privat". Ini membuat import lebih rapi (import { ProductList } from '../features/produk') dan memudahkan refactor isi fitur tanpa memengaruhi pemakai.
Penamaan file dan komponen
Konsistensi lebih penting daripada pilihan konvensinya. Yang umum dipakai: file komponen memakai PascalCase sesuai nama komponennya (ProductCard.jsx), file hook memakai camelCase berawalan use (useProducts.js), dan file helper memakai camelCase (formatRupiah.js). Ekstensi .jsx untuk file yang berisi JSX dan .js untuk file tanpa JSX membantu editor serta pembaca. Satu komponen utama per file, dengan nama file sama dengan nama komponen, membuat fitur "Go to file" di editor langsung menemukan yang kamu cari.
Kapan memindahkan komponen ke components/ui?
Aturan praktis yang sering dipakai: kalau sebuah komponen dipakai di dua fitur atau lebih dan tidak bergantung pada data bisnis fitur mana pun, pindahkan ke folder UI generik. Kalau baru dipakai di satu fitur, biarkan di folder fitur itu. Memindahkan terlalu cepat membuat folder UI penuh komponen setengah jadi yang sebenarnya hanya cocok untuk satu kasus.
Path alias
Import seperti '../../../components/ui/Button.jsx' mudah salah hitung. Vite mendukung alias lewat opsi resolve.alias di vite.config.js, misalnya @ menunjuk ke src, sehingga import menjadi '@/components/ui/Button.jsx'. Ini opsional, tapi sangat membantu ketika folder mulai dalam.
Contoh kode
Komponen UI generik:
// src/components/ui/Button.jsx
const varianStyle = {
primary: { background: '#2563eb', color: 'white' },
secondary: { background: '#e5e7eb', color: '#111' },
};
export function Button({ varian = 'primary', children, style, ...rest }) {
// ...rest meneruskan onClick, type, disabled, aria-label, dll.
return (
<button
style={{ padding: '8px 16px', border: 'none', borderRadius: 8, ...varianStyle[varian], ...style }}
{...rest}
>
{children}
</button>
);
}
Custom hook untuk logika form:
// src/features/auth/hooks/useLoginForm.js
import { useState } from 'react';
export function useLoginForm() {
const [values, setValues] = useState({ email: '', password: '' });
const [errors, setErrors] = useState({});
const handleChange = (e) => {
const { name, value } = e.target;
setValues((v) => ({ ...v, [name]: value }));
};
const validate = () => {
const err = {};
if (!values.email.includes('@')) err.email = 'Email tidak valid';
if (values.password.length < 8) err.password = 'Minimal 8 karakter';
setErrors(err);
return Object.keys(err).length === 0;
};
return { values, errors, handleChange, validate };
}
Komponen fitur yang memakai keduanya:
// src/features/auth/components/LoginForm.jsx
import { Button } from '../../../components/ui/Button.jsx';
import { useLoginForm } from '../hooks/useLoginForm.js';
export function LoginForm({ onSukses }) {
const { values, errors, handleChange, validate } = useLoginForm();
const handleSubmit = (e) => {
e.preventDefault();
if (validate()) onSukses(values.email);
};
return (
<form onSubmit={handleSubmit}>
<input name="email" value={values.email} onChange={handleChange} placeholder="Email" />
{errors.email && <small>{errors.email}</small>}
<input name="password" type="password" value={values.password} onChange={handleChange} />
{errors.password && <small>{errors.password}</small>}
<Button type="submit">Masuk</Button>
</form>
);
}
// Email "sari" + password "123" → muncul "Email tidak valid" dan "Minimal 8 karakter"
// src/features/auth/index.js, pintu depan fitur
export { LoginForm } from './components/LoginForm.jsx';
Kesalahan umum
- Komponen UI generik meng-import kode fitur. Misalnya
ButtonmembacauseCart(). AkibatnyaButtontidak bisa dipakai di fitur lain. Jaga arah ketergantungan satu arah. - Membuat folder terlalu dalam sejak hari pertama. Struktur per fitur dipakai ketika fiturnya mulai banyak. Proyek kecil boleh mulai sederhana lalu dipindah bertahap.
- Custom hook yang tidak memakai hook apa pun. Kalau fungsimu tidak memanggil
useState/useEffect/hook lain, itu fungsi biasa; jangan beri awalanuse. Taruh dilib/. - Memanggil hook di dalam kondisi.
if (login) { useLoginForm() }melanggar aturan hook dan menyebabkan bug urutan state. Panggil selalu di level atas. - Satu file berisi banyak komponen besar. Satu file satu komponen utama memudahkan pencarian. Komponen kecil pembantu yang hanya dipakai di file itu boleh tetap di sana.
Latihan
- Mudah, Buat komponen UI
Inputyang menerimalabeldan meneruskan sisa props ke elemen<input>. - Sedang, Ekstrak logika counter (nilai, tambah, kurang, reset) menjadi custom hook
useCounter(awal), lalu pakai di dua komponen berbeda. - Menantang, Rancang struktur folder per fitur untuk aplikasi toko online dengan fitur
produk,keranjang, dancheckout. Tulis daftar file yang kamu rencanakan beserta isiindex.jsfiturkeranjang.
Lihat solusi
// 1. src/components/ui/Input.jsx
export function Input({ label, id, ...rest }) {
return (
<label htmlFor={id} style={{ display: 'grid', gap: 4 }}>
{label}
<input id={id} {...rest} />
</label>
);
}
// <Input id="nama" label="Nama" placeholder="Isi nama" />
// 2. src/hooks/useCounter.js
import { useState } from 'react';
export function useCounter(awal = 0) {
const [nilai, setNilai] = useState(awal);
return {
nilai,
tambah: () => setNilai((n) => n + 1),
kurang: () => setNilai((n) => n - 1),
reset: () => setNilai(awal),
};
}
// Pemakaian di dua komponen, state masing-masing terpisah
function Like() {
const { nilai, tambah } = useCounter();
return <button onClick={tambah}>👍 {nilai}</button>;
}
function Stok() {
const { nilai, tambah, kurang } = useCounter(10);
return <p>Stok {nilai} <button onClick={kurang}>-</button><button onClick={tambah}>+</button></p>;
}
# 3. Rancangan struktur
src/
├── app/App.jsx
├── components/ui/{Button,Input,Modal,Spinner}.jsx
├── features/
│ ├── produk/{api.js,hooks/useProducts.js,components/ProductList.jsx,index.js}
│ ├── keranjang/{CartContext.jsx,components/CartBadge.jsx,components/CartList.jsx,index.js}
│ └── checkout/{components/CheckoutForm.jsx,hooks/useCheckout.js,index.js}
├── hooks/useDebounce.js
└── lib/formatRupiah.js
// 3. src/features/keranjang/index.js
export { CartProvider, useCart } from './CartContext.jsx';
export { CartBadge } from './components/CartBadge.jsx';
export { CartList } from './components/CartList.jsx';
Ringkasan
- Struktur per fitur menaruh semua file satu fitur di satu folder, memudahkan menambah dan menghapus fitur.
- Komponen UI generik (
components/ui) tidak boleh bergantung pada kode fitur. - Komponen UI yang baik menerima
childrendan meneruskan...restke elemen HTML. - Custom hook memisahkan logika dari tampilan dan bisa dipakai ulang.
index.jsper fitur berfungsi sebagai pintu depan yang mengatur apa yang boleh dipakai dari luar.