Struktur Folder React yang Scalable dengan Feature-Based Architecture
Programming Tutorial React #react #architecture #folder-structure #javascript

Struktur Folder React yang Scalable dengan Feature-Based Architecture

A
Abd. Asis
7 min read
Bagikan:

Kamu pernah membuka proyek React yang sudah berjalan setahun dan tidak tahu harus mulai membaca dari mana? Folder components berisi 80 file tanpa konteks, logic data fetching bercampur dengan UI, dan tidak ada pola yang konsisten. Semuanya berasal dari satu keputusan awal yang salah: struktur folder dipilih berdasarkan tipe file, bukan berdasarkan fitur.

Feature-based folder structure adalah cara paling efektif untuk menghindari kondisi itu. Alih-alih mengelompokkan file berdasarkan apa jenisnya (components, hooks, utils), kamu mengelompokkannya berdasarkan milik siapa — fitur mana yang membutuhkan kode tersebut. Hasilnya adalah codebase yang bisa tumbuh tanpa kehilangan keterbacaan, dan developer baru bisa langsung paham navigasinya.

Artikel ini membahas bagaimana membangun struktur tersebut dari fondasi: mulai dari page component yang berperan sebagai orkestrator, prinsip single responsibility di setiap lapisan komponen, pola data fetching yang mengurangi re-render, hingga organisasi folder yang membuat setiap fitur berdiri sendiri.

Page Component Sebagai Orkestrator, Bukan Implementor

Perbedaan paling mencolok antara kode junior dan senior terletak di page component. Developer yang belum terbiasa dengan arsitektur yang baik cenderung menaruh segalanya di sana — fetch data, render UI, validasi state, logic error — semuanya dalam satu komponen yang membengkak.

Page component (atau root component) hanya punya satu tanggung jawab: mengambil semua potongan kode yang sudah ada di aplikasi dan merangkainya menjadi satu tampilan yang koheren. Ia tidak mengimplementasikan sesuatu; ia mengomposisikan.

// src/routes/todos.tsx
import { createFileRoute } from "@tanstack/react-router"
import { queryClient } from "@/lib/query-client"
import { getTodosQueryOptions } from "@/features/todos/hooks/todo-query-options"
import { TodoList } from "@/features/todos/components/todo-list"

export const Route = createFileRoute("/todos")({
  loader: async () => {
    await queryClient.ensureQueryData(getTodosQueryOptions())
  },
  component: TodosPage,
})

function TodosPage() {
  return (
    <main>
      <h1>Daftar Tugas</h1>
      <TodoList />
    </main>
  )
}

Lihat apa yang tidak ada di sini: tidak ada useState, tidak ada useEffect untuk fetching, tidak ada conditional rendering loading state. Semua itu ditangani di tempat yang lebih tepat. Page component hanya menyambungkan titik-titik.

Ini berlaku untuk Next.js, React Router, maupun TanStack Start — konsepnya sama meskipun sintaksnya berbeda.

Single Responsibility di Setiap Lapisan Komponen

Prinsip yang sama berlaku saat kamu masuk lebih dalam ke component tree. Setiap komponen hanya melakukan satu hal. Bukan dua hal yang “mirip”, bukan satu hal plus sedikit tambahan — satu hal.

Bayangkan kamu membangun tampilan daftar tugas. Alih-alih satu komponen besar yang fetch data, render list, dan tampilkan setiap item, kamu pisahkan menjadi tiga lapisan:

// src/features/todos/components/todo-list.tsx
import { useSuspenseQuery } from "@tanstack/react-query"
import { getTodosQueryOptions } from "../hooks/todo-query-options"
import { TodoCard } from "./todo-card"

export function TodoList() {
  const { data: todos } = useSuspenseQuery(getTodosQueryOptions())

  return (
    <ul className="flex flex-col gap-2">
      {todos.map((todo) => (
        <TodoCard key={todo.id} todo={todo} />
      ))}
    </ul>
  )
}

TodoList hanya tahu cara merender sebuah list. Ia tidak tahu bagaimana data diperoleh (itu urusan query options dan loader), dan ia tidak tahu bagaimana satu item ditampilkan (itu urusan TodoCard).

