Skip to content

fix: subscribe useObservableState to its subject so setState re-renders - #156

Merged
mbret merged 1 commit into
mainfrom
fix/bug-hunt-2026-07-27
Jul 27, 2026
Merged

fix: subscribe useObservableState to its subject so setState re-renders#156
mbret merged 1 commit into
mainfrom
fix/bug-hunt-2026-07-27

Conversation

@mbret

@mbret mbret commented Jul 27, 2026

Copy link
Copy Markdown
Owner

Bug

useObservableState (publicly exported from the package root) returns [value, setState, subject], mirroring React.useState — its setter is even typed as Dispatch<SetStateAction<T>>. But calling setState never triggers a re-render: the component keeps rendering the stale initial value forever.

How to observe:

const [value, setState] = useObservableState(0)
// setState(1) → subject.next(1) fires, but `value` stays 0 on screen

Root cause

The hook only pushed values into its internal BehaviorSubject (subject.next(...)) and read subject.getValue() once during render. Nothing subscribed the subject back to React, so React had no reason to re-render — the returned value was a one-shot snapshot from mount. (The pre-rewrite version of this hook returned the observable itself, so consumers subscribed on their own; the rewrite in abfbbbb changed the contract to useState parity but never added the subscription.)

Fix

Wire the subject to React with useSyncExternalStore, following the same pattern the library already uses in useSignalValue: subscribe with skip(1) (a BehaviorSubject synchronously replays its current value, while useSyncExternalStore reads the initial value through getSnapshot), and use subject.getValue() as the snapshot. This also makes the hook re-render on direct subject.next() calls from outside the hook, which the returned subject explicitly invites.

Verification

Added src/lib/binding/useObservableState.test.tsx (the hook previously had no tests). Before the fix, 3 of the 5 tests fail on main:

  • ❌ → ✅ re-render when setState is called with a value
  • ❌ → ✅ re-render when setState is called with an updater function
  • ❌ → ✅ re-render when the subject is updated directly
  • ✅ default value on first render (already passing)
  • ✅ no re-render when setState receives the current value (already passing)

Full gates pass after the fix: npm run check (biome), npm run build (tsc + vite), npm run test:ci — 24 files / 139 tests green.

Other findings (not addressed in this PR)

  • signal({ default: null }) yields a signal whose value is undefined, not null, despite being typed Signal<null>: the factory in src/lib/state/Signal.ts builds the config with default: config.default ?? undefined, and ?? coerces an explicit null default to undefined. Verified by tracing the exact expression: { ...{ default: null }, default: null ?? undefined }default: undefined. Also affects SIGNAL_RESET, which resets to undefined instead of the declared null default.

Generated by Claude Code

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VHTuazpMJYhEVqdtuVWvz9
@mbret
mbret merged commit a226c01 into main Jul 27, 2026
2 checks passed
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.

2 participants