After you've got the basics of reactive programming down, the next question is usually 'ok great, so how do I use this thing?'. A quick search for using RxJS with React usually winds up at one of the following solutions:
A. Use Redux with redux-observable
middleware.
B. Write your own store that's very similar to redux, but powered by RxJS.
While both are valid solutions, they don't really help if you're only looking to use RxJS in a single component/hook. You don't want a global store!
TL;DR
If you just want to see the hook, and an example here it is.
useObserve()
import {useEffect, useMemo, useState} from 'react'
import {Subject} from 'rxjs'
export function useObserve<T>(value: T) {
const [ready, setReady] = useState(false)
const subject = useMemo(() => new Subject<T>(), [])
useEffect(() => {
if (!ready) {
return
}
subject.next(value)
}, [value, ready, subject])
const onReady = useMemo(() => {
return ready ? null : () => setReady(true)
}, [ready])
return {value$: subject, onReady}
}
And here is an example of it in action:
export function usePriceForCredits(numCredits: number) {
const [loading, setLoading] = useState(true)
const [price, setPrice] = useState<number | null>(null)
const {value$, onReady} = useObserve(numCredits)
useEffect(() => {
if (!onReady) {
return
}
value$
.pipe(
tap(() => {
setLoading(true)
setPrice(null)
}),
debounceTime(1000),
switchMap((numCredits: number) => {
const url = api(`/price_for_credits?num_credits=${numCredits}`)
const request = ajax.get(url, {
'Content-Type': 'application/json', // Avoid rxjs from serializing data into [object, object]
})
return request
}),
map((res) => res.response.price),
tap(() => {
setLoading(false)
}),
)
.subscribe({
next: setPrice,
})
onReady()
}, [value$, onReady, token])
return {
loading: loading,
price: price,
}
}
Breaking It Down
If you're curious about how I got to the above solution, let's keep going.
I'll be creating a custom hook that calculates the price given a number of credits:
- The number of credits is updated via a slider.
- If we fetched the price on every change we'd be sending way too many requests.
- Want to debounce sending requests so we only send once after the user has stopped sliding.
A perfect case for some rx!
Creating the Observable
Here's our hook:
export function usePriceForCredits(numCredits: number) {
// ...
}
We want to observer whenever numCredits
changes. Let's manually send updated values whenever it changes.
Side note: redux-observable
also uses Subject
under the hood.
function usePriceForCredits(numCredits: number) {
const subject = useMemo(() => new Subject<number>(), [])
useEffect(() => {
if(!subject) {
return
}
subject.next(numCredits)
}, [numCredits, subject])
}
- We wrap subject in a
useMemo
to avoid React creating newSubject
on every render. -
useEffect
to handle whennumCredits
changes. -
subject.next()
sends a new value to the subject.
Writing the pipeline
Now on to the fun part! With our new observable (subject) we can write the actual pipeline that does the work.
const [price, setPrice] = useState<number | null>(null)
useEffect(() => {
subject
.pipe(
tap(() => {
setPrice(null)
}),
debounceTime(1000),
switchMap((numCredits: number) => {
const url = api(`/price_for_credits?num_credits=${numCredits}`)
const request = ajax.get(url, {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json', // Avoid rxjs from serializing data into [object, object]
})
return request
}),
map((res) => res.response.price),
)
.subscribe({
next: setPrice,
})
}, [subject, token])
- Set in a
useEffect
to avoid subscribing on every render. - Use
tap
for side-effects -
debounceTime(1000)
- The debounce we needed! -
switchMap()
- returning anajax
observable that'll automatically cancel requests for us. - Finally,
.subscribe({next: ...})
to kick off the subscription. In this example we're just setting the value viasetPrice
A Bug!
Eagle-eyed readers might have spotted it, but there's actually a race-condition in the code above. The initial value is sent before the subscription is ready! This results in us always missing the first value.
In this example we'll need to fetch the price for the initial number of credits to so users don't start with a 0 price.
const [ready, setReady] = useState(false)
useEffect(() => {
if (!ready) {
return
}
subject.next(numCredits)
}, [numCredits, subject, ready])
useEffect(() => {
if (ready) {
return
}
subject
.pipe(
//... same as above
)
.subscribe(
//... same as above
)
setReady(true)
}, [subject, token])
- Introduce a
ready
flag to know when to start sending values - Set
ready
totrue
only after pipeline is set.
Top comments (0)