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
useMemoto avoid React creating newSubjecton every render. -
useEffectto handle whennumCreditschanges. -
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
useEffectto avoid subscribing on every render. - Use
tapfor side-effects -
debounceTime(1000)- The debounce we needed! -
switchMap()- returning anajaxobservable 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
readyflag to know when to start sending values - Set
readytotrueonly after pipeline is set.
Top comments (0)