Building financial applications demands instant data updates, robust state synchronization, and a developer experience that scales with complexity. In this post, we walk through how we combine Supabase for real-time database subscriptions with Valtio for lightweight, proxy-based state management.
Why This Stack?
| Concern | Supabase | Valtio | |---------|----------|--------| | Real-time data | Postgres + Realtime channels | — | | Client state | — | Proxy-based reactivity | | Auth | Built-in Row Level Security | — | | Bundle size | Server-side | ~3 kB |
Financial dashboards often display live portfolio values, transaction feeds, and alerts. Supabase's Realtime feature pushes row-level changes over WebSockets, while Valtio lets React components subscribe to only the slices of state they need—no context providers or reducers required.
Architecture Overview
┌──────────────┐ WebSocket ┌────────────────┐
│ Supabase │ ──────────────────▶ │ Valtio Store │
│ Realtime │ │ (proxy state) │
└──────────────┘ └───────┬────────┘
│
▼
┌───────────────┐
│ React UI │
│ (useSnapshot) │
└───────────────┘
- Supabase client subscribes to a Postgres channel (e.g.,
transactions). - On
INSERT,UPDATE, orDELETE, the callback mutates the Valtio store. - React components using
useSnapshotre-render automatically.
Step-by-Step Implementation
1. Define the Valtio Store
// lib/stores/transactionStore.ts
import { proxy } from 'valtio';
import type { Transaction } from '@/lib/types';
/**
* Proxy store for transactions.
* Mutations here trigger React re-renders via useSnapshot.
*/
export const transactionStore = proxy<{
transactions: Transaction[];
isLoading: boolean;
}>({
transactions: [],
isLoading: true,
});
2. Subscribe to Supabase Realtime
// lib/services/subscribeTransactions.ts
import { supabase } from '@/lib/supabase/client';
import { transactionStore } from '@/lib/stores/transactionStore';
export function subscribeTransactions(userId: string) {
// Initial fetch
supabase
.from('transactions')
.select('*')
.eq('user_id', userId)
.order('created_at', { ascending: false })
.then(({ data }) => {
transactionStore.transactions = data ?? [];
transactionStore.isLoading = false;
});
// Realtime subscription
const channel = supabase
.channel('transactions-realtime')
.on(
'postgres_changes',
{ event: '*', schema: 'public', table: 'transactions', filter: `user_id=eq.${userId}` },
(payload) => {
switch (payload.eventType) {
case 'INSERT':
transactionStore.transactions.unshift(payload.new as Transaction);
break;
case 'UPDATE':
const idx = transactionStore.transactions.findIndex((t) => t.id === payload.new.id);
if (idx !== -1) transactionStore.transactions[idx] = payload.new as Transaction;
break;
case 'DELETE':
transactionStore.transactions = transactionStore.transactions.filter(
(t) => t.id !== payload.old.id
);
break;
}
}
)
.subscribe();
// Return unsubscribe function for cleanup
return () => {
supabase.removeChannel(channel);
};
}
3. Consume in React
// components/TransactionFeed.tsx
'use client';
import { useSnapshot } from 'valtio';
import { transactionStore } from '@/lib/stores/transactionStore';
export function TransactionFeed() {
const { transactions, isLoading } = useSnapshot(transactionStore);
if (isLoading) return <p>Loading...</p>;
return (
<ul>
{transactions.map((tx) => (
<li key={tx.id}>
{tx.description} — ${tx.amount}
</li>
))}
</ul>
);
}
Key Takeaways
- Decoupled data layer: Supabase handles persistence and real-time; Valtio handles UI state.
- Minimal boilerplate: No Redux slices, no context wrappers.
- Scalable pattern: Add more tables/channels without restructuring your state tree.
When to Use This Pattern
- Dashboards with live metrics (finance, analytics, IoT).
- Collaborative apps where multiple users edit the same data.
- Any scenario where you need sub-second UI updates from a Postgres backend.
Further Reading
Have questions or want to see this pattern in action? Contact us to schedule a technical deep-dive.
Topics covered
Written by Prizmstack Team
Full-spectrum software agency