Observer Pattern
Define a subscription mechanism to notify multiple objects about events that happen to the object they're observing.
The Problemβ
You're building a stock trading app. When a stock price changes, multiple components need to react: the chart updates, alerts trigger, the portfolio recalculates.
The naive approach:
// stock-service.ts β the publisher knows ALL its consumers
import { updateChart } from "./chart";
import { checkAlerts } from "./alerts";
import { recalcPortfolio } from "./portfolio";
function updateStockPrice(stock: Stock, newPrice: number) {
stock.price = newPrice;
updateChart(stock); // tight coupling
checkAlerts(stock); // tight coupling
recalcPortfolio(stock); // tight coupling
// ... every new feature = modify this file and add another import
}
Every new subscriber = modify the publisher. The publisher must know about every consumer.
The Solutionβ
Publishers don't know their subscribers. They just emit events. Subscribers register independently:
import { createObservable } from "@pithos/core/eidos/observer/observer";
type PriceUpdate = { symbol: string; price: number };
const priceChanged = createObservable<PriceUpdate>();
// Chart subscribes
priceChanged.subscribe(({ symbol, price }) => {
chart.addPoint(symbol, price);
});
// Alert system subscribes (doesn't know about chart)
priceChanged.subscribe(({ symbol, price }) => {
if (price > thresholds[symbol]) sendAlert(`${symbol} spike!`);
});
// Portfolio subscribes (doesn't know about chart or alerts)
priceChanged.subscribe(({ symbol, price }) => {
portfolio.recalculate(symbol, price);
});
// Publisher doesn't know who's listening
// TS enforces the payload shape β emit({ symbol: 123 }) is a compile error
priceChanged.notify({ symbol: "AAPL", price: 150 });
New subscriber? Just call .subscribe(). No publisher changes needed. Three independent systems react to the same event without knowing about each other.
Live Demoβ
- stockTicker.ts
- Usage
/**
* Stock ticker using Pithos Observer pattern.
*
* The market ALWAYS runs. Subscribers receive updates only when subscribed.
* Unsubscribe = you stop receiving, but the market doesn't stop.
*/
import { createObservable } from "@pithos/core/eidos/observer/observer";
import type { Stock, StockUpdate } from "./types";
import type { Index } from "./data";
import { STOCKS as INITIAL_STOCKS, MARKET_START_HOUR, MINUTES_PER_TICK, INDICES as INITIAL_INDICES } from "./data";
export type { StockUpdate, Alert, Stock } from "./types";
export type { Index } from "./data";
export { STOCKS, ALERT_THRESHOLD, HOLDINGS, INDICES, MARKET_START_HOUR, MINUTES_PER_TICK, HISTORY_SIZE } from "./data";
/** The observable β the market emits updates, subscribers listen */
export const stockTicker = createObservable<StockUpdate>();
let tickIndex = 20;
export function getMarketTime(): string {
const totalMinutes = MARKET_START_HOUR * 60 + tickIndex * MINUTES_PER_TICK;
const h = Math.floor(totalMinutes / 60) % 24;
const m = totalMinutes % 60;
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
}
function randomChange(currentPrice: number): number {
return currentPrice * ((Math.random() - 0.5) * 6 / 100);
}
/** Internal market state β always running */
let marketStocks: Stock[] = INITIAL_STOCKS.map((s) => ({ ...s }));
let marketIndices: Index[] = INITIAL_INDICES.map((i) => ({ ...i }));
/** Tick the market: updates prices and notifies subscribers */
export function marketTick(): void {
tickIndex++;
marketStocks = marketStocks.map((stock) => {
const change = randomChange(stock.price);
const newPrice = Math.max(1, stock.price + change);
const changePercent = ((newPrice - stock.previousPrice) / stock.previousPrice) * 100;
// Notify subscribers
queueMicrotask(() => {
stockTicker.notify({
symbol: stock.symbol,
price: newPrice,
change: newPrice - stock.price,
changePercent,
timestamp: Date.now(),
});
});
return { ...stock, price: newPrice };
});
marketIndices = marketIndices.map((idx) => {
const pctChange = (Math.random() - 0.5) * 0.4;
return { ...idx, price: Math.max(1, idx.price + idx.price * (pctChange / 100)) };
});
}
/** Get current market indices (always live) */
export function getMarketIndices(): Index[] {
return marketIndices;
}
/** Reset everything */
export function resetMarket(): void {
tickIndex = 20;
marketStocks = INITIAL_STOCKS.map((s) => ({ ...s }));
marketIndices = INITIAL_INDICES.map((i) => ({ ...i }));
}
import { Play, Pause, RotateCcw } from "lucide-react";
import { type Index } from "@/lib/stockTicker";
import { useStockDashboard } from "@/hooks/useStockDashboard";
import { MiniChart } from "./MiniChart";
import { AlertPanel } from "./AlertPanel";
import { PortfolioSummary } from "./PortfolioSummary";
export function StockDashboard() {
const {
stocks, indices, isRunning, tickCount, marketTime,
handleToggle, handleReset,
} = useStockDashboard();
return (
<div className="h-screen flex flex-col bg-[#0a0e17] text-white overflow-hidden">
{/* Header */}
<div className="shrink-0 border-b border-white/[0.06]">
<div className="max-w-6xl mx-auto px-3 sm:px-4 h-12 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex items-center gap-1.5">
<div className="w-2 h-2 rounded-full bg-emerald-500" />
<span className="text-sm font-bold tracking-tight text-white">Ninja</span>
<span className="text-sm font-light text-white/50">Focus</span>
</div>
<div className="hidden sm:flex items-center gap-2 ml-4 text-[10px] text-white/30">
<span>Observer pattern</span>
<span>Β·</span>
<span>3 widgets, 1 observable</span>
</div>
</div>
<div className="flex items-center gap-2">
{/* Market status */}
<div className="hidden sm:flex items-center gap-2 mr-3 text-[10px]">
<div className={`w-1.5 h-1.5 rounded-full ${isRunning ? "bg-emerald-500 animate-pulse" : "bg-white/20"}`} />
<span className={isRunning ? "text-emerald-400" : "text-white/30"}>
{isRunning ? "LIVE" : "PAUSED"}
</span>
<span className="text-white/40 font-mono">{marketTime}</span>
{tickCount > 0 && (
<span className="text-white/20 font-mono">{tickCount} ticks</span>
)}
</div>
<button
onClick={handleToggle}
className={`flex items-center gap-1.5 px-3 sm:px-4 py-1.5 rounded-md text-xs font-medium transition-all ${
isRunning
? "bg-red-500/20 text-red-400 border border-red-500/30 hover:bg-red-500/30"
: "bg-emerald-500/20 text-emerald-400 border border-emerald-500/30 hover:bg-emerald-500/30"
}`}
>
{isRunning ? <Pause className="w-3 h-3" /> : <Play className="w-3 h-3" />}
<span className="hidden sm:inline">{isRunning ? "Pause" : "Start"}</span>
</button>
<button
onClick={handleReset}
className="p-1.5 rounded-md text-white/30 hover:text-white/60 hover:bg-white/[0.04] transition-colors"
title="Reset"
>
<RotateCcw className="w-3.5 h-3.5" />
</button>
</div>
</div>
</div>
{/* Ticker strip */}
<div className="shrink-0 border-b border-white/[0.04] bg-white/[0.02] overflow-hidden">
<div className="max-w-6xl mx-auto px-3 sm:px-4 py-1.5">
<div className="sm:flex items-center gap-4 sm:gap-6 hidden">
{indices.map((idx) => (
<IndexItem key={idx.symbol} index={idx} />
))}
</div>
{/* Mobile: scrolling marquee */}
<div className="sm:hidden relative">
<div className="flex items-center gap-6 animate-marquee whitespace-nowrap">
{[...indices, ...indices].map((idx, i) => (
<IndexItem key={`${idx.symbol}-${i}`} index={idx} />
))}
</div>
</div>
</div>
</div>
{/* Content */}
<div className="flex-1 min-h-0 overflow-auto">
<div className="max-w-6xl mx-auto px-3 sm:px-4 py-3 sm:py-4 h-full flex flex-col">
{/* Charts grid */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-2 sm:gap-3 mb-3 sm:mb-4 shrink-0">
{stocks.map((stock) => (
<MiniChart key={stock.symbol} stock={stock} />
))}
</div>
{/* Bottom row: Alerts + Portfolio, fill remaining space */}
<div className="grid sm:grid-cols-2 gap-2 sm:gap-3 flex-1 min-h-0">
{/* Alerts */}
<div className="bg-white/[0.03] rounded-xl border border-white/[0.06] overflow-hidden flex flex-col min-h-0">
<div className="h-10 px-4 flex items-center justify-between border-b border-white/[0.04] shrink-0">
<span className="text-[11px] font-medium text-white/50 uppercase tracking-wider">Alerts</span>
<span className="text-[10px] text-white/20">Β±2% threshold</span>
</div>
<div className="overflow-y-auto flex-1">
<AlertPanel subscribed={isRunning} />
</div>
</div>
{/* Portfolio */}
<div className="bg-white/[0.03] rounded-xl border border-white/[0.06] overflow-hidden flex flex-col min-h-0">
<div className="h-10 px-4 flex items-center border-b border-white/[0.04] shrink-0">
<span className="text-[11px] font-medium text-white/50 uppercase tracking-wider">Portfolio</span>
</div>
<div className="overflow-y-auto flex-1">
<PortfolioSummary stocks={stocks} />
</div>
</div>
</div>
</div>
</div>
{/* Footer */}
<div className="shrink-0 border-t border-white/[0.04] py-1.5 text-center text-[10px] text-white/15">
Simulated data Β· <code className="text-white/20">createObservable()</code> Β· 3 independent subscribers
</div>
</div>
);
}
function IndexItem({ index }: { index: Index }) {
const change = index.price - index.basePrice;
const pct = index.basePrice > 0 ? (change / index.basePrice) * 100 : 0;
const isUp = change >= 0;
return (
<div className="flex items-center gap-2 shrink-0">
<span className="text-[10px] text-white/30">{index.name}</span>
<span className="text-xs font-bold text-white/70">{index.symbol}</span>
<span className="text-xs font-mono text-white/50">{index.price.toFixed(0)}</span>
<span className={`text-[10px] font-mono ${isUp ? "text-emerald-400" : "text-red-400"}`}>
{isUp ? "+" : ""}{pct.toFixed(2)}%
</span>
</div>
);
}
Real-World Analogyβ
YouTube subscriptions. You subscribe to a channel, you get notified when they upload. The creator doesn't know how many subscribers will watch. You can unsubscribe anytime. The creator and viewers are completely decoupled.
When to Use Itβ
- Changes in one object should trigger updates in others
- You don't know in advance how many objects need to react
- You want loose coupling between event producers and consumers
When NOT to Use Itβ
If you have a single consumer that always reacts the same way, a direct function call is clearer. Observer adds indirection that's only worth it when subscribers are dynamic or unknown at compile time.
APIβ
- createObservable β Create a typed event emitter with
subscribe,notify,once,safeNotify,size, andclear