// src/features/todos/components/todo-card.tsx
import type { Todo } from "../types/todo"
import { Card } from "@/components/ui/card"

interface Props {
  todo: Todo
}

export function TodoCard({ todo }: Props) {
  return (
    <Card>
      <p>{todo.title}</p>
      <span>{todo.completed ? "Selesai" : "Belum selesai"}</span>
    </Card>
  )
}

TodoCard hanya tahu cara menampilkan satu todo. Empat lapisan ke bawah dari page component, baru kita menyentuh elemen HTML nyata — dan setiap lapisan punya alasan keberadaannya yang jelas.

Ini yang disebut Single Responsibility Principle: setiap unit kode hanya punya satu alasan untuk berubah. Jika desain card berubah, hanya TodoCard yang disentuh. Jika logika sorting list berubah, hanya TodoList yang dimodifikasi.

Data Fetching di Luar Komponen untuk Mengurangi Re-render

Salah satu pola paling berdampak yang jarang dibahas adalah memindahkan data fetching keluar dari komponen ke level navigasi (loader). Ini bukan sekadar soal organisasi kode — ini punya efek nyata pada performa.

Bayangkan komponen yang melakukan fetching di dalamnya:

  1. Render pertama saat mount
  2. Render kedua saat isLoading: true
  3. Render ketiga saat data akhirnya tersedia

Tiga render untuk satu operasi. Dengan menggunakan loader dan useSuspenseQuery, komponen hanya render sekali — karena data sudah tersedia sebelum komponen dimount.

// src/features/todos/hooks/todo-query-options.ts
import { queryOptions } from "@tanstack/react-query"
import { getTodos } from "../server/todo-functions"
import { todoQueryKeys } from "../constants/todo-query-keys"

export function getTodosQueryOptions() {
  return queryOptions({
    queryKey: todoQueryKeys.all,
    queryFn: getTodos,
    staleTime: 1000 * 60 * 5,
  })
}

Fungsi getTodosQueryOptions mengembalikan objek queryOptions dari TanStack Query. Pola ini — menyimpan query options di fungsi terpisah — membuatnya bisa digunakan ulang di mana saja: di loader untuk prefetching, di useSuspenseQuery untuk streaming, atau di useQuery jika kamu butuh behavior non-suspense. Semua konsisten tanpa menduplikasi query key atau query function.

// src/features/todos/constants/todo-query-keys.ts
export const todoQueryKeys = {
  all: ["todos"] as const,
  byId: (id: string) => [...todoQueryKeys.all, id] as const,
}

Query key disimpan sebagai konstanta terpisah. Ini menghilangkan risiko typo dan memudahkan invalidasi cache yang presisi — seperti yang dibahas di cara yang benar menggunakan React Query.

Struktur Folder Feature-Based yang Konkret

Inilah inti dari semua yang dibahas di atas: bagaimana menorganisasi file secara fisik sehingga setiap fitur berdiri sendiri dan mudah dinavigasi.

src/
├── features/
│   ├── todos/
│   │   ├── components/
│   │   │   ├── todo-list.tsx
│   │   │   └── todo-card.tsx
│   │   ├── hooks/
│   │   │   └── todo-query-options.ts
│   │   ├── server/
│   │   │   └── todo-functions.ts
│   │   ├── constants/
│   │   │   └── todo-query-keys.ts
│   │   └── types/
│   │       └── todo.ts
│   └── shared/
│       ├── components/
│       │   └── ui/
│       └── hooks/
│           └── use-debounce.ts
├── lib/
│   ├── db.ts
│   ├── query-client.ts
│   └── utils.ts
└── routes/
    └── todos.tsx

Setiap fitur — todos, users, posts, apapun — punya folder sendiri dengan sub-folder yang konsisten:

  • components/ — semua komponen UI yang spesifik untuk fitur ini
  • hooks/ — query options dan custom hooks terkait fitur
  • server/ — fungsi yang berjalan di server (server functions, API calls)
  • constants/ — query keys dan konstanta lain milik fitur ini
  • types/ — TypeScript types yang di-infer dari database atau didefinisikan manual

