From 542ce16998ac756e27537fc64ce06f72cfbb753b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 07:14:29 +0000 Subject: [PATCH] fix: subscribe useObservableState to its subject so setState re-renders The hook returns [value, setState, subject] mirroring React.useState, but nothing connected the BehaviorSubject back to React: setState only called subject.next() and the render read subject.getValue() once, so components never re-rendered and kept displaying the stale value forever. Wire the subject to React with useSyncExternalStore, skipping the BehaviorSubject's synchronous replay emission since useSyncExternalStore reads the initial value through getSnapshot. This also picks up direct subject.next() calls from outside the hook. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01VHTuazpMJYhEVqdtuVWvz9 --- src/lib/binding/useObservableState.test.tsx | 65 +++++++++++++++++++++ src/lib/binding/useObservableState.ts | 30 +++++++++- 2 files changed, 92 insertions(+), 3 deletions(-) create mode 100644 src/lib/binding/useObservableState.test.tsx 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] }