Skip to main content

Client adapters

Every framework adapter is a thin view over the same LiveStore contract. The client owns transport selection, deduplication, reconnection, revisions, patches, and optimistic overlays; an adapter only connects that store to the framework's lifecycle.

The LiveStore contract

interface LiveStore<T> {
subscribe(listener: () => void): () => void;
getSnapshot(): LiveState<T>;
}

getSnapshot() returns { data, pending, error, stale }. It is synchronous and stable when nothing changed. subscribe() starts the store's hold on the live resource and returns the release function. Several components asking for the same resource and canonical inputs share one store and one subscription.

This is exactly the shape required by React's useSyncExternalStore. React can read a snapshot during render, subscribe after commit, and ask for the same snapshot again without the adapter inventing a second state machine. The stable snapshot identity also prevents an unchanged current response from causing a render loop. The third argument, getServerSnapshot, is the same function, so a server-rendered React tree can read the hydrated value safely.

React

Use LiveProvider once near the application root. useLive accepts either a resource id or a typed live descriptor:

import { LiveClient } from '@carno.js/live/client';
import { LiveProvider, useLive } from '@carno.js/live/react';

const client = new LiveClient({ url: 'ws://localhost:3000/live' });

function TaskList() {
const { data, pending, error } = useLive<{ id: number; title: string }[]>(
'TasksController.list',
{ query: { tenant: 'acme' } }
);

if (pending) return <p>Loading...</p>;
if (error) return <p role="alert">{error}</p>;

return <ul>{data?.map(task => <li key={task.id}>{task.title}</li>)}</ul>;
}

export function App() {
return <LiveProvider client={client}><TaskList /></LiveProvider>;
}

useLiveAction wraps an application action and can project an optimistic change over one or more typed live descriptors. The confirmed server snapshot remains underneath the projection, so a patch that arrives while the action is in flight is not lost:

const createTask = useLiveAction(
(dto: { title: string }) => api.tasks.create(dto),
{
optimistic: [{
on: TasksController.list,
apply: (draft, dto) => draft.push({ id: 'optimistic', title: dto.title })
}]
}
);

The adapter does not put selected rows, open dialogs, or input values into the live store. Keep that interaction state in the component.

Angular

Provide one client at the application boundary and call liveSignal inside an injection context:

import { Component, signal } from '@angular/core';
import { LiveClient } from '@carno.js/live/client';
import { liveSignal, provideLive } from '@carno.js/live/angular';

const client = new LiveClient({ url: 'ws://localhost:3000/live' });

@Component({
selector: 'task-list',
providers: [provideLive(client)],
template: `
@if (tasks().pending) { <p>Loading...</p> }
@for (task of tasks().data ?? []; track task.id) {
<li>{{ task.title }}</li>
}
`
})
export class TaskListComponent {
readonly tenant = signal('acme');
readonly tasks = liveSignal('TasksController.list', () => ({
query: { tenant: this.tenant() }
}));
}

The inputs function is read reactively. When a signal it touches changes, the adapter points the slot at the new resource identity and releases the old subscription. Teardown is owned by Angular's DestroyRef; there is no manual unsubscribe and no requirement for zone.js.

Vue

Call provideLiveClient high in the component tree. useLiveQuery returns a ShallowRef:

<script setup lang="ts">
import { ref } from 'vue';
import { LiveClient } from '@carno.js/live/client';
import { provideLiveClient, useLiveQuery } from '@carno.js/live/vue';

const client = new LiveClient({ url: 'ws://localhost:3000/live' });
provideLiveClient(client);

const tenant = ref('acme');
const tasks = useLiveQuery<{ id: number; title: string }[]>(
'TasksController.list',
() => ({ query: { tenant: tenant.value } })
);
</script>

<template>
<p v-if="tasks.pending">Loading...</p>
<ul v-else>
<li v-for="task in tasks.data ?? []" :key="task.id">{{ task.title }}</li>
</ul>
</template>

The ref is shallow because the server replaces the snapshot as a whole. A deep proxy would track mutations that application code cannot make to server-owned data. Inputs are evaluated in watchEffect; scope disposal calls the adapter's cleanup through onScopeDispose.

Vanilla JavaScript

For code that has no component lifecycle, use liveStore. It returns a small handle with explicit ownership:

import { LiveClient, liveStore } from '@carno.js/live/client';

const client = new LiveClient({ url: 'ws://localhost:3000/live' });
const tasks = liveStore<{ id: number; title: string }>(
client,
'TasksController.list',
{ query: { tenant: 'acme' } }
);

const stop = tasks.subscribe(state => renderTasks(state));
renderTasks(tasks.get());

// When the widget is removed:
stop();
tasks.close();

liveStoreOf is available when an integration needs the raw LiveStore contract. LiveSlot is the shared lifecycle primitive used by the framework adapters when reactive inputs can point at a different resource instance.

None of these adapters touches the DOM. They expose server-owned state to the host framework, while rendering remains the responsibility of React, Angular, Vue, or the caller. Component-local state stays local.