Manfaat praktis dari struktur ini: jika kamu diminta mengerjakan fitur users, kamu langsung tahu seluruh kode ada di features/users/. Tidak perlu berburu di berbagai folder. Dan jika dua developer bekerja bersamaan — satu di todos dan satu di users — kemungkinan merge conflict nyaris nol.

Folder features/shared/ mengikuti struktur yang sama persis dengan fitur lain. Di sana kamu taruh komponen UI generik (shadcn/ui, design system), hooks yang tidak terikat domain tertentu seperti useDebounce, dan shared types. Bedanya dengan lib/: shared untuk hal yang UI-related, lib untuk low-level utilities yang tidak butuh antarmuka.

Apa yang Masuk ke lib/ dan Apa yang Masuk ke features/shared/

Kebingungan ini sering muncul dan cukup mudah diselesaikan dengan satu pertanyaan: apakah ini langsung berkaitan dengan UI?

lib/features/shared/
Database connectionYaTidak
Environment variablesYaTidak
cn() utility (clsx + tailwind-merge)YaTidak
Komponen Button, Card, BadgeTidakYa
Hook useWindowSizeTidakYa
Query client instanceYaTidak

lib/ adalah infrastruktur aplikasi — hal-hal yang bisa ada tanpa UI sama sekali. features/shared/ adalah shared UI layer — hal-hal yang tidak milik satu fitur tertentu tapi tetap berkaitan dengan tampilan.

Hindari membuat barrel file (index.ts) yang mengekspor semua file dari sebuah folder. Ini memudahkan import di permukaan, tapi bisa menyebabkan masalah tree-shaking di Vite dan circular dependency yang sulit di-debug.

Yang Sering Salah Dipahami Tentang Arsitektur Ini

Ada beberapa hal yang perlu diperhatikan saat pertama kali menerapkan pola ini.

Pertama, bukan berarti setiap komponen harus sekecil mungkin. Tujuannya adalah kejelasan tanggung jawab, bukan minimisasi ukuran. Komponen 100 baris yang melakukan satu hal dengan baik lebih baik daripada 5 komponen 20 baris yang tanggung jawabnya tidak jelas.

Kedua, jangan buru-buru mengabstraksi. Jika sebuah komponen atau hook baru digunakan di satu tempat, taruh saja di folder fitur tersebut. Pindah ke shared hanya ketika benar-benar dibutuhkan di tempat kedua — bukan antisipasi kebutuhan.

Ketiga, imports antar fitur harus diminimalkan. Jika features/todos mengimpor dari features/users, itu sinyal bahwa ada coupling yang perlu dievaluasi. Komposisi sebaiknya terjadi di level routes/pages, bukan di dalam fitur itu sendiri.

Kesimpulan

Feature-based structure bukan tentang mengikuti aturan demi aturan — ini tentang membangun codebase yang terasa natural untuk dinavigasi, bahkan setelah berbulan-bulan tidak menyentuhnya. Dengan menjaga setiap fitur self-contained, memisahkan tanggung jawab di setiap lapisan komponen, dan memindahkan data fetching keluar dari komponen, kamu memberi proyek fondasi yang tahan terhadap skala. Langkah selanjutnya yang layak dieksplorasi adalah menambahkan pola optimistic updates dengan TanStack Query mutations untuk memberikan pengalaman yang lebih responsif.

Referensi

  1. 1React Folder Structure in 5 Steps — Robin Wieruch
  2. 2Bulletproof React: Project Structure — alan2207/bulletproof-react
  3. 3TanStack Query: Query Options — Dokumentasi Resmi
  4. 4Applying the Single Responsibility Principle to React App — DhiWise

Tentang Penulis

Abd. Asis

Abd. Asis

Software Developer dan Laravel Programmer dari Madura, Indonesia. Passionate tentang PHP, Laravel, dan teknologi web modern.

Artikel Terkait

Artikel lain yang mungkin menarik untuk kamu