Live Views
useLiveView resolves a ViewRef just like useView, but also keeps the selected object up to date through the native live SSE transport.
import { useLiveView, ViewRef } from 'react-fate';
export const PostCard = ({ post: postRef }: { post: ViewRef<'Post'> }) => {
const post = useLiveView(PostView, postRef);
return (
<Card>
<h2>{post.title}</h2>
{/* Updates automatically! */}
<p>{post.likes} likes</p>
</Card>
);
};The API mirrors useView: pass a view and a ref, and get back the same masked data shape. A null ref returns null and does not subscribe.
How Live Updates Work
The native HTTP transport opens one Server-Sent Events (SSE) connection per Fate client. When components mount or unmount live views, the client sends subscribe and unsubscribe control messages to the server. The server keeps those selections on the connection and sends updates only for records that connection subscribed to.
When the server sends an update, fate normalizes the selected record into the same cache used by requests, actions, and mutations. Components that read affected fields re-render automatically.
For example, if PostView selects likes, a live update that changes likes re-renders the PostCard. If another component only selected title, it does not re-render for a likes change.
Live deletion events remove the record from the normalized cache in the same way as mutations, and any lists or object fields that reference it are pruned.
Client Setup
Generate the client with the native transport and point it at your Fate endpoint:
import { FateClient } from 'react-fate';
import { createFateClient } from 'react-fate/client';
export function App() {
const fate = useMemo(
() =>
createFateClient({
fetch: (input, init) =>
fetch(input, {
...init,
credentials: 'include',
}),
url: `${env('SERVER_URL')}/fate`,
}),
[],
);
return <FateClient client={fate}>{/* Components go here */}</FateClient>;
}NOTE
Live views use GET /fate/live for the single SSE stream and POST /fate/live for subscribe/unsubscribe control messages.
Server Setup
Live views use an event bus. By default, the bus signals that an object changed and fate refetches the selected object through the same data view pipeline used by byId queries before sending it to the client. Update events can also include changed field paths so fate only resolves the intersection of those paths and each active subscription.
Pass a live event bus to createFateServer and expose the native handler:
import { createFateServer, createHonoFateHandler, createLiveEventBus } from '@nkzw/fate/server';
import type { AppContext } from './context.ts';
import { sources } from './sources.ts';
import { Root } from './views.ts';
export const live = createLiveEventBus();
export const fate = createFateServer<AppContext>({
live,
roots: Root,
sources,
});
app.all('/fate/*', createHonoFateHandler(fate));Once this is in place, components can switch from useView to useLiveView without changing their view definitions or return types.
Live List Views
useLiveListView mirrors useListView, but subscribes to live connection events for the connection it receives:
import { useLiveListView, useLiveView, ViewRef } from 'react-fate';
export function PostCard({ post: postRef }: { post: ViewRef<'Post'> }) {
const post = useLiveView(PostView, postRef);
const [comments, loadNext] = useLiveListView(CommentConnectionView, post.comments);
return (
<>
{comments.map(({ node }) => (
<CommentCard comment={node} key={node.id} />
))}
{loadNext ? <button onClick={loadNext}>Load more</button> : null}
</>
);
}The hook returns the same tuple as useListView: items, loadNext, and loadPrevious. Live events append, prepend, insert, or delete edges from one connection without deleting the underlying records.
By default, live appends and prepends respect pagination boundaries. If the relevant edge still has more pages, fate keeps the incoming node attached to that edge instead of expanding the loaded window. For chat or activity streams where new items should keep appearing immediately, opt into visible live insertion on the connection view:
const MessageConnectionView = {
args: { first: 30 },
items: {
node: MessageView,
},
live: {
append: 'visible',
},
};Emit connection events on the server when list membership changes:
live.connection('Post.comments', { id: postId }).prependNode('Comment', comment.id);
live.connection('Post.comments', { id: postId }).deleteEdge('Comment', comment.id);For root lists, use the generated root procedure name:
live.connection('posts', { categoryId }).prependNode('Post', post.id);If the changed list cannot be described precisely, invalidate the active connection and fate will refetch it:
live.connection('posts', { categoryId }).invalidate();Connection identity follows Relay's model: pagination args like first, last, after, and before are ignored for live connection matching, while filter args such as categoryId are part of the identity.
Emitting Events
After a mutation changes an object, emit an update event for that object:
export const postRouter = router({
...fate.procedures({
view: postDataView,
}),
like: procedure.input(likeInput).mutation(async ({ ctx, input }) => {
const post = await ctx.prisma.post.update({
data: {
likes: {
increment: 1,
},
},
where: { id: input.id },
});
live.update('Post', input.id);
return post;
}),
});This tells fate that the Post changed. Every active live view for that post refreshes using the selection it subscribed with.
If you know which fields changed, pass them with changed to reduce the amount of data sent to each subscriber:
live.update('Post', input.id, { changed: ['likes'] });With this version, a live view that selected likes refreshes only likes, while a live view that only selected unrelated fields is skipped entirely.
If a mutation changes a related object, emit for the object whose live view should refresh. For example, adding a comment usually changes the post's commentCount and comments list, so emit for the Post:
export const commentRouter = router({
add: procedure.input(addCommentInput).mutation(async ({ ctx, input }) => {
const comment = await ctx.prisma.comment.create({
data: {
content: input.content,
postId: input.postId,
},
});
live.update('Post', input.postId, { changed: ['commentCount', 'comments'] });
return comment;
}),
});For deletions, emit a delete event for the deleted object if clients may be subscribed to it:
live.delete('Comment', input.id);If deleting the object also changes another object, emit an update for that object too:
live.update('Post', postId, { changed: ['commentCount', 'comments'] });You can pass an eventId when emitting. fate sends it on the native SSE event and includes the last received event ID when it resubscribes after a reconnect:
live.update('Post', input.id, {
changed: ['likes'],
eventId: `post:${input.id}:${Date.now()}`,
});The default createLiveEventBus is an in-memory fanout bus and does not replay events that were emitted while a client was disconnected. Use a durable custom live bus if your deployment needs reconnects to catch up from lastEventId; otherwise the client receives future live events after it reconnects.
Error Handling
Live subscription errors are reported out of band. They do not replace the last cached data or throw through the component that called useLiveView.
Pass onLiveError when creating the client to send those failures to your logger or monitoring system:
const fate = createFateClient({
fetch: (input, init) =>
fetch(input, {
...init,
credentials: 'include',
}),
onLiveError(error) {
captureException(error);
},
url: `${env('SERVER_URL')}/fate`,
});The handler runs in a microtask after the subscription reports the error. Components continue to read whatever data is currently available in the fate cache.