Skip to content

Immutable state, only reassigning changed subtree#3

Merged
endel merged 47 commits intomainfrom
dev/immutable-change-tree
Feb 25, 2026
Merged

Immutable state, only reassigning changed subtree#3
endel merged 47 commits intomainfrom
dev/immutable-change-tree

Conversation

@FTWinston
Copy link
Collaborator

@FTWinston FTWinston commented Dec 18, 2025

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.)

@FTWinston FTWinston changed the title Immutable state, only reassigning subtrees Immutable state, only reassigning changed subtree Dec 18, 2025
@FTWinston FTWinston marked this pull request as ready for review December 23, 2025 01:14
@FTWinston FTWinston requested a review from Copilot December 23, 2025 01:15
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 useColyseusState that 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.

@FTWinston
Copy link
Collaborator Author

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 useRoomState hook in the example, or the tests. Both were easier without involving a real room, so they call the useColyseusState hook instead. (Not a great name, that. Any better suggestions?)

But useRoomState is just a wrapper around a call to useColyseusState, so hopefully that doesn't make much difference.

If there's more tests you'd like to see, please just ask.

FTWinston and others added 2 commits December 23, 2025 01:20
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
* ```
*/
export function useRoomState<T extends Schema = Schema, U = T>(
room: Room<T>,
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This line is the only one that requires colyseus.js.

@endel
Copy link
Member

endel commented Dec 23, 2025

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 😆

@FTWinston
Copy link
Collaborator Author

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!

@BlueFire023
Copy link

BlueFire023 commented Jan 27, 2026

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 ArraySchema<CellData>. CellData is also a Schema and it has up to 3 fields. Now my game allows the board to be at most 50x50 cells big. That results in an array with 2500 entries.

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 useRoomState:

//...
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 ArraySchema resulted in major performance issues. Even changing the properties of only one cell is a big problem.

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 createSnapshot gets called for all other Schemas subscribed to. In my case if for example the remainingFlags property changes its value, createSnapshotForArraySchema gets called aswell. Im not aware where this issue originates from.

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.

@FTWinston
Copy link
Collaborator Author

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 @colyseus/schema in your dependencies? (i.e. all but one are deduped)

npm list @colyseus/schema

This shows two dependencies on 4.0.8 for the demo-client project, the second one being deduped. But that's not the whole story, as the demo-client project imports the useRoomState hook from the project above it, and that one has its own separate dependency on @colyseus/schema.

@BlueFire023
Copy link

BlueFire023 commented Feb 12, 2026

This looks good to me:

minesweeper-frontend@0.1.0 D:\Repos\minesweeperv3\frontend
+-- @colyseus/react@0.1.1
| `-- @colyseus/schema@4.0.11 deduped
+-- @colyseus/schema@4.0.11
`-- @colyseus/sdk@0.17.31
  `-- @colyseus/schema@4.0.11 deduped

@BlueFire023
Copy link

If you wish i could give you read rights to my repository and you could have a look yourself at it.

@FTWinston
Copy link
Collaborator Author

Thanks for checking! If you don't mind giving me access then sure, I'll have a look.

@endel
Copy link
Member

endel commented Feb 12, 2026

Hi, there are a few tricks we can do to avoid instanceof not returning the correct value in case of lib duplication:

  • node instanceof MapSchema -> typeof(node["set"]) === "function"
  • node instanceof ArraySchema -> typeof(node["push"]) === "function"
  • node instanceof Schema -> Schema.isSchema(node)

@FTWinston
Copy link
Collaborator Author

Thanks endel, those look like safer checks than what I was coming up with! 👍

@FTWinston
Copy link
Collaborator Author

FTWinston commented Feb 13, 2026

Thanks @BlueFire023, I think I can see what's causing your problems.

useRoomState reassigns the decoder's triggerChanges method, but your useBoardViewModel hook also reassigns this (via room['serializer']['decoder'].triggerChanges). Your hook also calls Callbacks.get, which in turn also reassigns the decoder's triggerChanges method. Each of those reassigned versions still calls the "original" version, but it looks like the combination of them is somehow skipping the useRoomState version after the initial render.

(Your hook actually does both of those on every render: you'd probably want to put these into a useEffect instead.)

To stop useRoomState from interfering with places that reassign the decoder's triggerChanges method, I've changed it to reassign the decode method instead of triggerChanges. All of the tests still pass, and your player objects now update their counters with every change, like they're supposed to.

@endel do you mind giving this approach a quick sanity check? (reassigning decode instead of triggerChanges ... that's not something that is as likely to be reassigned, is it?)

@BlueFire023
Copy link

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

@BlueFire023
Copy link

I'll let you know when i migrate all my properties if it works.

@endel
Copy link
Member

endel commented Feb 13, 2026

Thanks @FTWinston 👏 No problem, it's ok plugging the hook into the .decode() method!

@endel
Copy link
Member

endel commented Feb 13, 2026

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

@BlueFire023
Copy link

This fix also works for me. 🙌

@endel
Copy link
Member

endel commented Feb 13, 2026

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...

Screenshot 2026-02-13 at 16 52 43

@BlueFire023
Copy link

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;

@FTWinston
Copy link
Collaborator Author

FTWinston commented Feb 13, 2026

That's great, glad it's working for you both. Moving the dirtyRefIds.clear() call looks good to me, @endel.

As for changing how ArraySchema, MapSchema etc are detected: I agree with the change. It'd be nice if we could have kept using instanceof, but if someone imports a schema class from their backend project into their frontend, and they aren't using an npm workspace to dedupe the @colyseus/schema dependency, that will fail. That seems to me like it would be too easy a trap to fall into.

As for how I solved using joinOrCreate from inside a useEffect in development when using react's <StrictMode> ... I didn't 😅

(My backend is running in Electron, I never did work out how I could usefully use dev mode.)

@BlueFire023
Copy link

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?

@FTWinston
Copy link
Collaborator Author

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 useClient hook have value, for managing reconnection etc?

A useRoomConnection hook could potentially listen for messages, but you'd need to pass a memoised callback, and I don't know if that offers a benefit compared to managing that outside the hook.

It could also potentially handle some callbacks, but I'd suggest using useRoomState with useEffect or useMemo instead of callbacks in a react app.

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.)

@BlueFire023
Copy link

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.

can't access property "serializer", room is null

@BlueFire023
Copy link

Nevermind. It wasnt updated yet 🙃

@endel endel merged commit 6c2c64c into main Feb 25, 2026
1 check passed
@FTWinston FTWinston deleted the dev/immutable-change-tree branch February 25, 2026 14:45
@endel
Copy link
Member

endel commented Feb 25, 2026

Hi there! I've merged this PR today 🥳 Just created a new one for the useRoom() + createRoomContext() utilities: #6

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!!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants