diff --git a/src/lib/binding/useObservableState.test.tsx b/src/lib/binding/useObservableState.test.tsx new file mode 100644 index 0000000..c85ba89 --- /dev/null +++ b/src/lib/binding/useObservableState.test.tsx @@ -0,0 +1,65 @@ +import { act, cleanup, renderHook } from "@testing-library/react" +import { afterEach, describe, expect, it } from "vitest" +import { useObservableState } from "./useObservableState" + +afterEach(() => { + cleanup() +}) + +describe("useObservableState", () => { + it("should return the default value on first render", () => { + const { result } = renderHook(() => useObservableState(5)) + + expect(result.current[0]).toBe(5) + expect(result.current[2].getValue()).toBe(5) + }) + + it("should re-render with the new value when setState is called", () => { + const { result } = renderHook(() => useObservableState(0)) + + act(() => { + result.current[1](1) + }) + + expect(result.current[0]).toBe(1) + }) + + it("should re-render with the new value when setState is called with an updater", () => { + const { result } = renderHook(() => useObservableState(1)) + + act(() => { + result.current[1]((prev) => prev + 1) + }) + + expect(result.current[0]).toBe(2) + }) + + it("should re-render when the subject is updated directly", () => { + const { result } = renderHook(() => useObservableState(0)) + + act(() => { + result.current[2].next(10) + }) + + expect(result.current[0]).toBe(10) + }) + + it("should not re-render when setState is called with the current value", () => { + let renderCount = 0 + + const { result } = renderHook(() => { + renderCount++ + + return useObservableState(0) + }) + + const renderCountAfterMount = renderCount + + act(() => { + result.current[1](0) + }) + + expect(result.current[0]).toBe(0) + expect(renderCount).toBe(renderCountAfterMount) + }) +}) diff --git a/src/lib/binding/useObservableState.ts b/src/lib/binding/useObservableState.ts index cb588f7..72879ec 100644 --- a/src/lib/binding/useObservableState.ts +++ b/src/lib/binding/useObservableState.ts @@ -1,5 +1,10 @@ -import { type Dispatch, type SetStateAction, useCallback } from "react" -import { BehaviorSubject } from "rxjs" +import { + type Dispatch, + type SetStateAction, + useCallback, + useSyncExternalStore, +} from "react" +import { BehaviorSubject, skip } from "rxjs" import { useConstant } from "../utils/react/useConstant" export const useObservableState = ( @@ -29,7 +34,26 @@ export const useObservableState = ( [subject], ) - const value = subject.getValue() + const subscribe = useCallback( + (onChange: () => void) => { + /** + * `BehaviorSubject` synchronously replays its current value to new + * subscribers while `useSyncExternalStore` reads the initial value + * through `getSnapshot`, so the first emission is skipped to only + * notify React about actual changes. + */ + const sub = subject.pipe(skip(1)).subscribe(onChange) + + return () => { + sub.unsubscribe() + } + }, + [subject], + ) + + const getSnapshot = useCallback(() => subject.getValue(), [subject]) + + const value = useSyncExternalStore(subscribe, getSnapshot, getSnapshot) return [value, setState, subject] }