Watch for Changes


The Events API is in beta. Event shapes, limits, and behavior may change between releases.

The Events API tells your app when the user's key-value data or files change, so it can show the new data. It works with the data from Store Data and Store Files.

The handler runs for every change, including the ones your own code makes. So you can write data anywhere in your code and update the screen in one place, the handler. It also runs for changes made somewhere else:

To subscribe, use the puter.events.onLocal() method. The subscription lasts until you end it or the user closes the tab.

Watch a Key

To re-render when a key changes, subscribe to kv:<key>. In the handler, read the key again with puter.kv.get() and show it:

async function showSettings () {
    const settings = await puter.kv.get('settings') ?? { theme: 'light' };
    applySettings(settings);
}

const sub = await puter.events.onLocal('kv:settings', showSettings);
await showSettings();

Subscribe first, then do the first read. The onLocal() promise resolves once the subscription is in place. A change made during the first read then still reaches the handler.

A key that expires on its own sends no event. Neither does puter.kv.flush().

On a website, the user has to be signed in. If nobody is, onLocal() opens the Puter sign-in window. It rejects with auth_canceled if the user closes the window.

Some subjects below use puter.appID. The app ID is only known after sign-in, so sign the user in first. The puter.auth.signIn() method opens a popup, so call it from a click:

if (puter.auth.isSignedIn()) {
    await startWatching();
} else {
    signInButton.addEventListener('click', async () => {
        await puter.auth.signIn();
        await startWatching();
    });
}

Here startWatching() runs the subscriptions from the sections below. An app running on Puter is always signed in, so it can call it directly.

Get the New Value With the Event

To skip the read in the handler, pass includeValue: true. Each event then has the key's new value in event.value. The value is null when the key was deleted:

const sub = await puter.events.onLocal('kv:settings', async ({ event }) => {
    const settings = 'value' in event ? event.value : await puter.kv.get('settings');
    applySettings(settings ?? { theme: 'light' });
}, { includeValue: true });

The value is sometimes left out, so keep the fallback to puter.kv.get(). For example, it's left out on a gap. Every case is listed under Getting the new value.

The includeValue option works on kv: subjects only.

Watch Several Keys

A kv: subject matches one exact key. To watch every key that starts with the same text, end the subject with *:

await puter.events.onLocal('kv:draft*', handler);                   // draft, drafts, draft-2, ...
await puter.events.onLocal(`kv:${puter.appID}:cart:*`, handler);   // cart:apple, cart:pear, ...

If the keys contain :, start the subject with your app ID, as in the second line. Without it, kv:cart:* is read as the keys of an app called cart, and the subscription is refused.

Use one subscription for the whole group, since a tab can hold only a limited number of subscriptions. See Rate Limits and Quotas for the numbers. The event's key field tells you which key changed:

const sub = await puter.events.onLocal(`kv:${puter.appID}:cart:*`, async ({ event }) => {
    if (event.op === 'gap') return showCart();
    const item = 'value' in event ? event.value : await puter.kv.get(event.key);
    showCartItem(event.key, item);
}, { includeValue: true });

await showCart();

Here showCart() loads the whole cart with puter.kv.list('cart:*', true). The showCartItem() function updates one row, or removes it when item is empty.

Watch a Folder

To refresh a file list, subscribe to fs:<path>. In the handler, read the folder again with puter.fs.readdir(). A folder subscription covers everything inside it, at any depth:

async function showFiles () {
    renderFileList(await puter.fs.readdir('uploads'));
}

let queued;
const sub = await puter.events.onLocal('fs:uploads', () => {
    queued ??= setTimeout(() => {
        queued = undefined;
        showFiles();
    }, 500);
});
await showFiles();

A relative path in a subject works the same way as in puter.fs, so fs:uploads is the uploads folder inside your app's folder.

Each file gets its own event, so uploading 50 files sends 50 events. The handler above turns them into one read every half second.

The folder doesn't have to exist yet. The subscription starts delivering events when the folder is created. Until then readdir() fails. To show an empty list instead, create the folder first with puter.fs.mkdir('uploads', { createMissingParents: true }).

To watch a folder outside your app's own, such as the user's Documents, ask the user for access first. Ask for Access shows how.

Narrow What You Watch

When the view shows only part of a folder, say so in the subject. A * matches within one folder level. A ** matches across levels. A suffix limits the subscription to one kind of change:

await puter.events.onLocal('fs:uploads/*.json', handler);       // JSON files directly in uploads
await puter.events.onLocal('fs:uploads/*.json:add', handler);   // only new ones
await puter.events.onLocal('fs:uploads/**/*.png', handler);     // PNGs at any depth

The suffix is one of add, write, move or remove. It works on fs: subjects only. Events the subject filters out are never sent.

Handle Gaps

Sometimes an event can't be delivered. The handler then gets a gap marker in its place. A gap marker has op: 'gap' and no key or path. It only tells you that something changed, so read everything the view shows again:

const sub = await puter.events.onLocal('fs:inbox/*.json:add', ({ event }) => {
    if (event.op === 'gap') return showInbox();
    addInboxRow(event.path);
});

A gap also arrives after the connection drops and comes back, as covered in When the Connection Drops. The handlers in Watch a Key and Watch a Folder read everything again on every event, so they need no extra code for gaps.

Several quick writes to the same key or file can arrive as one event with the latest state. So one event can stand for more than one change.

Stop Watching

To stop, call off() on the subscription when the view that uses it goes away. For example, the user closes a panel or opens a different folder. Closing the tab ends every subscription on its own.

let sub;

async function openFolder (dir) {
    await sub?.off();
    sub = await puter.events.onLocal(`fs:${dir}`, () => showFolder(dir));
    await showFolder(dir);
}

Subscribe once when a view opens, and call off() when it closes. Subscribing on every render can hit the subscribe rate limit.

When the Connection Drops

All the subscriptions in one tab share one connection. The connection can drop, for example when a laptop goes to sleep or the network changes. The SDK then reconnects and subscribes again by itself.

Changes made while the connection was down are not sent later. Instead, each subscription's handler gets one gap with reason: 'reconnect'. The handlers above read everything again on a gap, so they catch up with no extra code.

The SDK can fail to restore a subscription, for example after the user signs out. The subscription then ends and onError is called. The same happens, with the code subscription_ended, when the watched file or folder is deleted. Nothing arrives after that, so tell the user the view has stopped updating:

const sub = await puter.events.onLocal('kv:settings', showSettings, {
    onError: (error) => showBanner(`Live updates stopped (${error.code})`),
});

Without onError the error is only logged to the console. Your app then stops updating without any sign.

Notes

← All recipes