DEV Community

Mike Wu
Mike Wu

Posted on • Edited on

RxJS in a Single React Component (No Store/Redux)

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}
}

Enter fullscreen mode Exit fullscreen mode

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,
  }
}
Enter fullscreen mode Exit fullscreen mode

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) {
  // ...
}
Enter fullscreen mode Exit fullscreen mode

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])
}
Enter fullscreen mode Exit fullscreen mode
  • We wrap subject in a useMemo to avoid React creating new Subject on every render.
  • useEffect to handle when numCredits 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])
Enter fullscreen mode Exit fullscreen mode
  • Set in a useEffect to avoid subscribing on every render.
  • Use tap for side-effects
  • debounceTime(1000) - The debounce we needed!
  • switchMap() - returning an ajax 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 via setPrice

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])
Enter fullscreen mode Exit fullscreen mode
  • Introduce a ready flag to know when to start sending values
  • Set ready to true only after pipeline is set.

Top comments (0)