Skip to content

Push and subscriptions

function

declare function useDocumentWatch(document: DocumentOrSnapshot): DocumentWatchSnapshot;

Subscribes the calling component to other people’s writes to the rows of a query document.

Takes the document, which for a view is the value beside it: useInjection() returns a snapshot and a snapshot knows the document it was taken of, so the query and its parameters are named in one place and this is not it:

const bars = useInjection(Q_Bar)
const live = useDocumentWatch(bars)
{ live.stale && <button onClick={ () => bars.update({}) }>Reload</button> }

The document is left alone – no values travel with a change notification, so there is nothing to apply. What this returns says that a row on screen has changed and which fields of it did; the view decides what that is worth.

For rows the user is editing this is the wrong half: a working set holds a draft for them and can say what a change means, so an editing view passes {watch: true} to useWorkingSet() instead. Doing both for the same rows asks to be told twice and refetches the document the drafts stand on.

Rules of hooks apply.

Parameters

document query document to watch, or the snapshot a view holds of one

Returns what has changed under the document since it was last read

function

declare function watchDocument(document: QueryDocument<any>, options?: WatchOptions): DocumentWatch;

Watches the rows of a query document for other people’s writes, and reports them.

Nothing is applied to the document and nothing can be: the message carries a mask and no values, so what is knowable is that a row on screen is no longer what the database holds. What an application does with that runs from nothing at all, through a notice the user acts on, to running the query again – and none of those is a decision a framework is in a position to make, so this reports and stops.

const live = useDocumentWatch(bars)
...
{ live.stale && <button onClick={ () => bars.update({}) }>Reload</button> }

Running the query again is update({}), which the document has already. What arrived is forgotten when the document changes, that being either the refetch or a page turn, and either way rows that are fresh.

Parameters

document the document to watch
options how to start it, see WatchOptions

Returns the store, which has to be closed when the view holding it goes

function

declare function watchWorkingSet(workingSet: WorkingSet): () => void;

Tells the given working set about the writes other people land on the rows it is holding, as they land.

The rows are being edited, so a change to one means something on its own: the field takes the status it would have taken at save time, and the user sees it while they are still typing rather than afterwards. No values arrive, which the working set already has a shape for – a field known to have changed and not known to what reads as the value the row was read with, and carries the same status a conflict does.

The version is deliberately left where it stands. Adopting the one that just landed would quietly remove the guard the next save runs into, and the marks would be the only warning the user ever got; leaving it keeps the merge as the authority and makes this the early notice it is meant to be.

A row this working set holds is its case and not a document’s, even where the document it was registered from is on screen: refetching that document would swap the row objects the drafts stand on. A view that edits watches its working set – useWorkingSet({watch: true}) – and a view that only displays watches its document.

One subscription per working set, however many callers ask for it: a working set is read by as many components as care to render it, and what arrives is applied to the set rather than handed to a caller, so a second registration would only deliver every message twice. The last caller to stop watching is the one that closes it.

Parameters

workingSet the working set to keep current

Returns a function that stops watching

interface

interface WatchOptions

How a watch is to be started.

open?: boolean;

Whether the watch is watching when it is handed over. Default true, which is what a caller outside React wants: it asked for a watch and gets one that is live.

Opening registers a subscription, which is a side effect and so belongs in an effect rather than in a render – React calls a useState() initializer twice under StrictMode and keeps one of the two results, so a watch that opened where it was created would leave a second, live one that nothing holds and nothing can ever close. useDocumentWatch() therefore constructs the watch closed and opens it from its effect.

interface

interface DocumentWatch

A document’s view of what other people are writing: a store like the document itself.

subscribe: (fn: () => void) => () => void;
getSnapshot: () => DocumentWatchSnapshot;
open: () => void;

starts watching, and does nothing to a watch that is watching already

close: () => void;

stops watching, reversibly: open() puts the subscription back

interface

interface DocumentWatchSnapshot

What has happened under a document since it was last read.

stale: boolean;

true once somebody else’s write touched a row this document is showing, in a field it selected.

Whether that is worth telling the user about, refetching for, or ignoring is the application’s, and this is the whole of what the framework knows.

remoteChanged: RemoteChangedRow[];

Which rows are out of date and in which of their fields: one entry per row, however many times it changed, in the order the rows were first heard about.

interface

interface RemoteChangedRow

One row somebody else changed under us, and the fields of it they touched.

type: string;
id: string;
fields: string[];

the fields the change touched that this client has a name for, in bit order. In a document’s record this is every field the row has changed in, the messages about it having been folded together.

constant

declare const ENTITY_VERSION = "EntityVersion";

The channel every merge publishes to, named as EntityVersionPublisher registers it.

interface

interface EntityVersionMessage

One recorded change to one row, as it arrives: which row changed, which fields it touched, and who did it.

The EntityVersion record the merge writes, and nothing more – no field values travel, which is what makes the message the same size whatever changed and is why what a subscriber can do with one is either mark it or go and read the row.

id: string;

the version the row stands at now

entityType: string;
entityId: string;
prev: string | null;

the version the change was made against, null for a row that had none

fieldMask: string | null;

the fields that changed, as a decimal string: 128 bits is past what a number holds exactly

fieldSet: string;

id of the field set the mask was written against

ownerId: string | null;

who made the change

created: string;

when, ISO-8601

function

declare function subscribeToTopic<T>(topic: string, handler: TopicHandler<T>, condition?: FilterExpression | null): () => void;

Subscribes to a channel, connecting if this is the first subscription.

The returned function unsubscribes. A subscription lives until it does: a dropped connection does not end one, because this module re-issues every registered subscription on reconnect – an application subscribes once and keeps receiving messages across a server restart without doing anything about it.

Parameters

topic name of the channel, as the server registered it
handler called with the payload of every message matching the condition
condition FilterDSL condition every message on the channel is evaluated against before it is delivered here, or null to receive everything the channel carries. Field paths are resolved against the channel’s payload, and a to-many hop in one reads positionally (bazes.0.name), unlike the same DSL against the database.

Returns a function that unsubscribes

constant

declare const PubSubConnection: {
subscribe(fn: () => void): () => void;
getSnapshot(): PubSubConnectionSnapshot;
};

The connection state as a store, in the subscribe/getSnapshot shape QueryDocument and WorkingSet already use – so a view renders an offline indicator with useSyncExternalStore and nothing else.

getSnapshot() returns the same object for as long as nothing changes, which is what useSyncExternalStore’s own change detection needs.

interface

interface PubSubConnectionSnapshot

Snapshot for a pub sub connection.

status: PubSubStatus;

“reconnecting” does not distinguish a server that is down from a session that has expired, and it cannot: a browser reports a refused websocket handshake to script without a status code, so a 401 and a dead port arrive here as the same event. A page whose session is gone therefore sits in “reconnecting” until something on the ordinary HTTP path – the next query – gets the 401 that can be read, and the application does what it does about that. Worth knowing before this status is put in front of a user as “connection lost”.

attempts: number;

Failed connection attempts since the last successful one, which is what the current backoff window is derived from. Zero while connected.

type

type PubSubStatus = "idle" | "connecting" | "connected" | "reconnecting";

What the connection is doing, as far as an application has any use for knowing.

“idle” covers both “nothing has subscribed yet” and “the last socket closed and nothing wants another one”, because to a view waiting for messages those are the same situation: none are coming.

type

type TopicHandler<T> = (payload: T) => void;

Called with the payload of every message published to the topic that the subscription’s condition matched.