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:
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!

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;
});
}
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)
);
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;
};
};
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.
The strength of the Observable is you can bundle cleanup/cancelation logic right at the definition of the Effect. For this cancelation, we should cancel the Image loading by switching the src
property to an image that doesn't need downloading. The DOM would then cancel itself..
So we simply return a cancelation function from the Observable constructor:
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==";
};
};
Now, even when cancelation occurs during image bytes downloading, the Observable teardown can stop it mid-request! Cool and performant!
Other Subscribers - Analytics
Now, a new Feature Request arrives that requires we log clicks to an Analytics Service whenever the Fetch Cat button is pressed, so marketing can assess how engaging the app is.
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, with a cancelable item called a Subscription:
const analyticsSub = gifService.observe({
request(){ logAnalytics('fetch cat clicked') }
})
// to turn off
// analyticsSub.unsubscribe();
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(
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 timeoutHandler
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)
);
Doing it this way, our currentError
property of the service will display information about the timeout, just like any other error!
Concurrency Control
We just 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)
)
}
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.
Top comments (0)