WEB-DEVELOPMENT FRONTEND CLIENT-SIDE-STORAGE DESIGN-PATTERNS DATA-MANAGEMENT JAVASCRIPT TYPESCRIPT ARCHITECTURE MAINTAINABILITY TESTING INDEXEDDB LOCALSTORAGE OFFLINE-FIRST PWA

Membangun Repository Pattern untuk Client-Side Storage: Abstraksi Data Lokal di Aplikasi Web Modern

⏱️ 15 menit baca
👨‍💻

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:

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:

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:

  1. Memisahkan Concern: Logika bagaimana data disimpan atau diambil sepenuhnya terpisah dari logika bisnis aplikasi atau komponen UI.
  2. Kode Lebih Bersih: Mengurangi boilerplate dan membuat kode aplikasi lebih fokus pada fungsionalitas intinya.
  3. 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.
  4. Fleksibilitas: Jika Anda memutuskan untuk mengganti mekanisme penyimpanan (misal dari LocalStorage ke IndexedDB), 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