At work, every realtime feature starts the same way: open a WebSocket, push messages into state, render a list. Ten minutes, done. Then it meets production.
The socket drops on flaky train wifi and never comes back. Two components mount the same feed and open two sockets. A burst of messages janks the main thread. A fast re-render tears the list mid-update. The ten-minute feature is now a week of socket plumbing: reconnect backoff, dedup, backpressure, tear-free reads. None of which was the feature.
I'd written that plumbing enough times to want it to live in one place. So I built Liveflux.
You describe the fold; it owns the socket
The shift is to stop thinking in "messages" and start thinking in "a reducer over a stream." You say which channel to subscribe to and how each event folds into the state your component renders. Liveflux owns the connection.
import { useStream } from '@liveflux/react';
type Trade = { id: number; symbol: string; price: number };
export function Trades() {
// upsert -> Trade[]: a matching id updates in place, a new id is appended.
const trades = useStream<Trade>({
channel: 'trades',
into: { strategy: 'upsert', key: 'id', cap: 50 },
});
return trades.map((t) => <Row key={t.id} symbol={t.symbol} price={t.price} />);
}
That's the whole component. The fold strategies cover most UIs: append (a log), upsert (a keyed list), replace (the latest value), or your own reducer.
Because the library owns the connection, the hard parts are handled once, for every subscription:
- On an unexpected close it backs off with jitter and replays every active subscription, so streams resume on their own.
- Identical subscriptions share one socket, ref-counted; it closes when the last subscriber leaves.
- Oversized inbound frames are dropped before decoding, so a burst can't lock the UI.
- Reads go through
useSyncExternalStore, so state stays tear-free under concurrent rendering.
One engine, five transports
The engine is transport-agnostic. Point it at whatever your backend speaks by swapping the adapter, and the component doesn't change:
-
@liveflux/wsfor any plain WebSocket -
@liveflux/ssefor Server-Sent Events -
@liveflux/socketiofor Socket.IO -
@liveflux/graphql-wsfor GraphQL subscriptions over WebSocket -
@liveflux/phoenixfor Phoenix Channels
The core is framework-agnostic too; React is the first binding.
Honest status
It's pre-alpha and it's a solo project, so the API is still settling. But it's typed end-to-end and tree-shakeable, and every adapter is covered by a shared conformance suite plus real-server end-to-end tests (a real SSE server, a real Socket.IO server, a real graphql-ws server). The reconnect, dedup, and backpressure behaviour is actually exercised, not just claimed.
If you build realtime UIs, I'd really like to know where this holds up and where it breaks in a real app.
Have a look
- Site and live demo: https://liveflux.bpdm.dev
- Docs (getting started, concepts): https://liveflux.bpdm.dev/docs
- Source: https://github.com/liveflux/liveflux
- Scaffold one:
pnpm create liveflux@latest
Top comments (0)