I recently found myself pondering some questions about dealing with asynchronous requests in React specifically, and figured it might be worth sharing.
Where traditional models break
Before we dig deep, let's leave JavaScript behind for a moment and look back to the traditional Model-View-Controller model. A totally synchronous page load would look something like this:

And then when our user decides to interact with a page, that triggers a new request with a new URL, closing the loop. So far so good.
Enter the Single Page Application era. Sticking with our Model-View-Controller approach, the easiest way would be to consider our entire backend and API as the model or datastore. There's more to it but that's what we're sticking with for the purpose of the exercise. We'd still need a controller to fetch and process all of that data, and a view to display it.

At this point, it's worth noting React's place in all this. Historically, it's always been seen more as a library than a fully featured framework. And in particular, a view library. The contract is very explicit, you pass the data from the top down and it renders all the components it needs to render using that data.
Except... It's not really practical to pass around a million props. So that's where things like context help us pass data deep down the component tree. But then you ask, "I'm only using this bit here, do I really need that on my global state tree?". And suddenly, we end up with this:
const Posts = () => {
const { data, loading, error } = useFetch('/api/posts');
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
return (
<ul>
{data?.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
};
An abomination
The code looks alright. But let's break it down in context of the larger application. I think you can see the shift.

Suddenly it seems like a large part of our controller has shifted into our view layer. Before React, in pretty much any other language or framework, this would be considered an anti-pattern and absolute abomination. In fact, it's not exactly encouraged by the React team either. Yet here we are, where it's a pretty common pattern and considered the go-to solution unless you explicitly run into its limitations.
How are we fine with this?
It turns out, this is actually pretty convenient. One of React's main strengths is component composability, and doing this, for all it's downsides, allows you to drop any component anywhere and "the app will figure it out". Sure, we might have to display a few loading placeholders too many, but that's ok, right? Maybe?
To be clear, there definitely are downsides. But unless you're dealing with tons of data dependencies between component, or SSR, chances are you don't care. And if you do, there are fully-featured frameworks built around React that solve this issue - like Next.js or Remix - and put you straight back into that second diagram. But you loose the colocation of your components and their data requirements, or there's other hoops you need to jump through.
And let's not forget, not all views are strictly bound to the route either. Think along the lines of notification or user profile widgets. Those need data too!
So that's the part that gets me excited. Of course, in any realistic use case, you should just figure out what's most important to you and make some concessions. But the engineer in me can't help but wonder, what if we could come up with a solution where we can have it all?
It also goes to show... there are times where strictly technically correct decisions can lead you to solving the wrong problems. Maybe it's not the ideal solution, but there are thousands if not millions of examples where it hasn't made any difference. Over-engineering is a thing too.