@repo/core-events
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.
This package is a pure tool. It ships zero application-specific events. Each consuming app (apps/web, apps/landing, etc.) registers its own events autonomously using TypeScript Declaration Merging — the same Inversion of Control pattern used by @repo/core-api's createHttpClient factory.
By routing communication through a centralized event bus, we achieve:
- App Autonomy: The core defines the bus. The app defines the contract. No circular knowledge.
- 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
useAppEventhook automatically handles subscription cleanup on component unmount, preventing the most common source of memory leaks in SPA architectures.
Architecture
graph TD
subgraph "@repo/core-events (Pure Tool)"
R["AppEventRegistry<br/>(empty interface)"]
T["AppEvents = mapped type"]
E((Event Bus<br/>mitt))
H[useAppEvent / usePublishEvent]
R --> T --> E
E --> H
end
subgraph "apps/web (App Autonomy)"
D["events.d.ts<br/>declare module augmentation"]
A[Cashier UI]
B[Profile Settings]
C[WebSocket Client]
X[Electron IPC Bridge]
Y[IndexedDB Sync]
Z[Stock Grid Row]
end
D -. "merges into" .-> R
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
style D fill:#e67700,color:#fff,stroke:#fff
Defining Events (Module Augmentation)
Important
Do NOT add application events to
packages/core-events/src/events.registry.ts. The core registry is intentionally empty. Each app owns its own event contract.
The core exports an open AppEventRegistry interface. Apps extend it using TypeScript's declare module syntax — the same pattern used for @types/* across the JS ecosystem.
Step 1: Create an augmentation file in your app
Warning
The
import type {}line is mandatory. Without it, TypeScript treatsdeclare moduleas an ambient module declaration that replaces the module's types instead of merging into them. All actual exports (useAppEvent,publish, etc.) would become invisible.
// apps/web/src/types/events.d.ts
// This import makes this file a module augmentation (merge)
// instead of an ambient declaration (replace).
import type {} from '@repo/core-events';
declare module '@repo/core-events' {
// Define your payload shapes
interface OrderPayload {
orderId: string;
total: number;
items: Array<{ sku: string; qty: number }>;
}
// Extend the registry
interface AppEventRegistry {
'STORE:ORDER_PLACED': OrderPayload;
'STORE:ORDER_CANCELLED': { orderId: string; reason: string };
'UI:SIDEBAR_TOGGLED': { collapsed: boolean };
}
}
Step 2: Use it — autocomplete works immediately
import { usePublishEvent, useAppEvent } from '@repo/core-events';
function CheckoutButton() {
const publish = usePublishEvent();
// ✅ 'STORE:ORDER_PLACED' autocompletes.
// ✅ Payload shape is enforced by TypeScript.
publish('STORE:ORDER_PLACED', { orderId: '123', total: 99, items: [...] });
}
function OrderTracker() {
// ✅ payload is fully typed as OrderPayload
useAppEvent('STORE:ORDER_PLACED', (payload) => {
console.log(payload.orderId); // string
});
}
Why this pattern?
| Concern | Old (Hardcoded) | New (Module Augmentation) |
|---|---|---|
| Core knows about app events? | ❌ Yes — violates IoC | ✅ No — core is a pure tool |
| Adding events requires editing core? | ❌ Yes | ✅ No — edit your app's .d.ts only |
| Multiple apps share the same registry? | ❌ Collision risk | ✅ Each app has its own .d.ts |
| Type safety / autocomplete | ✅ Works | ✅ Works identically |
Usage Examples
Here are three real-world architectural patterns powered by the Event Bus. All event types below are registered in apps/web/src/types/events.d.ts, not in the core package.
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',
timestamp: Date.now(),
});
};
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',
avatar: 'https://example.com/avatar.png',
updatedAt: Date.now(),
});
};
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;
}