Getting Started
Installation
npm i react-rx rxjsObservable Hooks
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
Example:
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 its omitted, the value returned from useObservable may be null 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:
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 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. useObservable always briefly subscribes 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.
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<string>())
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:
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 <pre>{JSON.stringify(users, null, 2)}</pre>
}Because the fetch observable is only created (and therefore only ever subscribed) when shouldFetch is true, this guarantees zero subscriptions to fromFetch until then.
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 boundary, and React 19.2 Activity 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:
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 (
<Suspense fallback={<p>Loading users…</p>}>
<UsersList promise={promise} />
</Suspense>
)
}
function UsersList({promise}: {promise: Promise<unknown>}) {
const users = use(promise)
return <pre>{JSON.stringify(users, null, 2)}</pre>
}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):
function UsersList({users$}) {
// `users$` must be referentially stable for the in-flight request
const users = use(useObservablePromise(users$))
return <pre>{JSON.stringify(users, null, 2)}</pre>
}Semantics
- Suspends until the observable’s first emission (
firstValueFromsemantics). - Later emissions update the UI without re-showing the Suspense fallback.
- Sync sources (
of,BehaviorSubject, replayedshareReplay) never flash a fallback. - Errors reject the promise and surface through the nearest Error Boundary. Prefer
catchErroron the inner observable when you want graceful degradation instead of a boundary. - Completing without emitting rejects with RxJS
EmptyError.
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
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 <Activity> tree longer than ttl never drops what it already rendered.
Preloading
Warm the cache outside of render (hover, route loaders) with preloadObservablePromise:
import {preloadObservablePromise, useObservablePromise} from 'react-rx'
function TabButton({users$, onSelect}) {
return (
<button
type="button"
onMouseEnter={() => preloadObservablePromise(users$, {ttl: 5_000})}
onClick={onSelect}
>
Users
</button>
)
}Which hook when?
| Need | Hook |
|---|---|
Live values, timers, subjects, optional initialValue | useObservable |
| 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:
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 (
<>
<input
type="range"
value={value}
onChange={(event) => handleChange(event.target.value)}
min={1}
max={10}
/>
<div>Value is: {value}</div>
</>
)
}
function nonNullable<T>(v: T): v is NonNullable<T> {
return v != null
}