Migrating from v4 to v5
v5 is a major release. This page covers every breaking change and the recommended upgrade path for the headline hook change.
Requirements
| v4 | v5 | |
|---|---|---|
| React | 18+ | ^19.2 |
| RxJS | 7.x (operators often from 'rxjs/operators') | ^7.2, import operators from 'rxjs' |
| Node | (unspecified) | >=22.12 |
| Module format | CJS + ESM | ESM-only |
See the RxJS import migration guide if you still import from 'rxjs/operators'.
Deferred useObservable (headline change)
In v4, useObservable was built on useSyncExternalStore and forced synchronous React updates. Under load that blocks the main thread for chrome that does not need to be sync — validation, presence, previews, permissions, and similar.
v5 makes that the library default:
useObservable— store updates are deferred withuseDeferredValue. Urgent renders keep the previous value; a background render catches up. Mounts, remounts, and<Activity>reveals still show the live snapshot (no initial-value flash). The deferral is identity-coherent: when the observable identity changes, the hook falls back to the live value so the previous observable’s value never renders under the new one — a stale-value bug a hand-rolleduseDeferredValue(useObservable(...))wrapper is prone to.useSyncObservable— exact v4 synchronous behavior, including the strict server snapshot (the server renders the resolvedinitialValue).
What you may notice
- Controlled inputs can lag or lose caret position under load if they keep using
useObservable— switch those reads touseSyncObservable. - The rendered value can briefly trail the store, so imperative reads or equality checks against it can observe a stale value. Keep a sync read for write-path equality when needed.
- Render-count / test assertions may see extra passes: one Object.is bail-out pass on mount when the snapshot is defined, and an urgent-plus-deferred pair per emission.
initialValueis required since v7 for both hooks: any value works (undefinedincluded — pass it explicitly; functions act asuseState-style initializers), and omitting the argument throws during render — on the server too. The observable is never subscribed during render at all, so the server always paints the resolvedinitialValue. Observables without a meaningful initial value belong touseObservablePromiseinstead.- Observable identities must be stable since v7 (
useMemo,useState, module scope, or React Compiler memoization). There is no render-phase warm-up anymore: a fresh identity always renders theinitialValuefirst and is re-subscribed at commit, so rebuilding the observable on every render over a source that synchronously replays a different value loops forever — the same stable-identity contract asuseSyncExternalStore’ssubscribe.
Recommended: targeted migration
Keep calling useObservable everywhere. Switch only controlled-input (or same-event synchronous) reads to useSyncObservable:
// Before (v4)
const text = useObservable(text$, '')
const items = useObservable(items$, [])
// After (v5) — only the input value needs to be sync
const text = useSyncObservable(text$, '')
const items = useObservable(items$, [])Also remove redundant wrappers that are now built in:
// Before
const results = useDeferredValue(useObservable(results$))
// After (initialValue is required since v7 — undefined must be passed explicitly)
const results = useObservable(results$, undefined)Fallback: mechanical rename
If you need a zero-behavior-change upgrade day, rename every useObservable → useSyncObservable, then adopt deferral incrementally by switching non-input reads back to useObservable.
See it in action
The Suspense & deferred values example puts both hooks side by side on a search UI.