Hello!
I’m making a WebXDC app with preact (a React alternative) and WebxdcProvider (to share data through yjs ).
My intention is to have a shared Y.Array and have preact re-render things when it’s modified, but nothing gets re-rendered.
I’ve created a minimal example to reproduce what I’m doing in the actual app in this project: UUID Manager .
There’s an app that has a shared Y.Map and it works fine: Split Bill . What’s the actual difference?
WofWca
September 23, 2025, 5:42pm
2
Hey!
Good job on a detailed post.
Your app, as well as the example app you’re referencing (Split Bill), seems to be incorrectly utilizing the useSyncExternalStore hook. From its docs :
The store snapshot returned by getSnapshot must be immutable. If the underlying store has mutable data, return a new immutable snapshot if the data has changed. Otherwise, return a cached last snapshot.
What you are doing inside your getSnapshot function is return the same object every time. Which, I assume, what React detects as “the value didn’t change, so I don’t need to re-render”. Here is a solution that I came up with:
diff --git a/js/ystore.js b/js/ystore.js
index a1e7034..6d3e1f4 100644
--- a/js/ystore.js
+++ b/js/ystore.js
@@ -22,6 +22,7 @@ export const provider = new WebxdcProvider({
class YStore {
#isSubscribed = false;
#listeners = new Set();
+ #cached = uuidsArray.toJSON()
#handleEvent = (event) => {
console.log("Received an event for the YStore!");
@@ -29,6 +30,8 @@ class YStore {
console.log(`There are ${this.#listeners.size} listeners.`);
+ this.#cached = uuidsArray.toJSON()
+
this.#listeners.forEach((l) => {
console.log("Calling a listener.");
@@ -72,7 +75,7 @@ class YStore {
// If I do this, the app freezes: empty page, infinite logs in the web browser console...
// return uuidsArray.toArray();
- return uuidsArray;
+ return this.#cached;
};
}
But maybe it’s worth checking out examples of using React together with Yjs, maybe there is a better approach.
Hello!
Ohhh, that was it!!! Your solution works! The real app now behaves as expected.
Thank you so much!
And thanks for also including the code snippet. I’ll copy more parts of the code base here to persist more things in the forum.
“Frontend” code (it hasn’t changed):
import { html } from "./html.js";
import * as yStore from "./ystore.js";
export default function App(props) {
console.log("App");
const uuids = yStore.useUuids();
function addRandomUuid() {
yStore.uuidsArray.push([crypto.randomUUID()]);
}
return html`
<h1>UUID manager</h1>
<button onClick=${() => addRandomUuid()}>Add random UUID</button>
<p>UUIDs:</p>
${uuids.map((uuid) => html`<p>${uuid}</p>`)}
`;
}
Yjs-related logic before applying the fix:
import * as Y from "yjs";
import WebxdcProvider from "y-webxdc";
import { useSyncExternalStore } from "preact/compat";
const ydoc = new Y.Doc();
export const uuidsArray = ydoc.getArray("uuids");
export const provider = new WebxdcProvider({
webxdc,
ydoc,
autosaveInterval: webxdc.sendUpdateInterval,
getEditInfo: () => {
const document = "uuids";
return { document };
},
});
/**
* I copied this code from another WebXDC app that works.
*/
class YStore {
#isSubscribed = false;
#listeners = new Set();
#handleEvent = (event) => {
console.log("Received an event for the YStore!");
console.log(`uuidsArray has ${uuidsArray.length} elements.`);
console.log(`There are ${this.#listeners.size} listeners.`);
this.#listeners.forEach((l) => {
console.log("Calling a listener.");
l();
});
};
subscribe = (listener) => {
console.log("Subscribed!");
this.#listeners.add(listener);
if (!this.#isSubscribed) {
console.log("Observing!");
uuidsArray.observe(this.#handleEvent);
this.#isSubscribed = true;
}
return () => {
console.log("Unsubscribing!");
this.#listeners.delete(listener);
if (this.#listeners.size === 0) {
console.log("Unobserving!");
uuidsArray.unobserve(this.#handleEvent);
this.#isSubscribed = false;
}
};
};
getSnapshot = () => {
// This is an object.
console.log(uuidsArray);
// This is an array.
console.log(uuidsArray.toArray());
// If I do this, the app freezes: empty page, infinite logs in the web browser console...
// return uuidsArray.toArray();
return uuidsArray;
};
}
const uuidsStore = new YStore();
export function useUuids() {
return useSyncExternalStore(uuidsStore.subscribe, uuidsStore.getSnapshot);
}
Yjs-related logic after applying the fix:
import * as Y from "yjs";
import WebxdcProvider from "y-webxdc";
import { useSyncExternalStore } from "preact/compat";
const ydoc = new Y.Doc();
export const uuidsArray = ydoc.getArray("uuids");
export const provider = new WebxdcProvider({
webxdc,
ydoc,
autosaveInterval: webxdc.sendUpdateInterval,
getEditInfo: () => {
const document = "uuids";
return { document };
},
});
/**
* I copied this code from another WebXDC app that works.
*/
class YStore {
#isSubscribed = false;
#listeners = new Set();
/**
* preact's useSyncExternalStore needs immutable data to work correctly.
* We will store immutable snapshots of the Yjs data here.
*/
#immutableSnapshot = uuidsArray.toArray();
#handleEvent = (event) => {
console.log("Received an event for the YStore!");
console.log(`uuidsArray has ${uuidsArray.length} elements.`);
console.log(`There are ${this.#listeners.size} listeners.`);
this.#immutableSnapshot = uuidsArray.toArray();
this.#listeners.forEach((l) => {
console.log("Calling a listener.");
l();
});
};
subscribe = (listener) => {
console.log("Subscribed!");
this.#listeners.add(listener);
if (!this.#isSubscribed) {
console.log("Observing!");
uuidsArray.observe(this.#handleEvent);
this.#isSubscribed = true;
}
return () => {
console.log("Unsubscribing!");
this.#listeners.delete(listener);
if (this.#listeners.size === 0) {
console.log("Unobserving!");
uuidsArray.unobserve(this.#handleEvent);
this.#isSubscribed = false;
}
};
};
getSnapshot = () => {
// Return a snapshot of the immutable data.
// Simply doing this doesn't work (the app freezes: empty page, infinite logs in the web browser console...):
// return uuidsArray.toArray();
return this.#immutableSnapshot;
};
}
const uuidsStore = new YStore();
export function useUuids() {
return useSyncExternalStore(uuidsStore.subscribe, uuidsStore.getSnapshot);
}