Live Demo: Check out the working implementation at mtt.ggSource Code: The complete codebase is available at github.com/MobulaFi/MTT
What You’ll Build
By the end of this guide, you’ll have a Pulse feature with:- Three real-time token sections: New Pairs, Final Stretch (bonding), and Migrated
- WebSocket streaming for instant updates
- Advanced filtering by chain, protocol, metrics, and audits
- Performant rendering with batched updates and memoization
- Persistent filter state using Zustand with localStorage
Architecture Overview
The MTT Pulse feature uses a modern React architecture:┌─────────────────────────────────────────────────────────────┐
│ PulseStreamProvider │
│ (React Context - provides stream state to all children) │
├─────────────────────────────────────────────────────────────┤
│ usePulseV2 Hook │
│ (WebSocket connection, REST fallback, message batching) │
├───────────────────────┬─────────────────────────────────────┤
│ usePulseDataStore │ usePulseFilterStore │
│ (Token data per │ (Filter state with │
│ view: new/bonding/ │ localStorage persist) │
│ bonded) │ │
├───────────────────────┴─────────────────────────────────────┤
│ UI Components │
│ TokenSection → TokenCard → Filter Modal │
└─────────────────────────────────────────────────────────────┘
Step 1: Project Setup
First, create a new Next.js project with the required dependencies:npx create-next-app@latest my-pulse-terminal --typescript --tailwind --app
cd my-pulse-terminal
npm install @mobula/sdk zustand immer lucide-react @radix-ui/react-tooltip @radix-ui/react-hover-card
The
@mobula/sdk package provides the WebSocket client and REST API methods needed to connect to Mobula’s Pulse V2 stream.Step 2: Configure the Mobula Client
Create a client configuration file that handles REST and WebSocket connections:// src/lib/mobulaClient.ts
import { MobulaClient } from '@mobula/sdk';
const REST_ENDPOINTS = {
PREMIUM: 'https://pulse-v2-api.mobula.io',
STANDARD: 'https://api.mobula.io',
} as const;
const WSS_REGIONS = {
default: 'wss://default.mobula.io',
'pulse-v2': 'wss://pulse-v2-api.mobula.io',
} as const;
let client: MobulaClient | null = null;
export function getMobulaClient(): MobulaClient {
if (!client) {
client = new MobulaClient({
restUrl: REST_ENDPOINTS.PREMIUM,
apiKey: process.env.NEXT_PUBLIC_MOBULA_API_KEY,
debug: true,
timeout: 200000,
});
}
return client;
}
.env.local:
NEXT_PUBLIC_MOBULA_API_KEY=your_api_key_here
Get your free API key at admin.mobula.io
Step 3: Create the Data Store
The data store manages tokens for each view (new, bonding, bonded) with optimized sorting and token limits:// src/store/usePulseDataStore.ts
'use client';
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';
export type ViewName = 'new' | 'bonding' | 'bonded';
export interface PulseToken {
address?: string;
chainId?: string;
name?: string;
symbol?: string;
logo?: string;
marketCap?: number;
bondingPercentage?: number;
holders_count?: number;
price_change_24h?: number;
created_at?: string;
bonded_at?: string;
[key: string]: unknown;
}
export interface SectionDataState {
tokens: PulseToken[];
loading: boolean;
error: string | null;
lastUpdate: number;
searchQuery: string;
}
const TOKEN_LIMIT = 50;
function getTokenKey(token: PulseToken): string {
return `${token.address || ''}_${token.chainId || ''}`;
}
function getTokenTimestamp(token: PulseToken, view: ViewName): number {
const timestampStr = view === 'bonded'
? token.bonded_at || token.created_at
: token.created_at;
if (!timestampStr) return 0;
const timestamp = new Date(timestampStr).getTime();
return Number.isNaN(timestamp) ? 0 : timestamp;
}
export interface PulseDataStoreState {
sections: Record<ViewName, SectionDataState>;
setTokens(view: ViewName, tokens: PulseToken[]): void;
setLoading(view: ViewName, loading: boolean): void;
setError(view: ViewName, error: string | null): void;
mergeToken(view: ViewName, token: PulseToken): void;
clearView(view: ViewName): void;
setSearchQuery(view: ViewName, query: string): void;
}
export const usePulseDataStore = create<PulseDataStoreState>()(
devtools(
immer((set) => ({
sections: {
new: { tokens: [], loading: false, error: null, lastUpdate: 0, searchQuery: '' },
bonding: { tokens: [], loading: false, error: null, lastUpdate: 0, searchQuery: '' },
bonded: { tokens: [], loading: false, error: null, lastUpdate: 0, searchQuery: '' },
},
setTokens(view, tokens) {
set((state) => {
// Sort by timestamp (newest first)
const sortedTokens = [...tokens].sort((a, b) => {
return getTokenTimestamp(b, view) - getTokenTimestamp(a, view);
});
// Enforce token limit
const limitedTokens = sortedTokens.slice(0, TOKEN_LIMIT);
state.sections[view].tokens = limitedTokens;
state.sections[view].lastUpdate = Date.now();
state.sections[view].error = null;
});
},
setLoading(view, loading) {
set((state) => {
state.sections[view].loading = loading;
});
},
setError(view, error) {
set((state) => {
state.sections[view].error = error;
state.sections[view].loading = false;
});
},
mergeToken(view, token) {
set((state) => {
const currentTokens = state.sections[view].tokens;
const tokenKey = getTokenKey(token);
const existingIndex = currentTokens.findIndex((t) => getTokenKey(t) === tokenKey);
if (existingIndex !== -1) {
// Update existing token
Object.assign(currentTokens[existingIndex], token);
} else {
// Add new token at the beginning (newest first)
currentTokens.unshift(token);
// Enforce limit
if (currentTokens.length > TOKEN_LIMIT) {
currentTokens.splice(TOKEN_LIMIT);
}
}
state.sections[view].lastUpdate = Date.now();
});
},
clearView(view) {
set((state) => {
state.sections[view].tokens = [];
state.sections[view].loading = false;
state.sections[view].error = null;
state.sections[view].searchQuery = '';
});
},
setSearchQuery(view, query) {
set((state) => {
state.sections[view].searchQuery = query;
});
},
})),
{ name: 'PulseDataStore' }
)
);
Step 4: Create the Pulse V2 Hook
This is the core hook that manages WebSocket subscriptions and handles real-time updates:// src/hooks/usePulseV2.ts
'use client';
import { useEffect, useRef, useMemo, useState, useCallback } from 'react';
import { getMobulaClient } from '@/lib/mobulaClient';
import type { MobulaClient } from '@mobula/sdk';
import { usePulseDataStore, type ViewName, type PulseToken } from '@/store/usePulseDataStore';
interface PulseViewData {
data?: PulseToken | PulseToken[];
}
type WssPulseV2ResponseType =
| {
type: 'init';
payload: {
new?: PulseViewData;
bonding?: PulseViewData;
bonded?: PulseViewData;
};
}
| {
type: 'update-token';
payload: {
viewName: string;
token: PulseToken;
};
};
export interface UsePulseV2Options {
enabled?: boolean;
chainIds?: string[];
filters?: Record<string, unknown>;
}
export function usePulseV2({
enabled = true,
chainIds = ['solana:solana'],
filters = {},
}: UsePulseV2Options = {}) {
const clientRef = useRef<MobulaClient | null>(null);
const subscriptionIdRef = useRef<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [isConnected, setIsConnected] = useState(false);
const [isPaused, setIsPaused] = useState(false);
const pulseDataStore = usePulseDataStore();
// Build subscription payload
const payload = useMemo(() => {
const buildView = (name: string, model: 'new' | 'bonding' | 'bonded') => ({
name,
chainId: chainIds,
limit: 50,
model,
...(Object.keys(filters).length > 0 && { filters }),
});
return {
assetMode: true,
compressed: false,
views: [
buildView('new', 'new'),
buildView('bonding', 'bonding'),
buildView('bonded', 'bonded'),
],
};
}, [chainIds, filters]);
// Initialize client
const getClient = useCallback(() => {
if (!clientRef.current) {
clientRef.current = getMobulaClient();
}
return clientRef.current;
}, []);
// Handle incoming WebSocket messages
const handleMessage = useCallback((msg: WssPulseV2ResponseType) => {
if (isPaused || !msg) return;
if (msg.type === 'init') {
const { new: newData, bonding: bondingData, bonded: bondedData } = msg.payload;
if (newData?.data) {
const tokens = Array.isArray(newData.data) ? newData.data : [newData.data];
pulseDataStore.setTokens('new', tokens);
}
if (bondingData?.data) {
const tokens = Array.isArray(bondingData.data) ? bondingData.data : [bondingData.data];
pulseDataStore.setTokens('bonding', tokens);
}
if (bondedData?.data) {
const tokens = Array.isArray(bondedData.data) ? bondedData.data : [bondedData.data];
pulseDataStore.setTokens('bonded', tokens);
}
setLoading(false);
}
if (msg.type === 'update-token') {
const viewName = msg.payload.viewName as ViewName;
const token = msg.payload.token;
if (viewName && token) {
pulseDataStore.mergeToken(viewName, token);
}
}
}, [isPaused, pulseDataStore]);
// Load initial data via REST API (faster first paint)
const loadInitialData = useCallback(async () => {
try {
const client = getClient();
const restResponse = await client.fetchPulseV2({
model: 'default',
assetMode: true,
compressed: false,
chainId: chainIds,
});
if (restResponse.new?.data) {
pulseDataStore.setTokens('new',
Array.isArray(restResponse.new.data) ? restResponse.new.data : [restResponse.new.data]
);
}
if (restResponse.bonding?.data) {
pulseDataStore.setTokens('bonding',
Array.isArray(restResponse.bonding.data) ? restResponse.bonding.data : [restResponse.bonding.data]
);
}
if (restResponse.bonded?.data) {
pulseDataStore.setTokens('bonded',
Array.isArray(restResponse.bonded.data) ? restResponse.bonded.data : [restResponse.bonded.data]
);
}
setLoading(false);
} catch (e) {
console.error('[usePulseV2] REST load failed:', e);
}
}, [getClient, chainIds, pulseDataStore]);
// Subscribe to WebSocket stream
const subscribe = useCallback(() => {
try {
const client = getClient();
subscriptionIdRef.current = client.streams.subscribe(
'pulse-v2',
payload,
(data: unknown) => {
handleMessage(data as WssPulseV2ResponseType);
}
);
setIsConnected(true);
setError(null);
} catch (e) {
console.error('[usePulseV2] Subscribe error:', e);
setError(e instanceof Error ? e.message : 'Subscribe failed');
}
}, [getClient, payload, handleMessage]);
// Unsubscribe from WebSocket
const unsubscribe = useCallback(() => {
if (subscriptionIdRef.current && clientRef.current?.streams) {
try {
clientRef.current.streams.unsubscribe('pulse-v2', subscriptionIdRef.current);
} catch (e) {
console.warn('[usePulseV2] Unsubscribe error:', e);
}
}
subscriptionIdRef.current = null;
setIsConnected(false);
}, []);
// Pause/Resume controls
const pauseSubscription = useCallback(() => setIsPaused(true), []);
const resumeSubscription = useCallback(() => setIsPaused(false), []);
// Initial load effect
useEffect(() => {
if (!enabled) return;
loadInitialData();
const timer = setTimeout(() => {
subscribe();
}, 500);
return () => {
clearTimeout(timer);
unsubscribe();
};
}, [enabled, loadInitialData, subscribe, unsubscribe]);
return {
loading,
error,
isConnected,
isPaused,
pauseSubscription,
resumeSubscription,
};
}
Step 5: Create the Stream Context
Wrap your components with a context provider to share stream state:// src/context/PulseStreamContext.tsx
'use client';
import { createContext, useContext, type ReactNode } from 'react';
import { usePulseV2 } from '@/hooks/usePulseV2';
type PulseStreamContextValue = ReturnType<typeof usePulseV2>;
const PulseStreamContext = createContext<PulseStreamContextValue | null>(null);
export function PulseStreamProvider({ children }: { children: ReactNode }) {
const pulseStream = usePulseV2({ enabled: true });
return (
<PulseStreamContext.Provider value={pulseStream}>
{children}
</PulseStreamContext.Provider>
);
}
export function usePulseStreamContext() {
const context = useContext(PulseStreamContext);
if (!context) {
throw new Error('usePulseStreamContext must be used inside PulseStreamProvider');
}
return context;
}
Step 6: Build the Token Card Component
Create a reusable card component to display token information:// src/components/TokenCard.tsx
'use client';
import { memo, useMemo } from 'react';
import Image from 'next/image';
import type { PulseToken } from '@/store/usePulseDataStore';
interface TokenCardProps {
token: PulseToken;
viewName: 'new' | 'bonding' | 'bonded';
}
function formatPrice(value: number): string {
if (value >= 1_000_000) return `$${(value / 1_000_000).toFixed(2)}M`;
if (value >= 1_000) return `$${(value / 1_000).toFixed(2)}K`;
return `$${value.toFixed(2)}`;
}
function formatPercentage(value: number): string {
const sign = value >= 0 ? '+' : '';
return `${sign}${value.toFixed(2)}%`;
}
function formatTimeAgo(timestamp: string): string {
const seconds = Math.floor((Date.now() - new Date(timestamp).getTime()) / 1000);
if (seconds < 60) return `${seconds}s ago`;
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
return `${Math.floor(seconds / 86400)}d ago`;
}
function TokenCard({ token, viewName }: TokenCardProps) {
const priceChange = token.price_change_24h ?? 0;
const timestamp = viewName === 'bonded' && token.bonded_at
? token.bonded_at
: token.created_at;
const bondingLabel = useMemo(() => {
if (viewName === 'bonded') return 'Migrated';
return `${(token.bondingPercentage ?? 0).toFixed(1)}%`;
}, [viewName, token.bondingPercentage]);
return (
<div className="bg-[#121319] hover:bg-[#1D2028] transition-all duration-200 px-3 py-2 rounded-md cursor-pointer">
<div className="flex justify-between items-start gap-4">
{/* Token Info */}
<div className="flex space-x-3 flex-1 min-w-0">
{/* Logo */}
<div className="flex-shrink-0 w-12 h-12 relative">
{token.logo ? (
<Image
src={token.logo}
alt={token.symbol || 'Token'}
width={48}
height={48}
className="rounded-full object-cover"
/>
) : (
<div className="w-full h-full bg-gray-700 rounded-full flex items-center justify-center">
<span className="text-xs font-bold text-gray-300">
{(token.symbol || '?')[0].toUpperCase()}
</span>
</div>
)}
</div>
{/* Details */}
<div className="flex flex-col space-y-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-semibold text-white truncate">
{token.name || 'Unknown'}
</span>
<span className="text-xs text-gray-400 uppercase">
{token.symbol}
</span>
</div>
<div className="flex items-center gap-2 text-xs text-gray-500">
<span>{timestamp ? formatTimeAgo(timestamp) : '-'}</span>
<span className="text-purple-400 font-mono">
{token.address?.slice(0, 6)}...{token.address?.slice(-4)}
</span>
</div>
{/* Stats */}
<div className="flex items-center gap-3 text-xs">
<span className="text-gray-400">
👥 {token.holders_count ?? 0}
</span>
<span className={`px-1.5 py-0.5 rounded ${
viewName === 'bonded' ? 'bg-green-500/20 text-green-400' : 'bg-yellow-500/20 text-yellow-400'
}`}>
{bondingLabel}
</span>
</div>
</div>
</div>
{/* Market Data */}
<div className="flex flex-col items-end flex-shrink-0 min-w-[80px]">
<div className="flex items-center gap-1 text-xs">
<span className="text-gray-400">MCap</span>
<span className="text-white font-semibold">
{formatPrice(token.marketCap ?? 0)}
</span>
</div>
<div className={`text-xs font-medium ${
priceChange >= 0 ? 'text-green-400' : 'text-red-400'
}`}>
{formatPercentage(priceChange)}
</div>
</div>
</div>
</div>
);
}
export default memo(TokenCard);
Step 7: Build the Token Section Component
Create a section component that displays a list of tokens with real-time status:// src/components/TokenSection.tsx
'use client';
import { useMemo } from 'react';
import Link from 'next/link';
import TokenCard from './TokenCard';
import { usePulseDataStore, type ViewName } from '@/store/usePulseDataStore';
import { usePulseStreamContext } from '@/context/PulseStreamContext';
interface TokenSectionProps {
title: string;
viewName: ViewName;
}
export default function TokenSection({ title, viewName }: TokenSectionProps) {
const tokens = usePulseDataStore((state) => state.sections[viewName].tokens);
const searchQuery = usePulseDataStore((state) => state.sections[viewName].searchQuery);
const { loading, isConnected, isPaused } = usePulseStreamContext();
// Filter tokens by search query
const filteredTokens = useMemo(() => {
if (!searchQuery.trim()) return tokens;
const query = searchQuery.toLowerCase();
return tokens.filter((token) => {
const name = (token.name || '').toLowerCase();
const symbol = (token.symbol || '').toLowerCase();
const address = (token.address || '').toLowerCase();
return name.includes(query) || symbol.includes(query) || address.includes(query);
});
}, [tokens, searchQuery]);
// Status badge
const statusBadge = useMemo(() => {
if (loading) return { text: 'LOADING', color: 'bg-yellow-500/20 text-yellow-400' };
if (isPaused) return { text: 'PAUSED', color: 'bg-yellow-500/20 text-yellow-400' };
if (isConnected) return { text: 'LIVE', color: 'bg-green-500/20 text-green-400' };
return { text: 'OFFLINE', color: 'bg-gray-500/20 text-gray-400' };
}, [loading, isConnected, isPaused]);
return (
<div className="bg-[#121319] max-h-[calc(100vh-200px)] overflow-hidden overflow-y-auto">
{/* Header */}
<div className="sticky top-0 z-10 bg-[#0F1116] flex justify-between items-center px-3 py-2 border-b border-gray-700">
<div className="flex items-center gap-2">
<h2 className="text-sm font-medium text-white">{title}</h2>
<span className="text-xs text-gray-500">({filteredTokens.length})</span>
{/* Live Status Indicator */}
<div className="flex items-center gap-1.5">
<div className={`w-2 h-2 rounded-full ${
isConnected && !isPaused ? 'bg-green-500 animate-pulse' : 'bg-gray-500'
}`} />
<span className={`text-xs font-medium px-1.5 py-0.5 rounded ${statusBadge.color}`}>
{statusBadge.text}
</span>
</div>
</div>
</div>
{/* Token List */}
<div className="space-y-0">
{loading ? (
// Loading skeleton
<div className="space-y-2 p-3">
{[...Array(5)].map((_, i) => (
<div key={i} className="animate-pulse bg-gray-800 h-16 rounded-md" />
))}
</div>
) : filteredTokens.length === 0 ? (
<div className="p-4 text-gray-500 text-xs text-center">
{searchQuery ? `No tokens match "${searchQuery}"` : 'No tokens found'}
</div>
) : (
filteredTokens.map((token) => (
<Link
key={`${token.address}-${token.chainId}`}
href={`/token/${token.chainId}/${token.address}`}
className="block border-b border-gray-800"
>
<TokenCard token={token} viewName={viewName} />
</Link>
))
)}
</div>
</div>
);
}
Step 8: Assemble the Main Page
Put everything together on the main page:// src/app/page.tsx
'use client';
import TokenSection from '@/components/TokenSection';
import { PulseStreamProvider, usePulseStreamContext } from '@/context/PulseStreamContext';
function PulsePageContent() {
const { error } = usePulseStreamContext();
if (error) {
return (
<div className="p-4 text-red-500">
Error loading pulse data: {error}
</div>
);
}
return (
<div className="min-h-screen bg-[#121319]">
{/* Header */}
<div className="px-4 py-3">
<h1 className="text-xl font-bold text-white">Pulse</h1>
<p className="text-sm text-gray-400">Real-time token discovery</p>
</div>
{/* Three Column Layout */}
<div className="px-4 grid md:grid-cols-3 gap-0 border-t border-gray-700">
{/* New Pairs */}
<div className="border-r border-gray-700">
<TokenSection title="New Pairs" viewName="new" />
</div>
{/* Final Stretch (Bonding) */}
<div className="border-r border-gray-700">
<TokenSection title="Final Stretch" viewName="bonding" />
</div>
{/* Migrated */}
<div>
<TokenSection title="Migrated" viewName="bonded" />
</div>
</div>
</div>
);
}
export default function HomePage() {
return (
<PulseStreamProvider>
<PulsePageContent />
</PulseStreamProvider>
);
}
Step 9: Add Advanced Filtering
The MTT codebase includes comprehensive filtering capabilities. Here’s how to add filter support:// src/store/usePulseFilterStore.ts
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
export interface FilterState {
chainIds: string[];
protocols: string[];
metrics: {
volume: { min: string; max: string };
marketCap: { min: string; max: string };
liquidity: { min: string; max: string };
};
audits: {
holders: { min: string; max: string };
top10HoldersPercent: { min: string; max: string };
devHoldingPercent: { min: string; max: string };
};
}
const defaultFilters: FilterState = {
chainIds: ['solana:solana'],
protocols: [],
metrics: {
volume: { min: '', max: '' },
marketCap: { min: '', max: '' },
liquidity: { min: '', max: '' },
},
audits: {
holders: { min: '', max: '' },
top10HoldersPercent: { min: '', max: '' },
devHoldingPercent: { min: '', max: '' },
},
};
interface PulseFilterStore {
filters: FilterState;
setFilter<K extends keyof FilterState>(key: K, value: FilterState[K]): void;
resetFilters(): void;
buildApiFilters(): Record<string, unknown>;
}
export const usePulseFilterStore = create<PulseFilterStore>()(
persist(
(set, get) => ({
filters: defaultFilters,
setFilter(key, value) {
set((state) => ({
filters: { ...state.filters, [key]: value },
}));
},
resetFilters() {
set({ filters: defaultFilters });
},
buildApiFilters() {
const { filters } = get();
const apiFilters: Record<string, unknown> = {};
// Volume filter
if (filters.metrics.volume.min || filters.metrics.volume.max) {
apiFilters.volume_24h = {};
if (filters.metrics.volume.min) {
(apiFilters.volume_24h as Record<string, number>).gte = Number(filters.metrics.volume.min);
}
if (filters.metrics.volume.max) {
(apiFilters.volume_24h as Record<string, number>).lte = Number(filters.metrics.volume.max);
}
}
// Market cap filter
if (filters.metrics.marketCap.min || filters.metrics.marketCap.max) {
apiFilters.market_cap = {};
if (filters.metrics.marketCap.min) {
(apiFilters.market_cap as Record<string, number>).gte = Number(filters.metrics.marketCap.min);
}
if (filters.metrics.marketCap.max) {
(apiFilters.market_cap as Record<string, number>).lte = Number(filters.metrics.marketCap.max);
}
}
// Holders filter
if (filters.audits.holders.min || filters.audits.holders.max) {
apiFilters.holders_count = {};
if (filters.audits.holders.min) {
(apiFilters.holders_count as Record<string, number>).gte = Number(filters.audits.holders.min);
}
if (filters.audits.holders.max) {
(apiFilters.holders_count as Record<string, number>).lte = Number(filters.audits.holders.max);
}
}
return apiFilters;
},
}),
{
name: 'pulse-filters',
storage: createJSONStorage(() => localStorage),
}
)
);
Available Filter Parameters
The Pulse V2 API supports extensive filtering options. Here are the key filter parameters:| Filter | Type | Description |
|---|---|---|
volume_24h | { gte?, lte? } | 24-hour trading volume |
market_cap | { gte?, lte? } | Market capitalization |
liquidity | { gte?, lte? } | Available liquidity |
holders_count | { gte?, lte? } | Number of token holders |
top10_holders_percent | { gte?, lte? } | Top 10 holders percentage |
dev_holdings_percentage | { gte?, lte? } | Developer holdings percentage |
snipers_holdings_percentage | { gte?, lte? } | Sniper wallets holdings |
insiders_holdings_percentage | { gte?, lte? } | Insider holdings percentage |
bonding_percentage | { gte?, lte? } | Bonding curve progress |
pro_traders_count | { gte?, lte? } | Number of pro traders |
dexscreener_ad_paid | { equals: true } | DEX Screener ad status |
For the complete list of filter parameters, see the Pulse Stream V2 documentation.
Performance Optimizations
The MTT codebase uses several techniques for optimal performance:1. Update Batching
// Batch multiple token updates using requestAnimationFrame
class UpdateBatcher<T> {
private updates: T[] = [];
private scheduled = false;
constructor(private callback: (updates: T[]) => void) {}
add(update: T) {
this.updates.push(update);
if (!this.scheduled) {
this.scheduled = true;
requestAnimationFrame(() => {
this.callback(this.updates);
this.updates = [];
this.scheduled = false;
});
}
}
}
2. Memoized Components
// Use React.memo with custom comparison
export default memo(TokenCard, (prev, next) => {
return (
prev.token.price_change_24h === next.token.price_change_24h &&
prev.token.marketCap === next.token.marketCap &&
prev.token.holders_count === next.token.holders_count
);
});
3. Virtualized Lists
For large token lists, consider usingreact-window:
import { FixedSizeList } from 'react-window';
<FixedSizeList
height={600}
width="100%"
itemCount={tokens.length}
itemSize={80}
>
{({ index, style }) => (
<div style={style}>
<TokenCard token={tokens[index]} viewName={viewName} />
</div>
)}
</FixedSizeList>
Conclusion
You now have a fully functional Axiom-style Pulse feature built with:- Real-time WebSocket streaming for instant token updates
- REST API fallback for fast initial page loads
- Zustand state management with localStorage persistence
- Performant rendering with batched updates and memoization
- Advanced filtering by metrics, audits, and holder data
Next Steps
- Explore the complete MTT source code
- Add TradingView charts for token detail pages
- Implement wallet tracking for live trades
- Build a custom filter modal like the one in MTT
Get Started
Create a free Mobula API key and start building your own crypto terminal today!Live Demo
See the Pulse feature in action
Source Code
Explore the complete MTT codebase
Pulse V2 API Docs
Learn about all available filters and data
Get API Key
Sign up for free API access