Membangun Repository Pattern untuk Client-Side Storage: Abstraksi Data Lokal di Aplikasi Web Modern
1. Pendahuluan
Sebagai developer web, kita sering berurusan dengan data. Data ini bisa datang dari backend melalui API, tapi tak jarang kita juga perlu menyimpan dan mengelola data secara lokal di sisi klien, yaitu di browser pengguna. Mulai dari preferensi pengguna, token autentikasi, hingga data offline yang kompleks untuk aplikasi Progressive Web App (PWA) Anda.
Masalahnya? Ada banyak pilihan untuk menyimpan data di browser: LocalStorage, SessionStorage, IndexedDB, Cache API, bahkan Origin Private File System (OPFS). Masing-masing memiliki karakteristik, batasan, dan API yang berbeda. Jika kita berinteraksi langsung dengan API-API ini di setiap bagian aplikasi, kode kita bisa menjadi:
- Berantakan dan Sulit Dibaca: Logika penyimpanan tersebar di mana-mana.
- Sulit Diuji: Bergantung pada API browser yang asli.
- Kaku: Sulit untuk beralih dari satu mekanisme penyimpanan ke yang lain tanpa refactoring besar.
- Rentan Error: Penanganan error dan konsistensi data menjadi tantangan tersendiri.
Di artikel ini, kita akan membahas bagaimana kita bisa menerapkan Repository Pattern di frontend untuk mengatasi masalah-masalah ini. Repository Pattern, yang sering kita dengar di dunia backend, ternyata sangat ampuh untuk menyederhanakan manajemen data lokal di aplikasi web modern. Mari kita selami!
2. Mengapa Abstraksi Data Lokal itu Penting?
Bayangkan Anda sedang membangun aplikasi to-do list offline-first. Anda ingin menyimpan daftar tugas pengguna sehingga mereka bisa mengaksesnya bahkan tanpa koneksi internet.
Pada awalnya, Anda mungkin memilih LocalStorage karena paling mudah:
// Tanpa abstraksi, langsung pakai LocalStorage
function saveTodos(todos) {
localStorage.setItem('todos', JSON.stringify(todos));
}
function getTodos() {
const data = localStorage.getItem('todos');
return data ? JSON.parse(data) : [];
}
// Digunakan di komponen React/Vue/Vanilla JS
const myTodos = getTodos();
// ...
saveTodos(updatedTodos);
Ini bekerja dengan baik untuk kasus sederhana. Tapi bagaimana jika:
- Jumlah tugas bertambah banyak (>5MB)?
LocalStoragepunya batasan ukuran. - Anda butuh fitur query yang lebih canggih (misal: cari tugas yang sudah selesai)?
LocalStoragehanya key-value sederhana. - Anda ingin menyimpan data biner (misal: gambar kecil untuk setiap tugas)?
LocalStoragehanya bisa string.
Pada titik ini, Anda mungkin perlu beralih ke IndexedDB atau OPFS. ⚠️ Dan di sinilah masalah dimulai! Setiap getTodos() atau saveTodos() di seluruh aplikasi Anda harus diubah untuk menggunakan API IndexedDB yang asinkron dan lebih kompleks. Ini adalah contoh klasik tight coupling atau ketergantungan erat antara logika bisnis aplikasi dan detail implementasi penyimpanan data.
Abstraksi adalah kuncinya. Dengan abstraksi, kita menyembunyikan detail kompleks dari bagaimana data disimpan di balik antarmuka yang sederhana dan konsisten. Aplikasi kita hanya perlu tahu “saya ingin menyimpan tugas” atau “saya ingin mengambil tugas”, tanpa peduli apakah tugas itu ada di LocalStorage, IndexedDB, atau bahkan di Cache API yang dikelola Service Worker.
3. Konsep Repository Pattern di Frontend
Repository Pattern adalah pola desain yang mengabstraksi lapisan data. Ini menyediakan antarmuka yang seragam untuk mengakses data, terlepas dari sumber data yang mendasarinya (database, API, atau dalam kasus kita, berbagai client-side storage).
🎯 Tujuan utama Repository Pattern di frontend:
- Memisahkan Concern: Logika bagaimana data disimpan atau diambil sepenuhnya terpisah dari logika bisnis aplikasi atau komponen UI.
- Kode Lebih Bersih: Mengurangi boilerplate dan membuat kode aplikasi lebih fokus pada fungsionalitas intinya.
- Mudah Diuji: Karena logika penyimpanan terisolasi, kita bisa dengan mudah membuat “mock” atau tiruan dari repository saat melakukan unit testing pada komponen atau logika bisnis.
- Fleksibilitas: Jika Anda memutuskan untuk mengganti mekanisme penyimpanan (misal dari
LocalStoragekeIndexedDB), Anda hanya perlu mengganti implementasi repository, bukan mengubah setiap tempat di mana data diakses di aplikasi.
Anggap saja Repository Pattern ini seperti “perpustakaan pribadi” untuk data Anda. Anda tidak perlu tahu bagaimana buku-buku diatur di rak, di mana lokasi persisnya, atau bagaimana cara mengambilnya. Anda cukup memberi tahu pustakawan (repository) buku apa yang Anda inginkan, dan pustakawan akan mengurus sisanya.
4. Membangun Antarmuka Repository (Interface)
Langkah pertama adalah mendefinisikan “kontrak” atau antarmuka yang akan dipatuhi oleh semua implementasi repository kita. Ini memastikan bahwa, terlepas dari mekanisme penyimpanan yang digunakan, semua repository akan memiliki metode dasar yang sama.
Menggunakan TypeScript sangat membantu di sini untuk memastikan type safety.
// src/data/IRepository.ts
/**
* Antarmuka generik untuk operasi dasar CRUD pada data lokal.
* T adalah tipe data yang akan disimpan (misal: Todo, UserSetting).
* ID adalah tipe data untuk ID unik (misal: string, number).
*/
export interface IRepository<T, ID = string> {
// Mengambil satu item berdasarkan ID
getById(id: ID): Promise<T | undefined>;
// Mengambil semua item
getAll(): Promise<T[]>;
// Menambahkan item baru. Mengembalikan item yang ditambahkan (mungkin dengan ID yang di-generate).
add(item: T): Promise<T>;
// Memperbarui item yang sudah ada.
update(id: ID, item: T): Promise<T>;
// Menghapus item berdasarkan ID
delete(id: ID): Promise<void>;
}
Antarmuka ini sangat fleksibel. T adalah tipe data apa pun yang ingin Anda simpan (misalnya Todo, Product, UserSetting), dan ID adalah tipe untuk identifikasi unik item tersebut.
5. Implementasi Repository untuk Berbagai Storage
Sekarang, mari kita buat implementasi nyata dari IRepository untuk beberapa client-side storage yang umum.
5.1. LocalStorageRepository: Untuk Data Sederhana
LocalStorage ideal untuk data key-value yang kecil dan tidak sensitif.
// src/data/LocalStorageRepository.ts
import { IRepository } from './IRepository';
export class LocalStorageRepository<T extends { id: string }> implements IRepository<T, string> {
private keyPrefix: string;
constructor(entityName: string) {
// Gunakan prefix untuk menghindari konflik key dan mengelompokkan data
this.keyPrefix = `app_${entityName}`;
}
private getItemKey(id: string): string {
return `${this.keyPrefix}_${id}`;
}
async getById(id: string): Promise<T | undefined> {
const data = localStorage.getItem(this.getItemKey(id));
return data ? JSON.parse(data) : undefined;
}
async getAll(): Promise<T[]> {
const items: T[] = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && key.startsWith(this.keyPrefix)) {
const data = localStorage.getItem(key);
if (data) {
items.push(JSON.parse(data));
}
}
}
return items;
}
async add(item: T): Promise<T> {
if (!item.id) {
// Generate ID jika belum ada (sesuaikan dengan kebutuhan Anda)
item.id = crypto.randomUUID();
}
localStorage.setItem(this.getItemKey(item.id), JSON.stringify(item));
return item;
}
async update(id: string, item: T): Promise<T> {
// Pastikan item yang diperbarui memiliki ID yang sama
if (item.id && item.id !== id) {
console.warn(`ID item tidak cocok dengan ID yang diberikan. Menggunakan ID yang diberikan: ${id}`);
}
localStorage.setItem(this.getItemKey(id), JSON.stringify({ ...item, id }));
return { ...item, id };
}
async delete(id: string): Promise<void> {
localStorage.removeItem(this.getItemKey(id));
}
}
📌 Catatan: Untuk LocalStorageRepository, kita mengasumsikan tipe T memiliki properti id: string. Ini adalah batasan LocalStorage yang hanya bisa menyimpan data berdasarkan key tunggal. Untuk getAll(), kita perlu mengiterasi seluruh localStorage dan memfilter berdasarkan keyPrefix, yang bisa tidak efisien untuk data sangat besar.
5.2. IndexedDBRepository: Untuk Data Terstruktur dan Skala Besar
IndexedDB adalah database NoSQL yang powerful di browser, cocok untuk data terstruktur, biner, dan volume besar. Karena API-nya asinkron dan berbasis event, implementasinya sedikit lebih kompleks, namun library seperti Dexie.js dapat sangat menyederhanakan ini. Untuk tujuan demonstrasi, kita akan menggunakan API native yang disederhanakan.
// src/data/IndexedDBRepository.ts
import { IRepository } from './IRepository';
// Helper untuk membuka database IndexedDB
const DB_NAME = 'MyWebAppDB';
const DB_VERSION = 1;
function openDb(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
if (!db.objectStoreNames.contains('todos')) {
db.createObjectStore('todos', { keyPath: 'id', autoIncrement: false });
}
// Tambahkan objectStore lain jika diperlukan
if (!db.objectStoreNames.contains('settings')) {
db.createObjectStore('settings', { keyPath: 'id' });
}
};
request.onsuccess = (event) => {
resolve((event.target as IDBOpenDBRequest).result);
};
request.onerror = (event) => {
reject(`IndexedDB error: ${((event.target as IDBOpenDBRequest).error)?.message}`);
};
});
}
export class IndexedDBRepository<T extends { id: string }> implements IRepository<T, string> {
private objectStoreName: string;
constructor(objectStoreName: string) {
this.objectStoreName = objectStoreName;
}
private async getObjectStore(mode: IDBTransactionMode): Promise<IDBObjectStore> {
const db = await openDb();
const transaction = db.transaction(this.objectStoreName, mode);
return transaction.objectStore(this.objectStoreName);
}
async getById(id: string): Promise<T | undefined> {
const store = await this.getObjectStore('readonly');
return new Promise((resolve, reject) => {
const request = store.get(id);
request.onsuccess = (event) => resolve((event.target as IDBRequest).result);
request.onerror = (event) => reject(`Failed to get item: ${((event.target as IDBRequest).error)?.message}`);
});
}
async getAll(): Promise<T[]> {
const store = await this.getObjectStore('readonly');
return new Promise((resolve, reject) => {
const request = store.getAll();
request.onsuccess = (event) => resolve((event.target as IDBRequest).result);
request.onerror = (event) => reject(`Failed to get all items: ${((event.target as IDBRequest).error)?.message}`);
});
}
async add(item: T): Promise<T> {
if (!item.id) {
item.id = crypto.randomUUID(); // Pastikan ada ID unik
}
const store = await this.getObjectStore('readwrite');
return new Promise((resolve, reject) => {
const request = store.add(item);
request.onsuccess = () => resolve(item);
request.onerror = (event) => reject(`Failed to add item: ${((event.target as IDBRequest).error)?.message}`);
});
}
async update(id: string, item: T): Promise<T> {
const store = await this.getObjectStore('readwrite');
return new Promise((resolve, reject) => {
// IndexedDB membutuhkan seluruh objek untuk di-put/update
const updatedItem = { ...item, id: id };
const request = store.put(updatedItem);
request.onsuccess = () => resolve(updatedItem);
request.onerror = (event) => reject(`Failed to update item: ${((event.target as IDBRequest).error)?.message}`);
});
}
async delete(id: string): Promise<void> {
const store = await this.getObjectStore('readwrite');
return new Promise((resolve, reject) => {
const request = store.delete(id);
request.onsuccess = () => resolve();
request.onerror = (event) => reject(`Failed to delete item: ${((event.target as IDBRequest).error)?.message}`);
});
}
}
💡 Tips: Untuk implementasi IndexedDB yang lebih robust dan mudah, pertimbangkan untuk menggunakan library seperti Dexie.js. Ini akan mengelola banyak boilerplate dan kerumitan API native IndexedDB untuk Anda.
6. Menggunakan Repository di Aplikasi Anda
Setelah kita memiliki implementasi repository, cara menggunakannya di aplikasi menjadi jauh lebih bersih. Komponen UI Anda tidak perlu lagi tahu apakah data disimpan di LocalStorage atau IndexedDB.
Misalnya, kita punya tipe Todo sederhana:
// src/types/Todo.ts
export interface Todo {
id: string;
text: string;
completed: boolean;
}
Kemudian, di komponen React (atau framework lain), Anda bisa menggunakan repository:
// src/components/TodoList.tsx
import React, { useState, useEffect } from 'react';
import { Todo } from '../types/Todo';
import { IRepository } from '../data/IRepository';
// import { LocalStorageRepository } from '../data/LocalStorageRepository';
import { IndexedDBRepository } from '../data/IndexedDBRepository';
// Inisialisasi repository di level aplikasi atau melalui Dependency Injection
// const todoRepository: IRepository<Todo, string> = new LocalStorageRepository<Todo>('todos');
const todoRepository: IRepository<Todo, string> = new IndexedDBRepository<Todo>('todos'); // ✅ Mudah beralih!
function TodoList() {
const [todos, setTodos] = useState<Todo[]>([]);
const [newTodoText, setNewTodoText] = useState('');
useEffect(() => {
async function fetchTodos() {
const storedTodos = await todoRepository.getAll();
setTodos(storedTodos);
}
fetchTodos();
}, []);
const handleAddTodo = async () => {
if (newTodoText.trim()) {
const newTodo: Todo = {
id: '', // ID akan di-generate oleh repository
text: newTodoText,
completed: false,
};
const addedTodo = await todoRepository.add(newTodo);
setTodos((prevTodos) => [...prevTodos, addedTodo]);
setNewTodoText('');
}
};
const handleToggleComplete = async (id: string) => {
const todoToUpdate = todos.find((todo) => todo.id === id);
if (todoToUpdate) {
const updatedTodo = await todoRepository.update(id, {
...todoToUpdate,
completed: !todoToUpdate.completed,
});
setTodos((prevTodos) =>
prevTodos.map((todo) => (todo.id === id ? updatedTodo : todo))
);
}
};
const handleDeleteTodo = async (id: string) => {
await todoRepository.delete(id);
setTodos((prevTodos) => prevTodos.filter((todo) => todo.id !== id));
};
return (
<div>
<h1>My Todo List</h1>
<input
type="text"
value={newTodoText}
onChange={(e) => setNewTodoText(e.target.value)}
placeholder="Add a new todo"
/>
<button onClick={handleAddTodo}>Add Todo</button>
<ul>
{todos.map((todo) => (
<li key={todo.id}>
<span
style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}
onClick={() => handleToggleComplete(todo.id)}
>
{todo.text}
</span>
<button onClick={() => handleDeleteTodo(todo.id)}>Delete</button>
</li>
))}
</ul>
</div>
);
}
export default TodoList;
Lihat betapa bersihnya komponen TodoList ini! 🤩 Ia tidak peduli apakah data diambil dari LocalStorage atau IndexedDB. Jika Anda ingin beralih, Anda hanya perlu mengubah satu baris inisialisasi repository. Ini adalah kekuatan Repository Pattern!
7. Tips & Best Practices
- Penanganan Error: Pastikan setiap metode
asyncdi repository Anda memiliki penanganan error yang robust (try...catch) dan melempar error yang informatif jika terjadi masalah. - Testing: Dengan Repository Pattern, testing komponen UI Anda menjadi sangat mudah. Anda bisa membuat mock dari
IRepositorydan menginjeksikannya ke komponen Anda, sehingga tidak perlu berinteraksi dengan API browser yang sebenarnya saat testing.// Contoh mock repository untuk testing const mockTodoRepository: IRepository<Todo, string> = { getById: async (id) => ({ id, text: 'Test Todo', completed: false }), getAll: async () => [{ id: '1', text: 'Mock Todo 1', completed: false }], add: async (item) => ({ ...item, id: crypto.randomUUID() }), update: async (id, item) => ({ ...item, id }), delete: async () => {}, }; // Kemudian inject mockTodoRepository ini ke komponen Anda saat testing - Dependency Injection (DI): Untuk aplikasi yang lebih besar, pertimbangkan menggunakan pola Dependency Injection untuk menyediakan instance repository ke komponen atau layanan Anda. Ini akan membuat kode lebih fleksibel dan mudah diuji.
- Memilih Storage yang Tepat:
LocalStorage/SessionStorage: Untuk data non-sensitif, key-value sederhana, dan ukuran kecil (<5MB).IndexedDB: Untuk data terstruktur, volume besar, data biner, dan kebutuhan query kompleks.Cache API(via Service Worker): Ideal untuk caching respons jaringan (gambar, CSS, JS, respons API) agar bisa offline. Bisa diintegrasikan dengan repository untuk data yang sifatnya cache.Origin Private File System (OPFS): Untuk data file yang besar dan performa tinggi, mirip dengan sistem file native.
- Library Pihak Ketiga: Jangan ragu menggunakan library yang sudah matang untuk mengimplementasikan repository Anda, terutama untuk
IndexedDB. Dexie.js atau localforage adalah pilihan yang sangat baik dan akan menghemat banyak waktu dan effort. - Migrasi Skema: Seiring waktu, struktur data Anda mungkin berubah. Anda perlu strategi untuk migrasi skema data lokal. Artikel lain di