Excellent Effect Management in React with š—„š˜…š‘“š‘„ and RxJS

deanius

Dean Radcliffe

Posted on March 17, 2023

Excellent Effect Management in React with š—„š˜…š‘“š‘„ and RxJS

In Part 1, I'd shared an interview that opened my mind to how, and why, to use š—„š˜…š‘“š‘„ in React.

In this final installment part we take the UX of the Cat Fetcher to the extreme by adding these features:

  • Preload images as a chained part of the gifService.
  • Cancel an image preload when canceled.
  • Send out Analytics events, without coupling to existing code.
  • Apply a timeout to the Ajax load and overall load.
  • Pad the loading spinner to a minimum duration.

We'll even build a cancelable image preloader along the way. So let's dive right in!


Chained Loading Of the Image Bytes

There was an issue with our service. isActive would become false at the time where we knew the URL of the cat image- but didn't yet have its bytes:

loading indicator analysis

This led to the loading indicator turning off, and the UI looks like it's doing nothing - until the image bytes arrive. And that image could take a while to load, if over a slow pipe!

template with loading state, with delay

Image Preloading

This old trick always worked to preload an image:

function preloadImage(url) {
  return new Promise((resolve) => {
    const img = new Image();
    img.onload = () => resolve(url);
    img.src = url;
  });
}
Enter fullscreen mode Exit fullscreen mode

This is a Promise-returning function that resolves (with the img url) only once the image has loaded. Perfect! But how would we chain/compose that with the Observable? Simple - one line, like this:

  return
     ajax.getJSON("https://api.thecatapi.com/v1/images/search", {...})
     .pipe(
        map((data) => data[0].url),
+        mergeMap(preloadImage)
     );
Enter fullscreen mode Exit fullscreen mode

Perfect! We've used the fact that a function which returns Promise<string> can be composed onto an Observable with mergeMap - because a Promise<string> is a subset of an ObservableInput<string>. That's all we needed.

But for comparison purposes, and to get ready for cancelation, let's return an Observable instead:

function preloadImage(url) {
  return new Observable((notify) => {
    const img = new Image();
    img.onload = () => {
      notify.next(url);
      notify.complete();
    };
    img.src = url;
  };
};
Enter fullscreen mode Exit fullscreen mode

So we change our Promise-returning function into an Observable-returning one - sending out a single next notification (like a Promise's singular resolution) - followed by a single complete notification. Now we're ready for cancelation.

Cancelation

This chaining, or 'composition', is convenient, but not yet optimal. If a cancelation occurs while the image bytes are loading - the loading of the image itself is not canceled.

not canceled

The strength of the Observable is you can define one with any arbitrary cleanup/cancelation logic. For example, we started loading the gif into the DOM when we set the src property of an Image element. We could cancel the DOM's loading by switching the src property to an image that doesn't need downloading. The DOM would then cancel itself..

Lastly return a cancelation function:

function preloadImage(url) {
  return new Observable((notify) => {
    const img = new Image();
    img.onload = () => {
      notify.next(url);
      notify.complete();
    };
    img.src = url;
+
+   return () => img.src = "data:image/gif;base64,R0lGOD...Ow==";
  };
};
Enter fullscreen mode Exit fullscreen mode

Now, even when cancelation occurs during image bytes downloading, the Observable teardown can stop it mid-request! Cool and performant!

Cancelable Image Download

Other Subscribers - Analytics

Now, a request arrives that we log clicks to the Analytics Service whenever the Fetch Cat button is pressed.

You might be wondering now whether the UI onClick handler, or the gifService Observable ought to change. š—„š˜…š‘“š‘„ says - change neither, they're done already!

Handle it by observing the service's requests, and fiting off there:

const analyticsSender = gifService.observe({
  request(){ logAnalytics('fetch cat clicked') }
})
// to turn off
// analyticsSender.unsubscribe();
Enter fullscreen mode Exit fullscreen mode

For light-weight fire-and-forget functions you don't need to chain or concurrency-control, this mechanism will decouple sections of your codebase, and allow you to keep the code intact (and tests!) of existing components.

Timeouts

Users don't want to wait forever without feedback, and even a spinner gets old. In this post, I set out some thresholds that are handy to reference in timing constants - they are published in the @rxfx/perception library. Whatever values we choose, we need to pass them into code somewhere, and there are a few places this may happen.

For the AJAX to get the URL of the next cat image, we can specify a timeout directly in its options:

function fetchRandomGIF() {
  return ajax({
    url: "https://api.thecatapi.com/v1/images/search",
+    timeout: TIMEOUTS.URL
  }).pipe(
Enter fullscreen mode Exit fullscreen mode

The gifService will trigger a gif/error to the bus if it fails to get the url within that timeout.

But we must ask if overall our gif/request handler might exceed the user's patience. For that, we can wrap the handler in a withTimeout modifier from @rxfx/service.

export const gifService = createQueueingService(
  "gif", // namespace for actions requested,started,next,complete,error,etc
  bus, // bus to read consequences and requests from
-  fetchRandomGIF,
+  timeoutHandler({ duration: TIMEOUTS.OVERALL }, fetchRandomGIF),
  (ACTIONS) => gifReducer(ACTIONS) // the reducer to aggregate non-transient state
);

Enter fullscreen mode Exit fullscreen mode

This way, our gif/error bus message and currentError property of the service will contain information about the timeout.

Fine-Tune Timing

So we handled what happens when the connection is too slow - and we ensured that users get feedback rather than wait forever. But can it ever trouble users if their connection is too fast? Imagine - a user performs 3 quick clicks to queue up 3 kitty downloads - and they could be displayed, and gone before they have a chance to be admired if they download too fast. Here we can pad the download with just another RxJS operator:

function fetchRandomGIF() {
  return ajax({
    url: "https://api.thecatapi.com/v1/images/search",
    timeout: TIMEOUTS.URL
  }).pipe(
    mergeMap(preloadImage),
+   padToTime(TIMEOUTS.KITTY_MINIMUM)
  )
}
Enter fullscreen mode Exit fullscreen mode

While we may decide this amount of padding isn't necessary in every app, for these cute kitties it's probably worth it šŸ˜€ šŸˆ The lesson, of course, is that any RxJS or š—„š˜…š‘“š‘„ operator can be used to modify timing with usually no change to surrounding code - whether it's for timeout or time padding. This lets our UX be more intentional in its experience, and less vulnerable to random network conditions.


Conclusion

If there's one thing this code example showed, it's that there's never anything as simple as 'async data fetching'. Timing, timeouts, cancelation, and chained and related effects are requirements that swiftly come on the heels of making a simple fetch. Excellence in UX depends upon handling these 'edge cases' in the very core of your product.

š—„š˜…š‘“š‘„ has the features you need so that the app can scale in functionality without ballooning in complexity.

šŸ’– šŸ’Ŗ šŸ™… šŸš©
deanius
Dean Radcliffe

Posted on March 17, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related