diff --git a/packages/stash/src/exports/internal.ts b/packages/stash/src/exports/internal.ts index 37b08e10f6..2948920976 100644 --- a/packages/stash/src/exports/internal.ts +++ b/packages/stash/src/exports/internal.ts @@ -2,3 +2,4 @@ export * from "../createStash"; export * from "../common"; export * from "../queryFragments"; export * from "../actions"; +export * from "../react"; diff --git a/packages/stash/src/react/index.ts b/packages/stash/src/react/index.ts new file mode 100644 index 0000000000..b5731fc2c6 --- /dev/null +++ b/packages/stash/src/react/index.ts @@ -0,0 +1 @@ +export * from "./useSelector"; diff --git a/packages/stash/src/react/useSelector.test.ts b/packages/stash/src/react/useSelector.test.ts new file mode 100644 index 0000000000..5a86d262fa --- /dev/null +++ b/packages/stash/src/react/useSelector.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect } from "vitest"; +import { renderHook, act } from "@testing-library/react-hooks"; +import { useSelector } from "./useSelector"; +import { defineStore } from "@latticexyz/store/config/v2"; +import { createStash } from "../createStash"; + +describe("useCustomHook", () => { + it("checks the re-render behavior of the hook", async () => { + const config = defineStore({ + namespaces: { + game: { + tables: { + Position: { + schema: { player: "address", x: "uint32", y: "uint32" }, + key: ["player"], + }, + }, + }, + }, + }); + const Position = config.namespaces.game.tables.Position; + const stash = createStash(config); + const player = "0x00"; + + const { result } = renderHook(() => useSelector(stash, (state) => state.records["game"]["Position"][player])); + expect(result.current).toBe(undefined); + + act(() => { + stash.setRecord({ table: Position, key: { player }, record: { x: 1, y: 2 } }); + }); + + // Expect update to have triggered rerender + expect(result.all.length).toBe(2); + expect(result.all).toStrictEqual([undefined, { player, x: 1, y: 2 }]); + expect(result.current).toStrictEqual({ player, x: 1, y: 2 }); + + act(() => { + stash.setRecord({ table: Position, key: { player: "0x01" }, record: { x: 1, y: 2 } }); + }); + + // Expect unrelated update to not have triggered rerender + expect(result.all.length).toBe(2); + expect(result.all).toStrictEqual([undefined, { player, x: 1, y: 2 }]); + expect(result.current).toStrictEqual({ player, x: 1, y: 2 }); + + // Expect update to have triggered rerender + act(() => { + stash.setRecord({ table: Position, key: { player }, record: { x: 1, y: 3 } }); + }); + + expect(result.all.length).toBe(3); + expect(result.all).toStrictEqual([undefined, { player, x: 1, y: 2 }, { player, x: 1, y: 3 }]); + expect(result.current).toStrictEqual({ player, x: 1, y: 3 }); + }); +}); diff --git a/packages/stash/src/react/useSelector.ts b/packages/stash/src/react/useSelector.ts new file mode 100644 index 0000000000..cd1bca84fc --- /dev/null +++ b/packages/stash/src/react/useSelector.ts @@ -0,0 +1,28 @@ +import { useEffect, useRef, useState } from "react"; +import { State, Stash, StoreConfig } from "../common"; +import { subscribeStore } from "../actions"; + +export function useSelector( + stash: Stash, + selector: (stash: State) => T, + equals: (a: T, b: T) => boolean = (a, b) => a === b, +): T { + const state = useRef(selector(stash.get())); + const [, forceUpdate] = useState({}); + + useEffect(() => { + const unsubscribe = subscribeStore({ + stash, + subscriber: () => { + const nextState = selector(stash.get()); + if (!equals(state.current, nextState)) { + state.current = nextState; + forceUpdate({}); + } + }, + }); + return unsubscribe; + }, []); + + return state.current; +}