---
title: Introduction
url: https://react-rx.dev/
---
[](https://react-rx.dev/)
> Hooks for combining React with RxJS Observables
Features:
- Works well with `Observables` emitting values synchronously. You don't pay the re-render-on-mount tax.
- Non-blocking by default — `useObservable` defers store updates; reach for `useSyncObservable` for controlled inputs.
- Lightweight. Implemented on top of a small React Hook based core.
- Full TypeScript support.
---
title: Guide
url: https://react-rx.dev/guide
---
# Getting Started
## Installation
```sh npm2yarn
npm i react-rx rxjs
```
## Observable Hooks
### Which one should I use?
- **Default to `useObservable`** — store updates are deferred, so previews, validation, lists, and other chrome stay responsive and play nicely with Suspense.
- **Reach for `useSyncObservable`** only when the value feeds a controlled input (caret/IME breakage or lost keystrokes under load) or must be read back synchronously in the same event. It is also the hook with the strict v4 SSR contract (server renders the `initialValue`, throws without one).
See [Suspense & deferred values](/examples/suspense) for a side-by-side demo, and the [v4 → v5 migration guide](/migrate/v4-to-v5) if you are upgrading.
### useObservable()
Use observables in React components with the `useObservable` hook.
If you need to subscribe to an observable in your component, this hook will give you the current value from it. Later emissions update the component at deferred priority — urgent renders keep the previous value until a background render catches up.
Example:
```tsx
import {useMemo} from 'react'
import {useObservable} from 'react-rx'
import {interval} from 'rxjs'
function MyComponent(props) {
const observable = useMemo(() => interval(100), [])
const number = useObservable(observable, 0)
return <>The number is {number}>
}
```
The `initialValue` argument is optional. If it is omitted, the value returned from `useObservable` may be `undefined` initially. If the observable emits a value _synchronously_ at subscription time, that value will be used as the initial value, and any `initialValue` passed as argument to `useObservable` will be ignored on the first render (mounts and `` reveals are not deferred):
```tsx
import {useMemo} from 'react'
import {useObservable} from 'react-rx'
import {of} from 'rxjs'
// This component will never render "Hello mars!" since the observable emits "world" synchronously.
function MyComponent(props) {
const observable = useMemo(() => of('world'), [])
const planet = useObservable(observable, 'mars')
return <>Hello {planet}!>
}
```
The difference between `useObservable` and `useSyncObservable` is how _updates_ propagate (deferred vs synchronous), not the first render. On the server, `useObservable` paints what the first client render will show (here `"world"`), while `useSyncObservable` would paint the `initialValue` (`"mars"`).
The `disabled` option pauses the hook's _active_ subscription — think of it like `pause: true`. While `disabled` is `true`, the hook will not keep a live subscription that pushes updates into the component, and it returns the last value it already received (or the `initialValue` if nothing has been received yet). Turning `disabled` back to `false` resumes the live subscription.
Important: `disabled` does **not** skip the hook's initial warm-up subscription. Both hooks always briefly subscribe during render so a synchronous emission can become the current snapshot. That means cold observables with subscribe-time side effects (for example `fromFetch`) still run that work even when `disabled` is `true`.
```tsx
import {useEffect, useState} from 'react'
import {useObservable} from 'react-rx'
import {Subject} from 'rxjs'
// While `disabled` is true, later async emissions are ignored and the last
// received value (here the initialValue "mars") is returned.
function MyComponent(props) {
const [observable] = useState(() => new Subject())
const planet = useObservable(observable, 'mars', {disabled: true})
useEffect(() => {
observable.next('world')
}, [observable])
return <>Hello {planet}!>
}
```
If the goal is to avoid _any_ subscription to a particular observable, do not use `disabled`. Pass a different observable instead — for example swap in `of(null)` until you are ready to fetch:
```tsx
import {useMemo} from 'react'
import {useObservable} from 'react-rx'
import {of} from 'rxjs'
import {fromFetch} from 'rxjs/fetch'
function Users({shouldFetch}: {shouldFetch: boolean}) {
// Prefer swapping the observable over `{disabled: !shouldFetch}`:
// `disabled` still performs the render-phase warm-up subscribe, which would
// fire the request even when `shouldFetch` is false.
const users$ = useMemo(
() =>
shouldFetch
? fromFetch('https://api.github.com/users?per_page=5', {
selector: (response) => response.json(),
})
: of(null),
[shouldFetch],
)
const users = useObservable(users$, null)
return
{JSON.stringify(users, null, 2)}
}
```
Because the fetch observable is only created (and therefore only ever subscribed) when `shouldFetch` is true, this guarantees zero subscriptions to `fromFetch` until then.
### useSyncObservable()
Same signature as `useObservable`, but updates are synchronous (the previous default). Use it for controlled inputs:
```tsx
import type {ChangeEvent} from 'react'
import {useObservableEvent, useSyncObservable} from 'react-rx'
import {map, Subject, tap, type Observable} from 'rxjs'
const text$ = new Subject()
function SearchField() {
const handleChange = useObservableEvent((events$: Observable>) =>
events$.pipe(
map((e) => e.currentTarget.value),
tap((value) => text$.next(value)),
),
)
const text = useSyncObservable(text$, '')
return
}
```
### useObservablePromise()
Use this when you want **Suspense-powered data fetching** instead of tracking loading state in the stream.
`useObservable` is built on `useSyncExternalStore`. That is great for live values, but it cannot activate a [`Suspense`](https://react.dev/reference/react/Suspense#what-activates-a-suspense-boundary) boundary, and React 19.2 [`Activity`](https://react.dev/reference/react/Activity#pre-rendering-content-thats-likely-to-become-visible) pre-rendering only fetches data read with `use(promise)`.
`useObservablePromise` returns an instrumented Promise you pass to React's `use()`. The hook itself does **not** suspend — the consumer decides where the Suspense boundary lives:
```tsx
import {Suspense, use, useMemo} from 'react'
import {useObservablePromise} from 'react-rx'
import {fromFetch} from 'rxjs/fetch'
function Users() {
const users$ = useMemo(
() =>
fromFetch('https://api.github.com/users?per_page=5', {
selector: (response) => response.json(),
}),
[],
)
const promise = useObservablePromise(users$)
return (
Loading users…
}
```
Prefer creating the promise in a parent that does **not** suspend (as above), so Suspense retries always see the same promise identity. The single-component form also works when the observable identity is stable across retries (module-level cache, or a shared `WeakMap` keyed by request):
```tsx
function UsersList({users$}) {
// `users$` must be referentially stable for the in-flight request
const users = use(useObservablePromise(users$))
return
{JSON.stringify(users, null, 2)}
}
```
**Semantics**
- Suspends until the observable's **first** emission (`firstValueFrom` semantics).
- Later emissions update the UI **without** re-showing the Suspense fallback.
- Sync sources (`of`, `BehaviorSubject`, replayed `shareReplay`) never flash a fallback.
- Errors reject the promise and surface through the nearest Error Boundary. Prefer `catchError` on the _inner_ observable when you want graceful degradation instead of a boundary.
- Completing without emitting rejects with RxJS `EmptyError`.
- Swapping to a **different** observable returns a new pending promise, so the fallback shows again. To keep the previous content visible instead, change the observable inside [`startTransition`](https://react.dev/reference/react/startTransition) or read the promise through [`useDeferredValue`](https://react.dev/reference/react/useDeferredValue) — both also give you a staleness signal (`isPending`, or `deferredPromise !== promise`) to dim stale content while the new data loads.
**Not for `startWith` placeholders.** Because the first emission unblocks Suspense, `startWith('loading')` fulfills immediately with `"loading"`. For placeholder / loading-value patterns, use `useObservable` instead.
**Options**
```ts
useObservablePromise(observable$, {
disabled?: boolean // default false — when true, this component starts no fetch
ttl?: number // default 500 — retention (ms) after settle with no subscribers
})
```
Unlike `useObservable`'s `disabled` (which still runs a warm-up probe), `disabled: true` here fully prevents fetching on behalf of this component. The returned promise is still the shared cache entry — a sibling or `preloadObservablePromise` can warm it.
`ttl` controls how long a settled value stays reusable after unmount. Remount within the window reuses the promise (no refetch, no fallback). After it expires, the next mount refetches. Eviction only affects future consumers: components that are still mounted keep their value — hiding an `` tree longer than `ttl` never drops what it already rendered.
**Deferring expensive re-renders**
Like every external-store subscription, emission-driven updates render at synchronous priority — React cannot time-slice them directly. If an emission re-renders something expensive, defer the promise itself and memoize the expensive subtree. The synchronous pass then skips the memoized subtree (it still sees the old promise), and its re-render happens at deferred priority: time-sliced, interruptible by urgent updates, and coalesced under rapid emissions.
```tsx
const BigChart = memo(function BigChart({promise}) {
const data = use(promise)
return
})
function Dashboard({metrics$}) {
const promise = useObservablePromise(metrics$)
const deferredPromise = useDeferredValue(promise)
return
}
```
The `memo` is load-bearing: without it the subtree re-renders during the synchronous pass anyway (with the old promise), defeating the deferral — see [deferring re-rendering for a part of the UI](https://react.dev/reference/react/useDeferredValue#deferring-re-rendering-for-a-part-of-the-ui). Swapped promises are always pre-settled, so the deferred subtree never suspends — it just lags by a paint under load. When the stream itself is too chatty, throttling in the pipe (`auditTime`, `throttleTime`) remains the RxJS-native complement.
**Preloading**
Warm the cache outside of render (hover, route loaders) with `preloadObservablePromise`. Calling it starts the source subscription immediately. Pending entries are never timed out — if the observable never emits or completes, the promise stays pending and the subscription stays alive until it settles (or the process tears down). Bound hang risk with RxJS [`timeout`](https://rxjs.dev/api/operators/timeout) (or cancel the source) when the preload can stall:
```tsx
import {preloadObservablePromise, useObservablePromise} from 'react-rx'
function TabButton({users$, onSelect}) {
return (
)
}
```
**Which hook when?**
| Need | Hook |
| ------------------------------------------------------ | ---------------------- |
| Live values, timers, subjects, optional `initialValue` | `useObservable` |
| Controlled inputs / synchronous store updates | `useSyncObservable` |
| Async data + Suspense / Activity pre-render | `useObservablePromise` |
| Event → observable pipelines | `useObservableEvent` |
For cold observables you want to share across subscribers yourself, keep using RxJS `shareReplay({bufferSize: 1, refCount: true})` — the hook's `ttl` is a lightweight mount/unmount cache, not a full query cache.
### useObservableEvent()
This creates an event handler that can be used to create an observable from events.
Here's an example of a component that displays the current value from a range input:
```tsx
import {useState} from 'react'
import {useObservableEvent} from 'react-rx'
import {filter, map, tap} from 'rxjs'
const ShowSliderValue = () => {
const [value, setValue] = useState(1)
const handleChange = useObservableEvent((value$) =>
value$.pipe(
// Ignore nullish values
filter(nonNullable),
// Cast to number
map((value) => Number(value)),
// Update local state
tap(setValue),
),
)
return (
<>
handleChange(event.target.value)}
min={1}
max={10}
/>
Value is: {value}
>
)
}
function nonNullable(v: T): v is NonNullable {
return v != null
}
```
---
title: API
url: https://react-rx.dev/reference
---
# React hooks
## useObservable()
A React hook that returns the current/latest value from an observable. Store updates are **deferred by default** via [`useDeferredValue`](https://react.dev/reference/react/useDeferredValue): urgent renders keep the previous value while a background render catches up. That makes it safe to suspend on the returned value without replacing already-revealed UI with a Suspense fallback.
The deferral is **identity-coherent**: unlike a bare `useDeferredValue(useObservable(...))`, the observable identity and its value are deferred as one snapshot, and when the observable identity changes (e.g. it is memoized on a document id that just changed) the hook falls back to the live value — typically the new observable's synchronous emission or the `initialValue` — so the previous identity's value never renders under the new one.
Mounts, remounts, and `` reveals still render the current snapshot synchronously (no initial-value flash). On the server, this hook renders exactly what the client's first paint will show (a synchronous emission when there is one, else the resolved `initialValue`, else nothing) and never throws for a missing `initialValue`.
Prefer this hook for previews, validation, lists, and other non-input reads. Use [`useSyncObservable`](#usesyncobservable) for controlled inputs or strict SSR control.
**Signature**
```ts
function useObservable(observable$: Observable): T | undefined
function useObservable(
observable$: Observable,
initialValue: T | (() => T),
options?: UseObservableOptions,
): T
interface UseObservableOptions {
disabled?: boolean
}
```
`disabled` pauses the live subscription (later emissions stop updating the component; the last value is kept). It does **not** skip the render-phase warm-up subscription — see the [guide](/guide) for swapping the observable when you need zero subscriptions.
**Example**
```tsx
import {useMemo} from 'react'
import {useObservable} from 'react-rx'
import {interval} from 'rxjs'
function MyComponent() {
const observable = useMemo(() => interval(100), [])
const number = useObservable(observable, 0)
return <>The number is {number}>
}
```
## useSyncObservable()
A React hook that returns the current/latest value from an observable **synchronously** via [`useSyncExternalStore`](https://react.dev/reference/react/useSyncExternalStore). This is the v4 `useObservable` behavior.
Use it when the value feeds a controlled input (or must stay consistent within the same event), or when you need strict control over server markup: the server renders the resolved `initialValue` and throws without one.
**Caveat:** store mutations cannot be marked as Transitions. Suspending on a value returned by this hook replaces already-visible content with the nearest Suspense fallback — see the [useSyncExternalStore caveats](https://react.dev/reference/react/useSyncExternalStore#caveats). Compare the two hooks in the [Suspense example](/examples/suspense).
**Signature**
```ts
function useSyncObservable(observable$: Observable): T | undefined
function useSyncObservable(
observable$: Observable,
initialValue: T | (() => T),
options?: UseObservableOptions,
): T
```
**Example**
```tsx
import type {ChangeEvent} from 'react'
import {useObservableEvent, useSyncObservable} from 'react-rx'
import {map, Subject, tap, type Observable} from 'rxjs'
const text$ = new Subject()
function SearchField() {
const handleChange = useObservableEvent((events$: Observable>) =>
events$.pipe(
map((e) => e.currentTarget.value),
tap((value) => text$.next(value)),
),
)
// Controlled input values must update synchronously.
const text = useSyncObservable(text$, '')
return
}
```
## useObservablePromise()
A React hook that turns an observable into a `use()`-compatible promise for Suspense and Activity pre-rendering.
**Signature**
```ts
function useObservablePromise(
observable: Observable,
options?: UseObservablePromiseOptions,
): ObservablePromise
interface UseObservablePromiseOptions {
disabled?: boolean
ttl?: number
}
type ObservablePromise = Promise &
({status: 'pending'} | {status: 'fulfilled'; value: T} | {status: 'rejected'; reason: unknown})
```
The hook does **not** suspend. Pass the returned promise to React's [`use`](https://react.dev/reference/react/use) inside a `` boundary. Suspends until the first emission; later emissions update without re-suspending. Errors reject the promise (Error Boundary). See the [guide](/guide) for `startWith` caveats, `disabled` / `ttl`, and when to prefer `useObservable`.
**Example**
```tsx
import {Suspense, use, useMemo} from 'react'
import {useObservablePromise} from 'react-rx'
import {fromFetch} from 'rxjs/fetch'
function Profile({url}: {url: string}) {
const data$ = useMemo(() => fromFetch(url, {selector: (r) => r.json()}), [url])
const promise = useObservablePromise(data$)
return (
)
}
function Pre({promise}: {promise: Promise}) {
return
{JSON.stringify(use(promise), null, 2)}
}
```
## preloadObservablePromise()
Warm the `useObservablePromise` cache outside of rendering (for example on `mouseenter` or in a route loader). Not a hook — callable anywhere. Returns the same promise instance the hook would return for that observable.
Calling it starts the source subscription immediately. Pending entries are never timed out, so a never-emitting / hung observable keeps both the promise and the subscription alive until it settles. Prefer RxJS [`timeout`](https://rxjs.dev/api/operators/timeout) (or cancel the source) when a preload can stall.
**Signature**
```ts
function preloadObservablePromise(
observable: Observable,
options?: {ttl?: number},
): ObservablePromise
```
Default `ttl` is `5000` (longer than the hook default) so a hover-warmed value survives until click/navigation.
## useObservableEvent()
A React hook that turns an event handler into an observable stream. Pass a function that receives an observable of events and returns an observable of side effects; the hook returns a stable callback you can attach to DOM or component event props.
When the returned callback is invoked, its single argument is emitted into the observable. The pipeline you return is subscribed for the lifetime of the component, and unsubscribed on unmount.
**Signature**
```ts
function useObservableEvent(
handleEvent: (arg: Observable) => Observable,
): (arg: T) => void
```
**Example**
```tsx
import {useState} from 'react'
import {useObservableEvent} from 'react-rx'
import {filter, map, tap} from 'rxjs'
const ShowSliderValue = () => {
const [value, setValue] = useState(1)
const handleChange = useObservableEvent((value$) =>
value$.pipe(
// Ignore nullish values
filter(nonNullable),
// Cast to number
map((value) => Number(value)),
// Update local state
tap(setValue),
),
)
return (
<>
handleChange(event.target.value)}
min={1}
max={10}
/>
Value is: {value}
>
)
}
function nonNullable(v: T): v is NonNullable {
return v != null
}
```
---
title: v4 to v5
url: https://react-rx.dev/migrate/v4-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
| | 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](https://rxjs.dev/guide/importing#how-to-migrate) if you still import from `'rxjs/operators'`.
## Deferred `useObservable` (headline change)
In v4, `useObservable` was built on [`useSyncExternalStore`](https://react.dev/reference/react/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 `` 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 (`initialValue` on the server, throws without one).
### 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.
- **SSR** now renders synchronous emissions instead of the `initialValue`, and no longer throws when `initialValue` is omitted. Non-deterministic sync emissions can surface hydration mismatches that were previously masked. Synchronously erroring observables now fail the server render instead of exploding at hydration.
### Recommended: targeted migration
Keep calling `useObservable` everywhere. Switch only controlled-input (or same-event synchronous) reads to `useSyncObservable`:
```tsx
// 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:
```tsx
// Before
const results = useDeferredValue(useObservable(results$))
// After
const results = useObservable(results$)
```
### 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](/examples/suspense) example puts both hooks side by side on a search UI.
---
title: Simple
url: https://react-rx.dev/examples/simple
---
## Hello World
```tsx filename="App.tsx"
import {useObservable} from 'react-rx'
import {of} from 'rxjs'
const observable = of('World')
export default function App() {
const data = useObservable(observable)
return
Synchronous store updates. Typing discards
visible results and shows the Suspense
fallback (also logs React’s “suspended
while responding to synchronous input”
warning — open the console).
)
}
```
## Why this happens
[`useSyncExternalStore`](https://react.dev/reference/react/useSyncExternalStore#caveats) cannot mark store mutations as Transitions, so suspending on its value triggers the nearest Suspense fallback. Wrapping the value in [`useDeferredValue`](https://react.dev/reference/react/useDeferredValue) (what `useObservable` does) is how you [prevent unwanted fallbacks](https://react.dev/reference/react/Suspense#preventing-unwanted-fallbacks) for already-revealed content.
See also [jantimon/react-hydration-rules](https://github.com/jantimon/react-hydration-rules) for the broader hydration/Suspense matrix, and the React reconciler tests for `useDeferredValue` / `useSyncExternalStore` in [facebook/react](https://github.com/facebook/react). Hydration-time differences between the two hooks are covered by the library’s Vitest suite (Sandpack examples are client-only).
---
title: Suspense data fetching
url: https://react-rx.dev/examples/data-fetching
---
# Suspense data fetching
`useObservablePromise` returns a promise for React's `use()`. The Suspense
fallback shows until the first emission; later stream updates do not re-trigger
it.
```tsx filename="App.tsx"
import {Suspense, use, useState} from 'react'
import {
useObservablePromise,
type ObservablePromise,
} from 'react-rx'
import {map, type Observable, timer} from 'rxjs'
const LATENCY_MS = 800
const userCache = new Map<
string,
Observable<{
id: string
name: string
bio: string
}>
>()
/** Stable per id so Suspense retries and remounts share one in-flight request. */
function fetchUser$(id: string) {
let observable = userCache.get(id)
if (!observable) {
observable = timer(LATENCY_MS).pipe(
map(() => ({
id,
name:
id === 'alpha'
? 'Ada Lovelace'
: 'Grace Hopper',
bio: `Profile loaded for ${id}`,
})),
)
userCache.set(id, observable)
}
return observable
}
const clock$ = timer(0, 1000).pipe(
map(
(n) =>
`Tick ${n} — live updates skip the Suspense fallback`,
),
)
function Profile({
promise,
}: {
promise: ObservablePromise<{
id: string
name: string
bio: string
}>
}) {
const user = use(promise)
return (
)
}
export default function DataFetchingExample() {
const [id, setId] = useState('alpha')
// Create the promise in a parent that does not suspend, so Suspense retries
// always see the same promise identity (see React's use() caching guidance).
const promise = useObservablePromise(
fetchUser$(id),
)
return (
{' '}
Loading {id}…
}
>
Switching profiles re-suspends (new
observable). The clock updates
continuously without flashing a fallback.
)
}
```
---
title: Activity and preload
url: https://react-rx.dev/examples/activity
---
# Activity and preload
Compare three strategies for the same ~1s tab fetch:
1. **No prefetch** — mount on click; Suspense fallback is visible
2. **Hover preload** — `preloadObservablePromise` on `mouseenter` warms the cache
3. **Activity pre-render** — hidden `` starts `use(promise)` fetches up front
```tsx filename="App.tsx"
import {
Activity,
Suspense,
useMemo,
useState,
} from 'react'
import {preloadObservablePromise} from 'react-rx'
import {fetchTab$} from './api'
import TabPanel from './TabPanel'
type Strategy = 'none' | 'preload' | 'activity'
const TABS = [
'Posts',
'Photos',
'Settings',
] as const
function TabButton({
tab,
active,
strategy,
onSelect,
}: {
tab: (typeof TABS)[number]
active: boolean
strategy: Strategy
onSelect: () => void
}) {
const data$ = useMemo(
() => fetchTab$(tab),
[tab],
)
return (
)
}
export default function App() {
const [strategy, setStrategy] =
useState('none')
const [active, setActive] =
useState<(typeof TABS)[number]>('Posts')
return (
Prefetch strategies
Each tab fetch takes ~1s. Compare
click-only loading vs hover preload vs
hidden Activity pre-render.