Immutable state, only reassigning changed subtree#3
Conversation
…ng a render count
…il because of an apparent bug with how colyseus encodes and then decodes array changes
… onto every state object.
There was a problem hiding this comment.
Pull request overview
This PR introduces an immutable state management system for Colyseus with structural sharing to minimize React re-renders. The implementation converts mutable Colyseus Schema objects into immutable snapshots while maintaining referential equality for unchanged subtrees, enabling efficient React memoization.
Key Changes:
- New React hook
useColyseusStatethat provides immutable state snapshots with structural sharing - Comprehensive test suite covering state updates, array operations, and structural sharing behavior
- Demo UI with visual render tracking to demonstrate selective re-rendering
- Updated dependencies including Colyseus, testing libraries (Vitest, Testing Library), and test environment (happy-dom)
Reviewed changes
Copilot reviewed 28 out of 29 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
src/schema/createSnapshot.ts |
Core snapshot creation logic implementing structural sharing for Schema, MapSchema, and ArraySchema |
src/schema/useColyseusState.ts |
React hook integrating with useSyncExternalStore for state subscription |
src/schema/useRoomState.ts |
Convenience wrapper for using room state directly |
src/schema/getOrCreateSubscription.ts |
Subscription management wrapping decoder's triggerChanges |
src/schema/simulateState.ts |
Testing utility for simulating Colyseus state changes |
src/tests/useColyseusState.test.ts |
Comprehensive test suite with 576 lines covering structural sharing scenarios |
src/display/* |
UI components demonstrating selective re-rendering with highlight animations |
src/App.tsx |
Updated demo application with multiple state manipulation actions |
vite.config.ts |
Added test environment configuration for Vitest |
package.json |
Updated dependencies: @colyseus/schema, colyseus.js, testing libraries |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
Hi @endel, I think this is ready for you to have a look at, when you've got the time for it. You'll see that I've not actually used the main But If there's more tests you'd like to see, please just ask. |
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
src/schema/useRoomState.ts
Outdated
| * ``` | ||
| */ | ||
| export function useRoomState<T extends Schema = Schema, U = T>( | ||
| room: Room<T>, |
There was a problem hiding this comment.
This line is the only one that requires colyseus.js.
|
This looks really great to me, thank you so much for working on this @FTWinston 🎉 What do you think? Feedback is very appreciated 🙏 @pedr0fontoura @eddiedotdev @essaenko @P3ntest @redrocket94 BTW Happy Holidays to you all 😆 |
|
You're welcome! Colyseus has saved me a huge amount of time and effort, but I needed this last part for my own purposes. I'm happy to be able to offer something back. Happy holidays! |
|
Hello guys. I have been trying this and absolutly love the concept. However i ran into an issue when working with a big colyseus state. Im working on a minesweeper multiplayer game. The board is tracked in an Backend declaration of minesweeper Schema: export class MinesweeperGame extends Schema {
@type([CellDataState]) boardData = new ArraySchema<CellDataState>();
@type("number") hintsUsed = 0;
@type("number") startTime = 0;
@type([PlayerState]) players = new ArraySchema<PlayerState>();
@type("number") status = GameStatus.NotReady;
@type("number") remainingFlags = 0;
@type("string") seed = "";
//...Frontend usage of //...
const boardData = useRoomState(room, (s) => s.boardData);
const hintsUsed = useRoomState(room, (s) => s.hintsUsed);
const startTime = useRoomState(room, (s) => s.startTime);
const connectedPlayers = useRoomState(room, (s) => s.players);
const gameStatus = useRoomState(room, (s) => s.status);
const remainingFlags = useRoomState(room, (s) => s.remainingFlags);
const seed = useRoomState(room, (s) => s.seed);
//...The problem i found was that using the useRoomState hook on this function createSnapshotForArraySchema(
node: ArraySchema<any>,
previousResult: any[] | undefined,
ctx: SnapshotContext
): any[] {
const items = Array.from(node);
const snapshotted: any[] = [];
let hasChanged = !previousResult || !Array.isArray(previousResult) || items.length !== previousResult.length;
for (let i = 0; i < items.length; i++) {
const snapshottedValue = createSnapshot(items[i], ctx);
snapshotted.push(snapshottedValue);
if (!hasChanged && previousResult && previousResult[i] !== snapshottedValue) {
hasChanged = true;
}
}
return hasChanged ? snapshotted : previousResult!;
}My guess is that every time the snapshot for an array is called the entire array gets iterated and thats the major performance killer. I dont even know if there is a patch for this. Another problem that relates to this is that when an unrelated state changes Something to note is that im using the new colyseus schema and sdk versions for this. (Schema 4.0.6 and sdk 0.17.24) Please let me know if my implementation of this hook is simply wrong or if im overlooking something. I just thought it would be good to tell you my findings. I have written a test to demonstrate these issues if you want to test it yourself. |
|
Ah, that's annoying, thanks for checking. I'm still a bit suspicious that there would be two similar bugs, neither of which affected my own project. Just to confirm, would you mind running the following command, and proving that there's only one instance of This shows two dependencies on 4.0.8 for the |
|
This looks good to me: |
|
If you wish i could give you read rights to my repository and you could have a look yourself at it. |
|
Thanks for checking! If you don't mind giving me access then sure, I'll have a look. |
|
Hi, there are a few tricks we can do to avoid
|
|
Thanks endel, those look like safer checks than what I was coming up with! 👍 |
… passed to Client.joinOrCreate
…r decoder triggerChanges reassignment
|
Thanks @BlueFire023, I think I can see what's causing your problems.
(Your hook actually does both of those on every render: you'd probably want to put these into a To stop @endel do you mind giving this approach a quick sanity check? (reassigning |
|
Thank you for investigating. Even without the triggerChanges override it did not work. But if it works now i dont have anything to complain about |
|
I'll let you know when i migrate all my properties if it works. |
|
Thanks @FTWinston 👏 No problem, it's ok plugging the hook into the |
|
HI @FTWinston, I was debugging with the help of AI, and this suggestion fixed the issues I were having. Could you check if that change could impact other things? I think that's fine, right? The example I was porting is working nicely after this change 🙌 cars.mp4 |
|
This fix also works for me. 🙌 |
|
This is really great! 👏 I love how easy this hook makes listening for state changes. Another pain point, not related with this hook, is joining rooms while in react's dev-mode. How have you solved this in your own projects @FTWinston @BlueFire023 ? Perhaps we could also add a way to join into rooms with support for react's dev-mode as part of this module...
|
|
well im catching this in my join method similar to how you are doing it: // join
const joiningRef = useRef<string | null>(null);
const joinRoomOrReconnect = async (roomId: string, options: RoomJoinType): Promise<Room> => {
console.log("joinRoomOrReconnect", roomId);
// 1. Already joining this room → do nothing
if (joiningRef.current === roomId) {
console.log("[Room] join already in progress, skipping", roomId);
return activeRoom!;
}
// 2. Already in this room → do nothing
if (activeRoom?.roomId === roomId) {
console.log("[Room] already in this room");
return activeRoom;
}
joiningRef.current = roomId; |
|
That's great, glad it's working for you both. Moving the As for changing how ArraySchema, MapSchema etc are detected: I agree with the change. It'd be nice if we could have kept using As for how I solved using (My backend is running in Electron, I never did work out how I could usefully use dev mode.) |
|
I do agree with @endel. I think there should be a room managment hook. But what should it include? Just create join and leave? And/Or logic for handling lobbies and the room with reconnection logic? |
|
I've only used colyseus with one room per server, which I'm sure is atypical. I wrapped my Client instantiation and room connection inside a single hook, but that wouldn't be any use if there were multiple rooms. Could a A It could also potentially handle some callbacks, but I'd suggest using IMO both hooks would have value even if they did nothing but connect: devs would still need to implement these hooks themselves anyway, so if they were part of the package, then they'd already be in use if there was any new behaviour added later. (I'll be away from the computer for a few days, but I can check in on this chat from mobile.) |
|
Guys what do you think. Can we make it so that the hook returns undefined if it was called with a room thats null? At the moment the thing just crashes when this happens. e.g. |
|
Nevermind. It wasnt updated yet 🙃 |
|
Hi there! I've merged this PR today 🥳 Just created a new one for the I think that's good enough for our initial versions, we can continue improving per user feedback on real world scenarios. If you guys spot any obvious problem please let me know, otherwise will merge that too and publish 🎉 Cheers!! |

I made a hook to provide immutable room state, and it works well enough for me that it's worth sharing. This is probably the best place for it, as it's not yet clear to me exactly what the best API for this would be.
I've expanded the existing state a bit, and added more buttons to the UI for interacting with the new items. I've added a react component for each object, with an animation so that items turn red when they re-render.
This should show visually that only the "ancestors" of changed items are reassigned. Structural sharing means that the rest of the state tree is not modified, so those components don't re-render. (As long as they are memoised. React otherwise renders children of a changed parent even if their props are unchanged.)