Skip to Content
Migratev4 to v5

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

v4v5
React18+^19.2
RxJS7.x (operators often from 'rxjs/operators')^7.2, import operators from 'rxjs'
Node(unspecified)>=22.12
Module formatCJS + ESMESM-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 with useDeferredValue. 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-rolled useDeferredValue(useObservable(...)) wrapper is prone to.
  • useSyncObservable — exact v4 synchronous behavior, including the strict server snapshot (the server renders the resolved initialValue).

What you may notice

  • Controlled inputs can lag or lose caret position under load if they keep using useObservable — switch those reads to useSyncObservable.
  • 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.
  • initialValue is required since v7 for both hooks: any value works (undefined included — pass it explicitly; functions act as useState-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 resolved initialValue. Observables without a meaningful initial value belong to useObservablePromise instead.
  • 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 the initialValue first 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 as useSyncExternalStore’s subscribe.

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 useObservableuseSyncObservable, 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.

Last updated on