Files
trackgo-fe/packages/core-events/README.md
T

6.2 KiB

@repo/core-events

← Back to Root

Overview

@repo/core-events is the decoupled Nervous System of the ERP. It provides a highly performant, strictly typed Event Bus powered by mitt and React hooks.

By routing communication through a centralized event bus, we achieve:

  • Zero Coupling: Publishers and subscribers don't need to import or know about each other.
  • Extreme Performance: Components can subscribe to high-frequency data streams (like WebSockets) and update their own local state without triggering massive React tree re-renders.
  • Memory Safety: The provided useAppEvent hook automatically handles subscription cleanup on component unmount, preventing the most common source of memory leaks in SPA architectures.
  • Strict Contracts: The AppEvents registry enforces payload shapes at compile-time, ensuring publishers and subscribers always agree on the data contract.

Architecture

graph TD
    subgraph Publishers
        A[Cashier UI]
        B[Profile Settings]
        C[WebSocket Client]
    end

    subgraph Core
        E((Event Bus<br/>mitt))
        R[[AppEvents<br/>Registry]] -.-> E
    end

    subgraph Subscribers
        X[Electron IPC Bridge]
        Y[IndexedDB Sync]
        Z[Stock Grid Row]
    end

    A -- "DEVICE:PRINT_RECEIPT" --> E
    B -- "AUTH:PROFILE_UPDATED" --> E
    C -- "WS:STOCK_UPDATE" --> E

    E -.-> X
    E -.-> Y
    E -.-> Z

    style E fill:#4263eb,color:#fff,stroke:#fff
    style R fill:#2b8a3e,color:#fff,stroke:#fff

Defining Events

Every event in the system MUST be registered in src/events.registry.ts. This provides a single source of truth and full autocomplete across the codebase.

To add a new event, simply extend the AppEvents type:

// packages/core-events/src/events.registry.ts

export interface CheckoutPayload {
  orderId: string;
  total: number;
}

export type AppEvents = {
  // Existing events...
  'DEVICE:PRINT_RECEIPT': PrintReceiptPayload;
  
  // Your new event:
  'STORE:CHECKOUT_COMPLETED': CheckoutPayload;
};

Usage Examples

Here are three real-world architectural patterns powered by the Event Bus.

Example 1: Hardware Abstraction (Cross-Platform)

Problem: The web app needs to print receipts. If running in a browser, it should use window.print(). If running in the Electron wrapper, it must use the secure IPC bridge (window.electronAPI.print()). We don't want the UI components cluttered with platform-detection logic.

Solution: The UI publishes a blind event. A headless listener handles the platform routing.

Publisher (Cashier UI):

import { usePublishEvent } from '@repo/core-events';

export function CashierUI() {
  const publish = usePublishEvent();

  const handlePrint = () => {
    // Fire and forget. Zero knowledge of how printing actually happens.
    publish('DEVICE:PRINT_RECEIPT', {
      receiptId: 'RCP-123',
      items: [...],
      total: 45.00,
      cashierName: 'Firman'
    });
  };

  return <Button onClick={handlePrint}>Print Receipt</Button>;
}

Subscriber (Headless Listener):

import { useAppEvent } from '@repo/core-events';

export function PrinterListener() {
  useAppEvent('DEVICE:PRINT_RECEIPT', (payload) => {
    const isElectron = typeof window !== 'undefined' && !!window.electronAPI;

    if (isElectron) {
      // Route via secure Electron IPC bridge
      window.electronAPI.print({ silent: true });
    } else {
      // Fallback to standard browser print dialog
      window.print();
    }
  });

  return null; // Renders nothing
}

Example 2: Extreme Performance (High-Frequency Data)

Problem: A massive data grid (1,000+ rows) receives 50 WebSocket updates per second. If the parent grid holds the state and passes it down via props, React will attempt to re-render all 1,000 rows 50 times a second, crushing the browser.

Solution: The parent grid renders empty rows. Each row subscribes to the event bus and filters updates so it only re-renders when its specific data changes.

Parent Grid (Never re-renders):

export function LiveStockGrid() {
  // Generates 1000 IDs once. No stock data is stored here!
  const stockIds = generateStockIds(1000); 

  return (
    <table>
      <tbody>
        {stockIds.map((id) => (
          <StockRow key={id} stockId={id} />
        ))}
      </tbody>
    </table>
  );
}

Child Row (Targeted Updates):

import { memo, useState } from 'react';
import { useAppEvent } from '@repo/core-events';

export const StockRow = memo(function StockRow({ stockId }) {
  const [data, setData] = useState(null);

  useAppEvent('WS:STOCK_UPDATE', (payload) => {
    // CRITICAL: Filter out events for other rows.
    // 999 out of 1000 rows will exit here instantly without causing a re-render.
    if (payload.id !== stockId) return;
    
    // Only the targeted row updates its local state
    setData(payload);
  });

  return (
    <tr>
      <td>{stockId}</td>
      <td>{data?.price}</td>
    </tr>
  );
});

Example 3: Background Sync (Auth to IndexedDB)

Problem: When a user updates their profile, we need to persist it to the secure local IndexedDB. We don't want to tightly couple our UI forms to the @repo/core-storage package.

Solution: The UI form announces the profile update. A dedicated storage listener persists it in the background.

Publisher (Profile UI):

import { usePublishEvent } from '@repo/core-events';

export function ProfileSettingsUI() {
  const publish = usePublishEvent();

  const handleSave = () => {
    publish('AUTH:PROFILE_UPDATED', {
      id: 'user-1',
      name: 'Firman',
      email: 'firman@eigen.co.id',
    });
  };

  return <Button onClick={handleSave}>Save Profile</Button>;
}

Subscriber (Storage Sync Listener):

import { useAppEvent } from '@repo/core-events';
import { secureIndexedDB } from '@repo/core-storage';

export function StorageSyncListener() {
  useAppEvent('AUTH:PROFILE_UPDATED', (payload) => {
    // Automatically encrypted at rest because 'user_profile' 
    // is defined in ENCRYPTED_KEYS in @repo/core-storage
    secureIndexedDB.setItem('user_profile', payload).catch(console.error);
  });

  return null;
}