Let's analyze this React tree structure:
Let's assume that App
renders the ShipSearch
and ShipDetails
components,
passing props as needed.
ShipSearch
is a server component which takes a search
and uses that to
perform a database query:
async function ShipSearch({ search }) {
const shipResults = await searchShips({ search })
The ShipDetails
component is also a server component which takes a shipId
and uses that to perform a database query:
async function ShipDetails({ shipId }) {
const ship = await getShip({ shipId })
Because these are sibling components, both queries will run at the same time.
searchShips query ----> searchShips result
getShip query ----> getShip result
Server Waterfalls
That's all fine. But let's say the App
component needs to resolve a logged in
user:
const user = await getLoggedInUser()
Uh oh, now we have a waterfall:
getLoggedInUser query ----> getLoggedInUser result
searchShips query ----> searchShips result
getShip query ----> getShip result
The issue here is now App
is waiting for getLoggedInUser
to resolve before
it can render anything. This means both searchShips
and getShip
won't be
executed until after getLoggedInUser
resolves, even though those don't
directly depend on the user.
This is a problem generally for React Suspense as well and I dive deep into that
in the
React Suspense workshop exercise 6.
There are a few ways to think about this problem:
Maybe it's not a problem if getLoggedInUser
is fast đ€·ââïž But that's not always a
sure thing, and even if it's fast today doesn't mean someone won't add something
else that will be slow tomorrow. Still, it's always good to think about how bad
ignoring the problem would actually be since there could be bigger fish to fry.
Alternatively, we can lift searchShips
and getShip
into App
and pass the
results down to ShipSearch
and ShipDetails
. Then we could use Promise.all
to make sure they run concurrently. But passing props can get annoying pretty
fast. We can use a library like
@epic-web/cachified
to dedupe the
queries and then App
simply kicks off the query earlier which would at least
be better.
Unfortunately, another issue though is this can get unweildy pretty fast,
especially if there's logic around which queries should run based on other
props. You'd have to move all that logic until your entire app lives in App
đ±.
The next solution I can come up with is to use or build a compiler which can
find all queries and preload them automatically. This is what Relay is. There
are quite a few things I don't like about this approach though:
- If you have logic around which queries should run based on other props, this
approach doesn't work.
- It adds a lot of complexity to your build process.
- When you're working in product code, it's not obvious what's happening.
So I guess what I'm saying is I don't like any of these solutions. But I'd like
to reframe the problem with more context.
The status quo
Let's say we live in a world where we don't have RSCs or Suspense. You're just
building an app with components that fetch data and render stuff.
In that world you've got three options:
- Group together (colocate) your queries with the components that need them.
- Lift all your queries into a single component and pass the results down.
- Use a compiler to find all your queries and preload them automatically.
Sound familiar? It's exactly the same problem we've always had with components
and data. There's always been this tension between colocating and passing down
data (prop drilling).
One of the nice things about Remix is that it gives you a fourth option:
- Decouple your data fetching from your components.
This allows Remix to load data as soon as the request comes in regardless of
whether the component has rendered.
This is what I've been doing for years and it's been awesome.
So, why RSCs?
This blog post isn't really about why I think RSCs are awesome. You can read
React Server Components: The Future of UI
for that. Just know that composition at route boundaries is not as good as
composition at component boundaries and I want React-level composition badly.
What I want to do is show how RSCs fit into the same tension between colocating
and prop drilling that we've always had with components and data. There's no new
problems here.
But what I find interesting is how a server-side waterfall is probably better
than a client-side waterfall primarily because you get to control the network.
The connection between your server rendering server and your database is
probably stronger, faster, more reliable, and closer. Or maybe it's not, but the
point is you are in control there and can make improvements to that if it's
important to you. You also have more fine-grained control over caching (requests
from separate clients can share a cache for common data).
When you have a client-side waterfall, you're dealing with the user's device and
their network connection which may be great or may be terrible but you
definitely have no control over.
So shifting this problem from the client to the server sounds like a net gain
for many scenarios.
Conclusion
The "waterfall" problem of RSCs is not a new problem. It's the same tension
between colocating and prop drilling that we've always had with components and
data. Maybe there's a new solution we can look forward to in the future. I
welcome ideas (preferrably something that doesn't require a special compiler)!
Until then, I'm actually pretty happy with just making things fast enough that the
problem isn't a problem.