Edit: Here is part 2 of this series.
Note: Some images refer to the library's old name "Omnibus-RxJS" and have not been updated to π₯π ππ₯.
An Interview Like No Other
It was a front-end React UI developer interview which began like any other. But unlike others, by the end of it I'd learned enough to change my whole approach to data-fetching, or maybe to async programming in general.
I was the interviewer for a 60 minutes live-coding interview. The goal was to build a simple React GUI that could fetch a random GIF. We assumed any candidate who reached this round could do async data-fetching in React. But we wanted to learn how a candidate thought about front-end problems in general. We were looking for some insight that we didn't already have. And in this candidateβ call them Chris - we found it :)
I handed Chris the paper with the requirements for their live-coding interview.
Getting Acquainted
The requirements contained an image of what was to be built in the interview.
Chris asked a few questions:
"Does it have to be cats, or will any GIF do?"
"Ha!" I said. "You can choose any GIF you want, but we've provided a function to get cats."
const fetchRandomGif = () =>
fetch("https://api.thecatapi.com/v1/images/search", {
headers: { "x-api-key": "blah-blah" },
})
.then((res) => res.json())
.then((data) => data[0].url);
"Can I use any libraries?" was Chris's next question. I replied: "Do you think one is needed for this app?"
"Well, we need a cancel button... And I find Observables make for cleaner, less error-prone code around cancelation."
This took me by surprise. I only knew of one cancelation tool, AbortControllers, and Chris pinpointed my frustrations with them - that they're easy to forget, don't compose well, and obscure the logic of the happy path.
I said, "yes you can use a library, but you must be able to explain what it is doing, and justify its contribution to the bundle size."
Chris chimed up with, "Sounds goodβ shall we get to work then?"
π₯π ππ₯βAn Odd Choice of Library
Chris started by creating a new file, naming it gifService.ts
. I gently inquired why they made a separate file instead of coding into the provided React component.
"React is the view layer, this is- well, a service layer. Easier to test with no React dependencies. Can I proceed?"
"Go for it," I said. Chris wrote the following as though from memory, and in TypeScript, but I'll post just the JS here.
import { Bus } from "@rxfx/bus";
import { createService } from "@rxfx/service";
import { fetchRandomGif } from "./prebuilt-for-candidate";
const bus = new Bus();
bus.spy(console.log);
export const gifService = createService("gif", bus, () => fetchRandomGif);
I said - Ok, now what is this library doing?
"Have you used Redux Saga, createAsyncThunk, or other async middleware? π₯π ππ₯ is a drop-in replacement, at less than half the bundle size. The bus receives events, which the service will put onto the bus as the effect starts, provides data, etc."
I knew Redux Saga. I said "The fetchRandomGif
function - it's not written as a generator function or a saga, it just returns a Promise. Is that going to be compatible with your bus?"
"Yep, no problem. It can do Promises, Observables, iterables. It's just RxJS underneath.
If you've ever been frustrated using React Context or prop-drilling to share data across an app, a bus
is a framework-free way to do the same. It's so easy I don't know why it's not built into every app!"
I did have prop-drilling and React Context issues, and I saw how events being visible to any part of the app by default would result in less code. That satisfied me of their choice, and I asked Chris to continue.
The State Model and Reducer
"Greatβ now let's start on our state model. It looks like there's only one field we need in state - the URL of the current image - is that right?"
I said "Are you forgetting the loading and error states?"
"One nice thing about an π₯π
ππ₯ service is your state model doesn't need to include loading and error. You get into trouble when you mix transient fields like loading
and error
into state fields that you may want to persist for longer - like across sessions. Separate things that change at different rates, right?"
I had just dealt with a bug where a loading
state loaded from local storage with a value of true
- the spinner spun but nothing was happening. It occurred to me it was not really DRY to have a state field that isn't a direct reflection of whether a process is actually running, so I was ready to see it in action.
"We still need to see loading and error in the UI, but let's go ahead with a loading-free reducer."
The reducer looked like this:
const initialState = {
url: "",
};
export const gifReducer = (state = initialState, e = {}) => {
switch (e.type) {
case "gif/next":
return { ...state, url: e.payload };
default:
return state;
}
};
I noticed the strings in the case
statements, and I said "These look like Redux Toolkit conventions, but with different names - where do they come from?"
"Fair question. A service has a standard set of actions, based on Observable life-cycle events. The next
event delivers data, error
an error, and started
indicates a search began. There are typesafe versions of these too, do you want me to use them?"
I said, "Let's skip that for now and get our data in the UI."
"Cool. Then let's add the reducer to our service."
Chris changed the line to create the service ever-so-slightly, by adding the reducer.
- createService('gif', bus, () => fetchRandomGif);
+ createService('gif', bus, () => fetchRandomGif, () => gifReducer);
"And now let's bring state into our UI".
UI Updates
Chris typed the following in a flurry of keystrokes..
import { gifService } from "./services/gifService";
import { useService } from "@rxfx/react";
function CatFetcher() {
const { state, request } = useService(gifService);
const { url } = state;
<img src={url} alt="Animal GIF" />
<button onClick={() => request()}/>
I said "Let me get caught up. Through the useService
hook, we have a reference to the state produced by the reducer. And we have a function with which to request the effect, which changes the state?
"Yeah, precisely!"
It worked greatβ on the happy path. Now what about errors, I asked.
Errors
Chris hacked a thrown error into the fetch endpoint. Then captured a currentError
field from the hook. Then used it in React to show the message.
- const { state, request } = useService(gifService);
+ const { state, request, currentError } = useService(gifService);
<div className="error">{currentError}</div>
I tested that when an error was shown in the GIF fetcher, the error display just worked. And it was cleared automatically on the next click. I guess that's why the field is named "currentError"βonce a new one begins there is no current error. A nice convenience.
After it had shown an error, it resumed future fetches just fine. I said to Chris "You pulled that off nicely. But since you left loading
out of your state, how will we display that?"
Loading State
While the GIF is loading, let's change the text "Fetch Cat" to "Fetching.."
Chris captured the isActive
field from the useService
hook return value.
- const { ... currentError } = useService(gifService);
+ const { ... currentError, isActive } = useService(gifService);
<button onClick={() => gifService()}>
{isLoading ? "Fetching." : "Fetch Cat"}
{/* Fetch Cat */}
<HourglassSpinner show={isLoading} />
</button>
That isActive
variable - the hook just knows that a fetch is active?
"Sure does. The service knows when it's doing work. It keeps a count of gif/started
and gif/complete
events and emits true
when the count is > 0
and false
otherwise."
I pretty much decided Chris had passed the interview, but to throw a challenge I asked about cancelation.
Cancelation and The Finished Product
I'd shipped many apps without cancelation before, especially before Abort Controllers. But I knew that to do top-notch UX, one had to be able to cancel effects to free up resources.
I asked how we could cancel a load while in progress. Chris added a Cancel button to the form, and I stepped out of the room for a second.
<button onClick={() => gifService.cancelCurrent()}>Cancel</button>
When I returned, I opened up DevTools, and clicked Fetch Cat. I clicked Cancel, and BOOM, a canceled XHR on /search
!
Chris showed the new fetchRandomGif function - which looked like the Promise-based version.
import { ajax } from "rxjs/ajax";
const fetchRandomGif = () => ajax.getJSON({
url: "https://api.thecatapi.com/v1/images/search",
}).pipe(
map((r) => r.response[0].url)
);
Seeing this, I asked, "So the service can just cancel this AJAX, even without an AbortController?"
"Cancelation is automatic- as long as the endpoint returns an Observable. It's crazy - every Observable since 2012 is cancelable, and yet today we have just Promises. It's nice that in π₯π
ππ₯ you can return a Promise
to start, and an Observable when you implement cancelation."
This was great. I made a mental note: Suggest the whole team learn about Observables and this API around them. Promises being run-to-complete by default started to look like a very bad idea, especially when it was easy as this to swap a non-cancelable AJAX with a cancelable one.
A Mysterious Departure
Chris had exceeded expectations on the first 3 mandatory points of the challenge. I wanted to hire, so I moved on to Chris's questions. We talked pleasantly, then when we were standing up to say goodbye, curiosity got the best of me, and I asked one more technical question:
"Just curious- but how would you handle a click while a GIF was already loading? Something like XState?"
Chris lifted their backpack to their shoulder and smiled.
"Oh, the π₯π
ππ₯ service has that covered too. Just change the call to createService
to createQueueingService
and you're covered. I'll send you a CodeSandbox of it later today so you can try it out."
And with that, Chris was gone. And my learning into RxJS and π₯π ππ₯ had just begun.
Author's Note
Here's the CodeSandbox of the Cat Fetcher.
As you may have guessed, this was a fictitious story, written by me, Dean, the author of π₯π ππ₯ packages. I must stress that π₯π ππ₯ was not designed to handle interview problems, but real world ones! And it has been deployed to production in various forms for 4 years, solving problems like dynamic forms, 60FPS animation, Web Sockets and many more. I hope you will give it a look, and let me know what you think!
And soon there will be Part 2 where we address timeouts, maintaining the loading state until the bytes of the image have arrived, and other subtleties of data fetching.
-- Dean
Update: Here is part 2!
Top comments (0)