---
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.
- **Reach for `useObservablePromise`** when the observable has no meaningful initial value, or you want fallback UI while waiting for the first emission — it returns a `use()`-compatible promise for Suspense.
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 **required**: it is what the component renders until the observable emits. Every value is a valid initial value — `undefined` included, pass it explicitly — and omitting the argument throws during render. Functions act as initializers, exactly like `useState`: pass `() => value` to compute the initial value lazily, and an initializer returning the function when the initial value should be a function itself.
The observable is never subscribed during render. Every render — the first one and every identity change alike — shows the `initialValue` (or the shared entry's last emission), and the subscription starts when the component commits — an observable that emits _synchronously_ at subscription time (`of`, `startWith`, a `BehaviorSubject`, …) replaces the `initialValue` right after that commit. This keeps subscribe-time side effects (for example a `fromFetch` request) out of the render phase.
Keep the observable's identity stable across renders (`useMemo`, `useState`, module scope, or React Compiler memoization). Like `useSyncExternalStore`'s `subscribe`, an observable rebuilt on every render is re-subscribed on every render — and when it synchronously replays a value that differs from the `initialValue`, the resulting re-render builds yet another identity and the component loops forever.
```tsx
import {useMemo} from 'react'
import {useObservable} from 'react-rx'
import {of} from 'rxjs'
// The first render shows "mars"; the synchronous emission "world" takes over
// right after mount, once the live subscription delivers it.
function MyComponent(props) {
const observable = useMemo(() => of('world'), [])
const planet = useObservable(observable, 'mars')
return <>Hello {planet}!>
}
```
If there is no initial value that makes sense for your observable — or you want to show fallback UI while the observable is "loading" — that is what [`useObservablePromise`](#useobservablepromise) is for: it returns a `use()`-compatible promise that suspends until the first emission instead of painting a placeholder value.
The difference between `useObservable` and `useSyncObservable` is how _updates_ propagate (deferred vs synchronous), not the first render. On the server both hooks render the resolved `initialValue` — exactly what the first client paint will show — and neither ever subscribes the observable there.
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. A disabled hook performs no subscriptions at all — even when the observable is rebuilt on every render — so `disabled: true` guarantees zero subscriptions until it is re-enabled.
```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}!>
}
```
That guarantee makes `disabled` the tool for gating observables with subscribe-time side effects:
```tsx
import {useMemo} from 'react'
import {useObservable} from 'react-rx'
import {fromFetch} from 'rxjs/fetch'
function Users({shouldFetch}: {shouldFetch: boolean}) {
const users$ = useMemo(
() =>
fromFetch('https://api.github.com/users?per_page=5', {
selector: (response) => response.json(),
}),
[],
)
// Nothing subscribes during render, and `disabled` skips the commit-time
// subscription too — the request is guaranteed not to fire until
// `shouldFetch` becomes true.
const users = useObservable(users$, null, {disabled: !shouldFetch})
return
{JSON.stringify(users, null, 2)}
}
```
### useSyncObservable()
Same signature as `useObservable`, but updates are synchronous (the previous default). Use it for controlled inputs:
```tsx
import type {ChangeEvent} from 'react'
import {useMemo} from 'react'
import {useObservableSubject, useSyncObservable} from 'react-rx'
import {map} from 'rxjs'
function SearchField() {
const [changes$, handleChange] = useObservableSubject>()
const text$ = useMemo(() => changes$.pipe(map((event) => event.currentTarget.value)), [changes$])
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 can only wait on data read with `use(promise)`.
`useObservablePromise` returns an instrumented Promise meant to be passed as a prop to a child component, which reads it with React's `use()`. The hook itself does **not** suspend, and mounting renders never subscribe the source — the fetch starts when the component that called the hook **commits**, when an already-live consumer swaps to a new observable (see below), or when you call `preloadObservablePromise`. Place a `` boundary between the hook caller and the child that reads the promise:
```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…
}
```
The boundary placement is load-bearing: it must sit **between** the component calling `useObservablePromise` and the child calling `use()`. Without a boundary in between, the child's suspension propagates to the hook caller itself — and a suspended component never commits, so the fetch can never start.
For the same reason, never call `use()` on the promise in the component that created it:
```tsx
function UsersList({users$}) {
// 🚫 Wrong: suspends this component on its own pending promise before the
// commit that would start the fetch — it deadlocks. This is unsafe in the
// same way as use()-ing a promise you created during your own render, and
// it is intentionally not guarded against.
const users = use(useObservablePromise(users$))
return
{JSON.stringify(users, null, 2)}
}
```
**Semantics**
- Fetching has three triggers: a non-`disabled` hook caller **commits**; an already-**live** consumer (committed, visible, subscribed) re-renders with a **new observable**, whose swap render starts the new source so `startTransition` / `useDeferredValue` swaps can settle and commit; or `preloadObservablePromise` is called. Mounting renders, hidden [`Activity`](https://react.dev/reference/react/Activity) pre-renders, and `disabled` consumers never trigger fetching from render.
- 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`) resolve during the hook caller's commit, so a cold mount still shows one fallback pass. Preload the observable (or share an already-settled entry) to render them without 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 a sync swap shows the fallback 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), React's [refetch pattern](https://react.dev/reference/react/use#re-fetching-data-in-client-components). Both also give you a staleness signal (`isPending`, or `deferredPromise !== promise`) to dim stale content while the new data loads. The live consumer's swap render starts the fetch itself; preloading first, for example on hover, is optional and lets the swap commit with no pending period. See the [Transitions and refetching example](/examples/transitions).
**Activity**
A hidden `` tree that calls the hook is fully paused — no subscription, no fetching — until it is revealed and effects mount. To pre-render hidden content _with_ data, own the promise in a visible component and pass it into the hidden tree, where `use(promise)` lets React pre-render in the background and suspend only while the observable has not emitted yet:
```tsx
function PrerenderedTab({tab, active}) {
// Visible owner: its commit starts the fetch.
const promise = useObservablePromise(fetchTab$(tab))
return (
}>
)
}
```
**Not for `startWith` placeholders.** Because the first emission unblocks Suspense, `startWith('loading')` fulfills 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
})
```
Like `useObservable`'s `disabled`, `disabled: true` fully prevents fetching on behalf of this component: it skips the commit-time store subscription, so it also receives no re-render notifications for later emissions. 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). The boundary between `Dashboard` and `BigChart` is load-bearing too: `Dashboard` must commit while `BigChart` suspends on the initial pending promise, since that commit starts the fetch. Swapped promises are always pre-settled, so after the first load the deferred subtree never re-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 before any consumer is live (hover, route loaders, ahead of a transition swap) with `preloadObservablePromise`. Calling it starts the source subscription immediately, before any component has committed, which also makes it the tool for sync sources that should mount without a fallback. On the server it is a no-op (see below), so a preload in shared/isomorphic code only takes effect in the browser. 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 (
)
}
```
**Server rendering and Server Components**
react-rx is a **client-only** library — every export ships behind `'use client'`, and observables are **never subscribed on the server**. A server-started subscription has no unmount to tear it down, a never-settling source would keep it (and the response stream) alive forever, and the module-scope promise cache would be shared across requests. Concretely:
- `useObservable` / `useSyncObservable` server-render like `useSyncExternalStore`: the server paints the resolved `initialValue` and the live subscription starts on the client.
- `useObservablePromise` returns a pending promise on the server, so server rendering emits the Suspense fallback; the fetch starts on the client once the hydrated hook caller commits.
- `preloadObservablePromise` is a no-op on the server: it returns an inert, forever-pending promise and subscribes nothing, so preloads in shared/isomorphic code (route loaders) only take effect in the browser.
This is not the library for React Server Components or server-only data flows. Hooks imported from a Server Component are client references and cannot be called there. When you need server-fetched data, fetch it in the Server Component with async/await or RxJS [`firstValueFrom`](https://rxjs.dev/api/index/function/firstValueFrom) (same settle semantics as the hook's promise) and pass the value — or the un-awaited promise, for [`use()`](https://react.dev/reference/react/use#streaming-data-from-server-to-client) — as a prop into your client components.
**Which hook when?**
| Need | Hook |
| --------------------------------------------------- | ---------------------- |
| Live values, timers, subjects (with `initialValue`) | `useObservable` |
| Controlled inputs / synchronous store updates | `useSyncObservable` |
| No meaningful `initialValue`, Suspense, Activity | `useObservablePromise` |
| Events pushed from handlers | `useObservableSubject` |
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.
### Handling events
`useObservableSubject` creates a `Subject` for the component and returns its observable side plus a stable handler that pushes events into it. Read the derived stream with whichever hook fits the read. This is the same mental model the upcoming [native Observable API](https://github.com/WICG/observable) builds on: events become observables, and state is derived from them.
Here's a component that displays the current value from a range input. The pipeline's emissions _are_ the rendered value — no local `useState` mirror, no `tap`:
```tsx
import {useMemo} from 'react'
import {useObservableSubject, useSyncObservable} from 'react-rx'
import {map} from 'rxjs'
const ShowSliderValue = () => {
const [input$, handleChange] = useObservableSubject()
const value$ = useMemo(() => input$.pipe(map((value) => Number(value))), [input$])
const value = useSyncObservable(value$, 1)
return (
<>
handleChange(event.currentTarget.value)}
min={1}
max={10}
/>
Value is: {value}
>
)
}
```
Pipelines with nothing to render (analytics, persistence, …) subscribe the observable in an effect instead:
```tsx
import {useEffect} from 'react'
import {useObservableSubject} from 'react-rx'
import {concatMap} from 'rxjs'
function SaveSearchButton({term}: {term: string}) {
const [saves$, handleSave] = useObservableSubject()
useEffect(() => {
const subscription = saves$.pipe(concatMap((t) => saveSearch(t))).subscribe()
return () => subscription.unsubscribe()
}, [saves$])
return
}
```
Everything RxJS offers applies on the way from event to value — `debounceTime`, `distinctUntilChanged`, `switchMap`, `scan`, and friends all go in the `pipe`, as in the [search example](/examples/search).
For **event-driven Suspense data**, seed a `BehaviorSubject` with the initial query and derive the request stream from it. [`useObservablePromise`](#useobservablepromise) suspends until the first result, and later events swap in new data without re-showing the fallback (while `switchMap` cancels the stale request):
```tsx
import {Suspense, use, useMemo} from 'react'
import {useObservablePromise} from 'react-rx'
import {BehaviorSubject, switchMap} from 'rxjs'
import {fromFetch} from 'rxjs/fetch'
const query$ = new BehaviorSubject('react')
function Search() {
const results$ = useMemo(
() =>
query$.pipe(
switchMap((query) =>
fromFetch(`https://api.github.com/search/repositories?q=${query}&per_page=5`, {
selector: (response) => response.json(),
}),
),
),
[],
)
const promise = useObservablePromise(results$)
return (
<>
query$.next(event.currentTarget.value)}
/>
Searching…}>
>
)
}
function Results({promise}: {promise: Promise}) {
return
{JSON.stringify(use(promise), null, 2)}
}
```
---
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 — the `initialValue`, or the new observable's last emission when it is already live elsewhere — so the previous identity's value never renders under the new one.
`initialValue` is **required**: it is what renders until the observable emits. Every value is a valid initial value — `undefined` included, pass it explicitly — and omitting the argument throws during render. Functions act as initializers, exactly like `useState`: pass `() => value` to compute the initial value lazily, and an initializer returning the function when the initial value should be a function itself. When there is no meaningful initial value, use [`useObservablePromise`](#useobservablepromise) instead.
Mounts, remounts, and `` reveals still render the current snapshot synchronously (no initial-value flash once a value has been emitted). The observable is **never subscribed during render** — the `initialValue` paints first and the live subscription starts on commit, keeping subscribe-time side effects out of the render phase. Keep the observable's identity stable across renders (`useMemo`, `useState`, module scope, or React Compiler memoization): like `useSyncExternalStore`'s `subscribe`, an observable rebuilt on every render is re-subscribed on every render, and when it synchronously replays a value that differs from the `initialValue` this forces a render loop. On the server, this hook renders the resolved `initialValue` — exactly what the client's first paint will show — and never subscribes the observable.
Prefer this hook for previews, validation, lists, and other non-input reads. Use [`useSyncObservable`](#usesyncobservable) for controlled inputs.
**Signature**
```ts
function useObservable(
observable$: Observable,
initialValue: T | (() => T),
options?: UseObservableOptions,
): T
function useObservable(
observable$: Observable,
initialValue: InitialValue | (() => InitialValue),
options?: UseObservableOptions,
): T | InitialValue
interface UseObservableOptions {
disabled?: boolean
}
```
`disabled` pauses the live subscription (later emissions stop updating the component; the last value is kept). A disabled hook performs no subscriptions at all — `disabled: true` means zero subscriptions until it is re-enabled — see the [guide](/guide).
**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.
`initialValue` is **required** and follows the same rules as [`useObservable`](#useobservable): every value is valid (`undefined` included), functions act as `useState`-style initializers, and omitting the argument throws during render. The server always renders the resolved `initialValue`.
**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,
initialValue: T | (() => T),
options?: UseObservableOptions,
): T
function useSyncObservable(
observable$: Observable,
initialValue: InitialValue | (() => InitialValue),
options?: UseObservableOptions,
): T | InitialValue
```
> [!WARNING]
>
> The overload without `initialValue` is deprecated. v7 removes it and requires the argument.
> `useSyncObservable(observable$, undefined)` is a drop-in replacement with the same type and, in
> v6, the same behavior. See the [v6 to v7 migration
> guide](/migrate/v6-to-v7#initialvalue-is-now-required).
**Example**
```tsx
import type {ChangeEvent} from 'react'
import {useMemo} from 'react'
import {useObservableSubject, useSyncObservable} from 'react-rx'
import {map} from 'rxjs'
function SearchField() {
const [changes$, handleChange] = useObservableSubject>()
const text$ = useMemo(() => changes$.pipe(map((event) => event.currentTarget.value)), [changes$])
// 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, and mounting renders never subscribe the source: the fetch starts when the component that called the hook commits, when an already-live consumer re-renders with a new observable (so `startTransition` / `useDeferredValue` swaps fetch and commit on their own; see the [Transitions and refetching example](/examples/transitions)), or via [`preloadObservablePromise`](#preloadobservablepromise). Pass the returned promise as a prop to a child component that reads it with React's [`use`](https://react.dev/reference/react/use), with a `` boundary **between** the hook caller and that child — the caller must be able to commit while the child suspends. Never call `use()` on the promise in the same component that called the hook (or without a boundary in between): it suspends on its own pending promise before the fetch can start and deadlocks, the same wrong usage as `use()`-ing a promise created during your own render, and it is not guarded against. Suspends until the first emission; later emissions update without re-suspending. Errors reject the promise (Error Boundary). Hidden `` trees calling the hook stay paused until revealed.
Client components only: on the server the observable is never subscribed, so server rendering emits the Suspense fallback and the fetch starts after hydration. react-rx is not a library for React Server Components or server-only flows — see [server rendering in the guide](/guide). Also see the [guide](/guide) for `startWith` caveats, `disabled` / `ttl`, Activity patterns, 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 before any consumer is live (for example on `mouseenter`, in a route loader, or ahead of a transition swap so it commits with no pending period). Not a hook — callable anywhere. Returns the same promise instance the hook would return for that observable.
Calling it starts the source subscription immediately, before any component has committed. On the server it is a no-op: it returns an inert, forever-pending promise and subscribes nothing (react-rx never subscribes observables on the server), so preloads in shared/isomorphic code only take effect in the browser. 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.
## useObservableSubject()
Creates an RxJS `Subject` scoped to the component instance and returns its two halves: an observable of the values pushed into it, plus a stable handler that pushes them.
Only the observable side of the `Subject` is exposed, so the pipeline cannot accidentally push into the stream and the handler cannot be subscribed. The handler is referentially stable, so it can be passed straight to event props or memoized children without `useCallback`. Read the observable with [`useObservable`](#useobservable) / [`useSyncObservable`](#usesyncobservable) when the pipeline produces something to render, or subscribe it in an effect for side-effect-only pipelines. Values emitted while nothing is subscribed are dropped, exactly like a `Subject`.
`useObservableEvent` was removed in v7; this hook is the replacement. Call sites migrated to it on v6.1 need no further changes. See the [v6 → v7 migration guide](/migrate/v6-to-v7#useobservableevent-is-removed).
**Signature**
```ts
function useObservableSubject(): [events$: Observable, handleEvent: (event: T) => void]
```
**Example**
```tsx
import {useMemo} from 'react'
import {useObservableSubject, useSyncObservable} from 'react-rx'
import {map} from 'rxjs'
const ShowSliderValue = () => {
const [input$, handleChange] = useObservableSubject()
const value$ = useMemo(() => input$.pipe(map((value) => Number(value))), [input$])
// The derived stream is the state — no setState mirror needed.
const value = useSyncObservable(value$, 1)
return (
<>
handleChange(event.currentTarget.value)}
min={1}
max={10}
/>
Value is: {value}
>
)
}
```
Side-effect-only pipelines (analytics, persistence, …) subscribe the observable in an effect instead:
```tsx
import {useEffect} from 'react'
import {useObservableSubject} from 'react-rx'
import {concatMap} from 'rxjs'
function SaveSearchButton({term}: {term: string}) {
const [saves$, handleSave] = useObservableSubject()
useEffect(() => {
const subscription = saves$.pipe(concatMap((t) => saveSearch(t))).subscribe()
return () => subscription.unsubscribe()
}, [saves$])
return
}
```
## What happened to useObservableEvent()?
It was removed in v7. Use [`useObservableSubject`](#useobservablesubject) and read the derived stream with one of the other hooks — see [Handling events](/guide#handling-events) and the [v6 → v7 migration guide](/migrate/v6-to-v7#useobservableevent-is-removed).
---
title: v4 to v7
url: https://react-rx.dev/migrate/v4-to-v7
---
# Migrating from v4 to v7
> v7 is in prerelease. Install it with `npm install react-rx@next`. The stable release is v6.
Most react-rx installs are still on v4, and there is no reason to stop at v5 or v6 on the way up. This page is the direct path: it groups the changes by what you need to do to your code rather than by the version that introduced them. The per-version guides ([v4 to v5](/migrate/v4-to-v5), [v5 to v6](/migrate/v5-to-v6), [v6 to v7](/migrate/v6-to-v7)) go deeper on each step.
Across the whole span, only one export was removed: `useObservableEvent`. `useObservable` is still here with the same call shape, and v5 added `useSyncObservable`, `useObservablePromise`, and `preloadObservablePromise`.
## Requirements
| | v4 | v7 |
| ------------- | --------------------------------------------- | -------------------------------------- |
| 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 |
Upgrade React and Node first; the rest of this guide assumes React 19.2. If you still import from `'rxjs/operators'`, follow the [RxJS import migration guide](https://rxjs.dev/guide/importing#how-to-migrate).
## 1. Pass an `initialValue` to every `useObservable` call
In v4 the second argument was optional. In v7 it is required, and the hooks throw during render if it is missing. Every value is valid, `undefined` included; it just has to be passed explicitly. Functions act as initializers, exactly like `useState`.
```tsx
// Before (v4) — implicitly undefined until the first emission
const users = useObservable(users$)
// After (v7)
const users = useObservable(users$, undefined)
const count = useObservable(count$, () => count$.getValue())
```
The `initialValue` also means something slightly different now. v4 subscribed during render, so a synchronously emitting source (`of`, `startWith`, a `BehaviorSubject`) painted its value on the very first render. v7 never subscribes during render: the `initialValue` renders first, the subscription starts on commit, and a synchronous emission replaces the initial value right after mount. Server rendering always emits the `initialValue`. If a source has no initial value that makes sense and you want fallback UI while it loads, that is what `useObservablePromise` is for (step 5).
## 2. Decide which reads must stay synchronous
v4's `useObservable` forced synchronous React updates. Since v5, `useObservable` defers store updates with `useDeferredValue`: urgent renders keep the previous value while a background render catches up, which keeps typing and interaction smooth under load. The old synchronous behavior lives on as `useSyncObservable`.
Keep `useObservable` for most reads. Switch to `useSyncObservable` for controlled inputs and for values that must stay consistent within the same event:
```tsx
// Before (v4)
const text = useObservable(text$, '')
const items = useObservable(items$, [])
// After (v7) — only the input value needs to be sync
const text = useSyncObservable(text$, '')
const items = useObservable(items$, [])
```
If you need a zero-behavior-change upgrade day, rename every `useObservable` to `useSyncObservable` first and adopt deferral incrementally. Remove hand-rolled `useDeferredValue(useObservable(...))` wrappers; the deferral is built in and identity-coherent. The [Suspense and deferred values example](/examples/suspense) shows the two hooks side by side.
## 3. Keep observable identities stable
Because nothing is subscribed during render anymore, an observable rebuilt on every render is torn down and re-subscribed on every render, the same contract as `useSyncExternalStore`'s `subscribe`. When such a source synchronously replays a value that differs from the `initialValue`, the component loops until React aborts. Memoize observables built from props or state with `useMemo`, keep them in `useState`, or hoist them to module scope. React Compiler memoization also satisfies this.
```tsx
// Before (v4) — tolerated by the render-phase warm-up
const value = useObservable(store.get(id).pipe(map(pick)), null)
// After (v7)
const value$ = useMemo(() => store.get(id).pipe(map(pick)), [store, id])
const value = useObservable(value$, null)
```
## 4. Replace `useObservableEvent` with `useObservableSubject`
`useObservableEvent` created a `Subject` internally, returned a handler that pushed into it, and subscribed the pipeline you returned in an effect. `useObservableSubject` exposes the observable and stable handler directly, and you read derived streams with the other hooks. Pipelines that ended in `tap(setState)` lose the local state mirror entirely:
```tsx
// Before (v4)
const [value, setValue] = useState(1)
const handleChange = useObservableEvent((value$) =>
value$.pipe(
map((value) => Number(value)),
tap(setValue),
),
)
// handleChange(event.currentTarget.value)} />
// After (v7) — the derived stream is the state
const [input$, handleChange] = useObservableSubject()
const value$ = useMemo(() => input$.pipe(map((value) => Number(value))), [input$])
const value = useSyncObservable(value$, 1)
// handleChange(event.currentTarget.value)} />
```
Handlers that only forwarded into an existing `Subject` become a plain `next` call; side-effect-only pipelines (analytics, persistence) subscribe the observable returned by `useObservableSubject` in an effect. The [v6 to v7 guide](/migrate/v6-to-v7#useobservableevent-is-removed) walks through each shape, and [Handling events](https://react-rx-git-next.sanity.dev/guide#handling-events) in the v7 guide covers the recommended patterns. The observable, handler, and tuple returned by `useObservableSubject` are stable across re-renders.
## 5. Optional: Suspense data with `useObservablePromise`
New since v5.1 and not something you have to adopt. `useObservablePromise` returns a `use()`-compatible promise that suspends until the first emission, for observables with no meaningful initial value. Two rules matter:
- Pass the promise to a child that reads it with `use()`, with a `` boundary between the hook caller and that child. The fetch starts when the hook caller commits, so `use(useObservablePromise(obs$))` in one component deadlocks.
- Swapping observables inside `startTransition` or behind `useDeferredValue` works without preloading, and `preloadObservablePromise` warms the cache from event handlers and route loaders.
```tsx
function Users() {
const promise = useObservablePromise(users$)
return (
Loading users…}>
)
}
function UsersList({promise}: {promise: Promise}) {
const users = use(promise)
return (
{users.map((user) => (
{user.name}
))}
)
}
```
See the [Suspense data fetching](/examples/data-fetching) and [Activity and preload](/examples/activity) examples, and the [Transitions and refetching](https://react-rx-git-next.sanity.dev/examples/transitions) example in the v7 docs.
## Checklist
1. React `^19.2`, Node `>=22.12`, ESM imports, operators from `'rxjs'`.
2. Every `useObservable` call passes an `initialValue`.
3. Controlled inputs and same-event reads use `useSyncObservable`; hand-rolled `useDeferredValue` wrappers are gone.
4. Observables built inside components are memoized.
5. No `useObservableEvent` imports remain.
6. Tests that asserted a synchronous first paint now expect the `initialValue` first.
---
title: v6 to v7
url: https://react-rx.dev/migrate/v6-to-v7
---
# Migrating from v6 to v7
> v7 is in prerelease. Install it with `npm install react-rx@next`. The stable release is v6.
v7 has three breaking changes, all pushing in the same direction — subscriptions are strictly commit-driven, and event streams are created separately from the pipelines that consume them:
1. [`initialValue` is now required](#initialvalue-is-now-required) in `useObservable` and `useSyncObservable`.
2. [`useObservablePromise` fetches start at commit](#useobservablepromise-fetches-start-at-commit), never during render.
3. [`useObservableEvent` is removed](#useobservableevent-is-removed).
Requirements are unchanged from v5: React `^19.2`, RxJS `^7.2`, Node `>=22.12`, ESM-only.
## initialValue is now required
The initial value is what renders until the observable emits. Omitting the argument is now a type error, and since JavaScript callers can bypass the types, the hooks also throw during render when it is missing.
```tsx
// Before (v6) — implicitly undefined until the first emission
const users = useObservable(users$)
// After (v7) — pass the initial value explicitly
const users = useObservable(users$, undefined)
```
Every value is valid, `undefined` included — it just has to be passed explicitly. Functions act as initializers, exactly like `useState`: `useObservable(value$, () => expensiveInitial())` computes lazily, and to use a function itself as the initial value, pass an initializer that returns it.
Call sites that already pass an `initialValue` are unaffected, including their runtime behavior.
### What you may notice
- **No render-phase warm-up on mount.** Without an `initialValue`, v6 briefly subscribed during render so a synchronous emission (`of`, `startWith`, a `BehaviorSubject`, …) could win the very first paint. In v7 the observable is never subscribed during render for its first paint: the `initialValue` renders first and the synchronous emission replaces it right after mount. If an observable has no initial value that makes sense — you want fallback UI while it "loads" — that is `useObservablePromise` with `use()` and Suspense.
- **`disabled: true` now always guarantees zero subscriptions.** Previously the warm-up probe still subscribed once when no `initialValue` was given.
- **Server rendering is uniform.** Both hooks render the resolved `initialValue` and never subscribe the observable on the server. `useSyncObservable` can no longer hit React's "Missing getServerSnapshot" error, and non-deterministic synchronous emissions can no longer cause hydration mismatches.
## useObservablePromise fetches start at commit
The hook no longer subscribes the source observable during mounting renders. Previously any render triggered fetching as a side effect, including hidden `` pre-renders. Fetching now has three triggers: a non-`disabled` component that called the hook **commits**; an already-**live** consumer re-renders with a **new observable** (`startTransition` / `useDeferredValue` swaps; the swap render starts the new source so the suspended transition can settle and commit); or `preloadObservablePromise` is called.
What to change:
- **`use(useObservablePromise(obs$))` in a single component now deadlocks** — the component suspends on its own pending promise before the commit that would start the fetch. This was never a supported pattern and is intentionally not guarded against. Pass the promise as a prop to a child that reads it with `use()`, with a `` boundary **between** the hook caller and that child, so the caller can commit while the child suspends:
```tsx
// Broken in v7 — suspends before the commit that would start the fetch
function Users() {
const users = use(useObservablePromise(users$))
return
{JSON.stringify(users, null, 2)}
}
// Works — the hook caller commits, the child suspends
function Users() {
const promise = useObservablePromise(users$)
return (
Loading users…}>
)
}
function UsersList({promise}: {promise: Promise}) {
return
{JSON.stringify(use(promise), null, 2)}
}
```
- **Hidden `` trees no longer fetch.** A hidden tree that calls the hook is fully paused — no subscription until it is revealed and effects mount. To pre-render hidden content _with_ data, call the hook in a visible parent and pass the promise into the hidden tree, where `use(promise)` lets React pre-render and suspend on its own terms. See the [Activity and preload example](/examples/activity).
- **Synchronously-emitting sources show one fallback pass on a cold mount** (`of`, `BehaviorSubject`, replayed `shareReplay`): they now resolve at the hook caller's commit instead of during render. Preload the observable to render them without a fallback.
- **Swapping observables inside `startTransition` / behind `useDeferredValue` needs no preloading.** A consumer that is already live starts the swapped-in source during the transition render itself. This is React's [client-side refetch pattern](https://react.dev/reference/react/use#re-fetching-data-in-client-components): previous content stays visible while the new data loads, and the swap commits when it settles. `preloadObservablePromise` remains useful for warming ahead of the swap, for example on hover, so the transition can commit with no pending period. See the [Transitions and refetching example](/examples/transitions).
- **On the server, `preloadObservablePromise` is a no-op** (an inert pending promise; no subscription, no cache entry) — react-rx never subscribes observables on the server. Server rendering emits the Suspense fallback and the fetch starts after hydration. For React Server Components or server-only flows, fetch with async/await or RxJS [`firstValueFrom`](https://rxjs.dev/api/index/function/firstValueFrom) and pass the promise or value down as a prop.
## useObservableEvent is removed
`useObservableEvent` created an RxJS `Subject` internally, returned a stable callback that pushed into it, and subscribed the pipeline you returned in an effect. `useObservableSubject` exposes the stream and callback directly, so you decide where the output is consumed instead of hiding the subscription inside the event hook.
The removal also drops the hook's `use-effect-event` dependency, leaving `react-rx` with no runtime dependencies beyond its `react` and `rxjs` peers.
To migrate, replace `useObservableEvent` with `useObservableSubject`, then move the pipeline to where its output is consumed. The new hook is available from v6.1 and carries over to v7, so you can migrate on v6 first and upgrade later:
```tsx
// Before (v6)
const handleSave = useObservableEvent((term$) => term$.pipe(concatMap((term) => saveSearch(term))))
// After (v6.1+ or v7) — the pipeline moves into an effect
const [saves$, handleSave] = useObservableSubject()
useEffect(() => {
const subscription = saves$.pipe(concatMap((term) => saveSearch(term))).subscribe()
return () => subscription.unsubscribe()
}, [saves$])
```
### Pipelines that updated state
Most `useObservableEvent` pipelines ended in `tap(setState)`. Drop the local state mirror entirely — the derived stream _is_ the state, read it with `useObservable` (or `useSyncObservable` when it feeds a controlled input):
```tsx
// Before (v6)
const [value, setValue] = useState(1)
const handleChange = useObservableEvent((value$) =>
value$.pipe(
map((value) => Number(value)),
tap(setValue),
),
)
// handleChange(event.currentTarget.value)} />
// After (v7)
const [sliderInput$, handleChange] = useObservableSubject()
const value$ = useMemo(() => sliderInput$.pipe(map((value) => Number(value))), [sliderInput$])
const value = useSyncObservable(value$, 1)
// handleChange(event.currentTarget.value)} />
```
### Pipelines that only forwarded into a Subject
A very common shape was a handler whose pipeline just pushed into an existing `Subject`. Call `next` directly instead:
```tsx
// Before (v6)
const handleChange = useObservableEvent((events$: Observable>) =>
events$.pipe(
map((e) => e.currentTarget.value),
tap((value) => text$.next(value)),
),
)
//
// After (v7)
// text$.next(event.currentTarget.value)} />
```
### Side-effect-only pipelines
Pipelines with no rendered output (analytics, persistence, …) subscribe in an effect; the handler stays a plain `next` call:
```tsx
// Before (v6)
const handleSave = useObservableEvent((term$) => term$.pipe(concatMap((term) => saveSearch(term))))
// After (v6.1+ or v7)
const [saves$, handleSave] = useObservableSubject()
useEffect(() => {
const subscription = saves$.pipe(concatMap((term) => saveSearch(term))).subscribe()
return () => subscription.unsubscribe()
}, [saves$])
```
### Semantics to be aware of
- `useObservableEvent` subscribed its pipeline in an effect after mount, so events fired before that were dropped. `useObservableSubject` also uses a non-replaying `Subject`: values pushed while nothing is subscribed are dropped.
- Both hooks return a referentially stable handler. The observable and tuple returned by `useObservableSubject` are stable across re-renders too.
See [Handling events](https://react-rx-git-next.sanity.dev/guide#handling-events) in the v7 guide for the full set of recommended patterns, including `useObservablePromise` for event-driven Suspense data.
---
title: v5 to v6
url: https://react-rx.dev/migrate/v5-to-v6
---
# Migrating from v5 to v6
v6 has a single breaking change: `useObservable` and `useSyncObservable` no longer subscribe the observable during render when an `initialValue` is provided. Requirements are unchanged from v5: React `^19.2`, RxJS `^7.2`, Node `>=22.12`, ESM-only.
## Sync emissions no longer win the first paint
In v5, both hooks ran a warm-up subscription during render so that a synchronously emitting source (`of`, `startWith`, a `BehaviorSubject`, a replayed `shareReplay`) could render its value on the very first paint, even when you had also passed an `initialValue`. That warm-up only exists to have something to show before the first emission. With an `initialValue` there already is something to show, so v6 skips it and subscribes on commit instead.
The observable behavior is the same. What changes is which value the first render shows when the two disagree:
```tsx
const count$ = new BehaviorSubject(5)
const count = useObservable(count$, 0)
// v5: renders 5 on the first paint (the warm-up saw the sync emission)
// v6: renders 0 on the first paint, then 5 right after mount
```
This applies to server rendering too. In v5 the SSR markup could contain the sync emission; in v6 the server always renders the `initialValue`, which is also exactly what the client's first paint shows, so hydration is deterministic.
### Who is affected
- Tests that assert the value of the first render, or SSR snapshot tests, for observables that emit synchronously and are paired with an `initialValue`.
- UI that relied on a sync emission being visible before mount, for example a `BehaviorSubject` whose current value was assumed to paint immediately.
- Call sites that pass a `startWith(x)` source together with an unrelated `initialValue`. Those now render the `initialValue` for one pass before `x` arrives.
### What to do
Pass the value you want on the first paint as the `initialValue`. For a `BehaviorSubject` that is its current value; for a `startWith` pipeline it is the same constant:
```tsx
// Before (v5) — relied on the warm-up to paint 5 first
const count = useObservable(count$, 0)
// After (v6) — say what the first paint should be
const count = useObservable(count$, () => count$.getValue())
```
Calls that omit `initialValue` still get the v5 warm-up in v6, so they behave exactly as before. Do not lean on that: v7 makes `initialValue` required, so passing the real initial value now is the forward-compatible move.
## Other effects of the change
- `disabled: true` now guarantees zero subscriptions, even when the observable is rebuilt on every render. In v5 the warm-up probe could still subscribe once.
- Subscribe-time side effects, such as a `fromFetch` request, stay out of the render phase whenever an `initialValue` is present.
- Once the hook has received an emission, a replacement observable on a later render is still warmed during render in v6, so components that rebuild the observable every render settle instead of looping. v7 removes this too and requires stable identities; see [Migrating from v6 to v7](/migrate/v6-to-v7).
## Next step
If you are upgrading past v6, continue with [Migrating from v6 to v7](/migrate/v6-to-v7). Coming from v4, the cumulative [v4 to v7 guide](/migrate/v4-to-v7) covers the whole path in one place.
---
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 (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`.
### 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 (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](/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
Hello, {data}!
}
```
## Counter
```tsx filename="App.tsx"
import {useObservable} from 'react-rx'
import {timer} from 'rxjs'
const observable = timer(0, 1000)
export default function App() {
const seconds = useObservable(observable, 0)
return <>Seconds: {seconds}>
}
```
---
title: Event handlers
url: https://react-rx.dev/examples/event-handlers
---
```tsx filename="App.tsx"
import {MouseEvent, useMemo} from 'react'
import {useObservable} from 'react-rx'
import {map, startWith, Subject} from 'rxjs'
// Create subject for mouse moves
const mouseMove$ = new Subject()
function EventHandlersExample() {
// Create mouse position stream
const position$ = useMemo(
() =>
mouseMove$.pipe(
map((event) => ({
x: event.clientX,
y: event.clientY,
})),
startWith(null),
),
[],
)
const position = useObservable(position$, null)
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).
Deferred store updates. Previous results
stay on screen (dimmed while stale) until
the new ones are ready — no fallback
flash.
Loading results…}
>
)
}
export default function App() {
// Controlled input value must update synchronously.
const keyword = useSyncObservable(keyword$, '')
return (
keyword$.next(event.currentTarget.value)
}
/>
)
}
```
## 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 promises in a parent that does
// not suspend: the fetch starts when the hook
// caller commits (a component suspended on
// its own promise never commits), and
// Suspense retries always see the same
// promise identity.
const promise = useObservablePromise(
fetchUser$(id),
)
const clockPromise =
useObservablePromise(clock$)
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** — the fetch starts when the visible tab's owner commits; the Suspense fallback shows
2. **Hover preload** — `preloadObservablePromise` on `mouseenter` warms the cache before the click
3. **Activity pre-render** — an always-visible wrapper owns `useObservablePromise` for every tab and passes each promise into a hidden `` panel, which pre-renders with the data as it arrives
Rendering never starts a fetch: a hook called inside a hidden `` tree stays paused until the tree is revealed. Pre-rendering hidden content _with_ data therefore means owning the promise in a visible component and letting the hidden tree `use()` it.
```tsx filename="App.tsx"
import {
Activity,
Suspense,
useMemo,
useState,
} from 'react'
import {
preloadObservablePromise,
useObservablePromise,
} from 'react-rx'
import {fetchTab$} from './api'
import TabPanel from './TabPanel'
type Strategy = 'none' | 'preload' | 'activity'
const TABS = [
'Posts',
'Photos',
'Settings',
] as const
function Spinner() {
return
🌀 Loading…
}
/**
* The hook caller renders the Suspense
* boundary below itself: it commits even while
* the panel suspends, and that commit is what
* starts the fetch.
*/
function ActiveTab({tab}: {tab: string}) {
const data$ = useMemo(
() => fetchTab$(tab),
[tab],
)
const promise = useObservablePromise(data$)
return (
}>
)
}
/**
* Activity pre-render: this always-visible
* wrapper owns the fetch and hands the promise
* into the hidden tree. React pre-renders the
* hidden panel in the background, suspending on
* the promise until the data arrives. (A hook
* called *inside* a hidden tree stays paused —
* rendering never starts a fetch.)
*/
function PrerenderedTab({
tab,
active,
}: {
tab: (typeof TABS)[number]
active: boolean
}) {
const data$ = useMemo(
() => fetchTab$(tab),
[tab],
)
const promise = useObservablePromise(data$)
return (
}>
)
}
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.
)
}
```
```tsx filename="TabPanel.tsx"
import {use} from 'react'
import {type ObservablePromise} from 'react-rx'
export default function TabPanel({
promise,
}: {
promise: ObservablePromise<{
tab: string
body: string
}>
}) {
const data = use(promise)
return (
{data.tab}
{data.body}
)
}
```
```ts filename="api.ts"
import {map, type Observable, timer} from 'rxjs'
const LATENCY_MS = 1000
const cache = new Map<
string,
Observable<{tab: string; body: string}>
>()
/** Cold fetch-like source with visible artificial latency. Stable per tab id. */
export function fetchTab$(
tab: string,
): Observable<{tab: string; body: string}> {
let observable = cache.get(tab)
if (!observable) {
observable = timer(LATENCY_MS).pipe(
map(() => ({
tab,
body: `Content for “${tab}” loaded after ${LATENCY_MS}ms`,
})),
)
cache.set(tab, observable)
}
return observable
}
```
---
title: Transitions and refetching
url: https://react-rx.dev/examples/transitions
---
# Transitions and refetching
Swapping which observable a live component renders follows React's [client-side refetch pattern](https://react.dev/reference/react/use#re-fetching-data-in-client-components): change the data source inside `startTransition` or behind `useDeferredValue`, and the previous content stays visible until the new data is ready. No Suspense fallback.
A suspended transition render never commits, and commit is otherwise what starts a fetch. So for exactly this case `useObservablePromise` starts the swapped-in source during the transition render itself. A consumer that is already **live**, meaning committed, visible, and subscribed, re-rendering with a new observable identity starts the fetch; the suspended transition settles and the swap commits. Mounts, server rendering, `disabled` consumers, and hidden `` pre-renders have no live subscription and stay fully lazy. Rendering alone still never fetches for them.
Try it:
1. Click **Grace**. The pending timer runs for about 1.5s while Ada stays on screen, then the swap commits. No preloading, no fallback flash.
2. Click **Ada** again. Settled observables are retained (`ttl: 60_000` here), so swapping back commits instantly.
3. Click **Reset demo**. A fresh mount is not a swap: the initial fetch starts at the hook caller's commit while the Suspense fallback shows.
```tsx filename="App.tsx"
import {
Suspense,
useEffect,
useState,
useTransition,
} from 'react'
import {useObservablePromise} from 'react-rx'
import {
fetchProfile$,
resetProfileCache,
} from './api'
import ProfileCard from './ProfileCard'
const NAMES = ['Ada', 'Grace', 'Alan'] as const
function Spinner() {
return
🌀 Loading…
}
/**
* Mounted only while a transition is pending — the window where the
* swapped-in profile is still fetching and the previous one stays visible.
*/
function PendingTimer() {
const [elapsed, setElapsed] = useState(0)
useEffect(() => {
const startedAt = Date.now()
const id = setInterval(() => {
setElapsed(Date.now() - startedAt)
}, 100)
return () => clearInterval(id)
}, [])
return (
<>
⏳ transition pending for{' '}
{(elapsed / 1000).toFixed(1)}s…
>
)
}
function TransitionStatus({
pending,
}: {
pending: boolean
}) {
return (
)
}
function ProfileSwitcher() {
const [name, setName] =
useState<(typeof NAMES)[number]>('Ada')
const [isPending, startTransition] =
useTransition()
// The Map-stable identity is what routes every render to the same cache
// entry; the long ttl keeps settled profiles retained for the whole demo
// session so swapping back commits instantly.
const promise = useObservablePromise(
fetchProfile$(name),
{ttl: 60_000},
)
return (
<>
Each profile fetch takes ~1.5s. Switching
profiles inside a transition keeps the
previous profile visible while the next
one loads — only the initial mount shows
the Suspense fallback.
)
}
```
```tsx filename="ProfileCard.tsx"
import {use} from 'react'
import {type ObservablePromise} from 'react-rx'
import {type Profile} from './api'
export default function ProfileCard({
promise,
}: {
promise: ObservablePromise
}) {
const profile = use(promise)
return (
{profile.name}
{profile.bio}
)
}
```
```ts filename="api.ts"
import {map, type Observable, timer} from 'rxjs'
const LATENCY_MS = 1500
export interface Profile {
name: string
bio: string
}
const BIOS: Record = {
Ada: 'Wrote the first published algorithm — a century before hardware could run it.',
Grace:
'Coined “debugging” after evicting an actual moth from a relay.',
Alan: 'Asked whether machines can think, then built one that helped answer it.',
}
/**
* One cold, fetch-like observable per profile, with visible latency.
*
* The Map matters: react-rx keys its promise cache by observable identity,
* so every render asking for the same profile must get the SAME instance.
* A factory that built a fresh observable per call would give each render
* its own never-shared cache entry.
*/
let cache = new Map>()
export function fetchProfile$(
name: string,
): Observable {
let profile$ = cache.get(name)
if (!profile$) {
profile$ = timer(LATENCY_MS).pipe(
map(() => ({
name,
bio:
BIOS[name] ??
`Profile for “${name}” loaded after ${LATENCY_MS}ms.`,
})),
)
cache.set(name, profile$)
}
return profile$
}
/** Forget every profile so the next run of the demo starts cold. */
export function resetProfileCache(): void {
cache = new Map()
}
```
## The rules that still apply
- **Identities must be stable.** react-rx keys its promise cache by observable identity, so every render asking for the same data must receive the same instance. That is what the `Map` in `fetchProfile$` is for. A factory creating a fresh observable per call gives every render its own entry.
- **The `` boundary sits between the hook caller and the `use()` reader.** The hook caller must be able to commit, or already be live. `use()`-ing the hook's own promise in the same component still deadlocks, exactly like `use()`-ing a promise created during your own render.
- **Preloading is now an optimization, not a requirement.** `preloadObservablePromise` on hover or in a route loader means the swap target can already be in flight, or even settled, by the time the transition renders. That shortens or removes the pending period. See [Activity and preload](/examples/activity).
- **An abandoned transition may have started a fetch nobody consumes.** It settles into the shared cache and stays reusable within `ttl`. Cap never-settling sources with RxJS [`timeout`](https://rxjs.dev/api/index/function/timeout), just as you would for preloads.
---
title: Fetch
url: https://react-rx.dev/examples/fetch
---
```tsx filename="App.tsx"
import {useMemo} from 'react'
import {useObservable} from 'react-rx'
import {
distinctUntilChanged,
map,
Subject,
switchMap,
} from 'rxjs'
// Create subject for URL changes
const url$ = new Subject()
const origin = new URL('https://react-rx.sanity.dev')
const URLS = [
new URL('/fetch/a.txt', origin),
new URL('/fetch/b.txt', origin),
]
function FetchExample() {
// Create fetch response stream
const response$ = useMemo(
() =>
url$.pipe(
distinctUntilChanged(),
switchMap((url) =>
fetch(url).then((response) =>
response.text(),
),
),
map((responseText) => (