React Clean Code — Simulate Network Scenarios with Mirage.js
React Clean Code — Simulate Network Scenarios with Mirage.js
In my book Maintainable React, I introduced a feature I had worked on a while ago. That feature is interesting in many ways, and I selected it as it involves several states in the view — which is one of the reasons why building UI is complicated.
They are not typical UI states but server cache states, as described in Kent’s article here. All the network requests could go wrong, timeout, too many requests or even service downtime. In the views, we need to reflect these network statuses correspondingly.
<a href="https://medium.com/media/775212369765d398058a9d54900de2da/href">https://medium.com/media/775212369765d398058a9d54900de2da/href</a>
Different status
The feature Direct to boot here is that when the user is landed on the page at the beginning, we need to check the current status of an order. If it’s in progress, we need to show a disabled button I'm here and some hint messages. And at some point, when the order is ready to pick up, the button needs to be enabled so we can click it to notify the store. When the button is clicked, a notification is sent, and the order will be delivered right into the boot of your vehicle. In addition, if anything goes wrong, we show a number as a fallback so we can call the store by phone.
So we need to consider the following things at least:
Fetch data from the server
Retry if something goes wrong in the network
Retry if the data is returned but not what we wanted
Stop retrying when we failed too many times
Update data to the server
Handling errors for each network request
If we draw a statechart for the description above, it will be something like the diagram below. Before the order is ready, we need to retry a couple of times. And the notification might have another retry counter (we’re not doing it in this chart). Also, each network request could lead to an error. Note that the happy path (idle → ready → notified) is only one of the branches.
The happy path
We can start with the happy path as that’s the most easier step and the most important thing we want to make sure to happen.
In the test, the happy path for (initialised → ready) can be described as:
Mocking the request for happy path
In my book, I used msw as the mocking server, and it worked pretty well. I’m using mirage.js here for simplicity. Also I like the data modelling part of the library. It doesn’t matter much here, and you can use either one.
For example, we can define a get API for checking the order status in the test. It intercepts requests send to /api/orders/<id> and always returns an object with ready status.
import { createServer } from "miragejs";
const createMockServer = () => createServer({
routes() {
this.get("/api/orders/:id", (schema, request) => {
return {
id: request.params.id,
status: "ready",
};
});
}
})
And then, we create a server at the beginning of each test, and shut it down at the end.
describe("Direct To Boot", () => {
beforeEach(() => {
server = createMockServer();
})
afterEach(() => {
server.shutdown()
})
//...
});
The API looks intuitive and works just as expected. In our component, we can send requests and consume the response like it would for the real APIs.
Implementing fetch with react-query
In my previous article, I discussed in detail how many trivial things you need to consider for making a “simple” network request. As well as how simpler if you use react-query instead of implementing it manually.
To use react-query, firstly we’ll need to define a query function. Note here if the res.data.status is not ready, it will throw an error. And react-query can detect that error and trigger a refetch if configured.
const fetchOrder = (orderId: string) => {
return axios.get(`/api/orders/${orderId}`).then((res) => {
if (res.data.status === "ready") {
return res.data;
} else {
throw new Error("fetch error");
}
});
};
Now with the fetchQuery function, I can then call useQuery, and wrap the whole logic inside a hook useOrder
const useOrder = (orderId: string) => {
const [status, setStatus] = useState<string>("initialised");
useQuery(["fetchOrder"], () => fetchOrder(orderId), {
retry: 5,
onError: () => setStatus("error"),
onSuccess: () => setStatus("ready"),
});
return { status }
}
I set retry as 5, so whenever an actual error happened (say, 500 from server side) or when res.data.status is not ready, react-query will retry. And it doesn’t retry right away but waits for a little while as a delay between each failure.
Simulate an error
In mirage.js, simulating an error for the test to catch is fairly straightforward. I also found it helpful to have several ids that would trigger errors so you can test different error-handling logic.
For example, we can define a list of ids that indicate errors when used.
this.get("/api/orders/:id", (schema, request) => {
if(['error-id', 'timeout-id'].includes(request.params.id)) {
return new Response(500, {}, {error: "something went wrong"});
}
return {
id: request.params.id,
status: "ready",
};
});
And then, in our test, we can use either error-id or timeout-id as the orderId to simulate the error:
it("shows a fallback call the store button", async () => {
render(<DirectToBoot orderId="error-id"/>);
await waitFor(() =>
expect(
screen.getByText("Seems something went wrong...")
).toBeInTheDocument(), { timeout: 3000});
const button = screen.getByText("04 23 33");
await waitFor(() => expect(button).toBeInTheDocument(), {timeout: 3000})
});
Simulate retries when request failed
In our feature, we also want to simulate the long-running order to make sure the UI won’t be blocked by the initial fetch. We can simulate it by defining a variable with initialised state and later on use a timer to update the value.
const longRunningOrder = {
id: 'long-running-order',
status: "initialised",
}
//...
const createMockServer = () => createServer({
routes() {
this.get("/api/orders/:id", (schema, request) => {
if(['long-running-order'].includes(id)) {
const timerId = setTimeout(() => {
longRunningOrder.status = 'ready'
clearTimeout(timerId);
}, 2000)
return longRunningOrder;
}
});
}
})
Then after several retries, the view finally gets the ready status and ready to notify the store. Note in the console that `mirage` had done three retries in this case.
The final code
Having react-query to handle all the network-related logic, as you can imagine how simple the final Direct To Order component itself can be simplified.
export function DirectToBoot({ orderId }: { orderId: string }) {
const {status, notifyStore} = useOrder(orderId);
return (
<div className="container">
<h3>Direct to boot</h3>
<p>{getMessage(status)}</p>
<div className="buttonContainer">
{createButton(status, notifyStore)}
</div>
</div>
);
}
So essentially, the component DirectToBoot accepts orderIdas a parameter, using a hook to get the status and a function to update the status.
We’re using the hook as a state machine, just like the statechart we illustrated at the beginning of the article.
Summary
In this article, we have discussed how to use mirage.js to simplify the network mocking for either impossible or difficult cases if you talk to real APIs in frontend code. We looked at the happy path, error handling and retries with mirage.js , also how easy it is to use react-query to simplify the logic of implementing the network-related code.
You can find the full source code for this article here. And if you prefer videos, I’ve published these on my YouTuBe channel in 2 hour-long videos.
If you like the reading, please Sign up for my mailing list. I share Clean Code and Refactoring techniques weekly via blogs, books and videos.
React Clean Code — Simulate Network Scenarios with Mirage.js was originally published in ITNEXT on Medium, where people are continuing the conversation by highlighting and responding to this story.