Welcome to the EpicWeb.dev Workshop app!

This is the deployed version. Run locally for full experience.

Optimizations

Waterfalls

React Suspense is a powerful way to colocate data requirements with the UI that requires the data. However, there is one drawback to this approach and that involves something called "waterfalls."
If you look at the Network tab of your DevTools, you'll find a "waterfall" column. This displays the time each request was in flight. When the request waterfalls look like a stair-stepping cascade, that leads to a slower user experience than if all the requests start at the same time.
While "waterfall" describes the visual appearance of the network requests in general (even the fast ones), it is often (confusingly) used to simply describe what I'm calling the "stair-stepping cascade" (the slow kind of waterfall). So, in the future when I say "waterfall" I'm talking about the "stair-stepping cascade" kind unless otherwise noted.
To be clear:
Request A --------> Response A
                     Request B --------> Response B
                                          Request C --------> Response C
This is a stair-stepping cascade, alternatively:
Request A --------> Response A
Request B --------> Response B
Request C --------> Response C
This is much faster.
Of course, sometimes you can't avoid a little waterfalling if the data you need depends on other data to be retrieved first (if that's the case, then fix your API to not have this limitation).
Due to the design of Suspense, you can easily create waterfalls by mistake. For example:
function ProfileDetails({ username }: { username: string }) {
	const favoritesCount = use(getFavoritesCount(username))
	const friends = use(getFriends(username))
	return <div>{/* some profile details */}</div>
}
The trouble with this is the use(getFavoritesCount(username)) will cause the ProfileDetails will suspend until the getFavoritesCount request is resolved. Only then will the getFriends request be made. This is a waterfall.
To solve this problem is pretty simple, though maybe not obvious at first. You just need to make sure to trigger both requests before use is called:
function ProfileDetails({ username }: { username: string }) {
	const favoritesCountPromise = getFavoritesCount(username)
	const friendsPromise = getFriends(username)
	const favoritesCount = use(favoritesCountPromise)
	const friends = use(friendsPromise)

	return <div>{/* some profile details */}</div>
}
This way, both requests are made at the same time and the ProfileDetails component will remain suspended when both are resolved (the order of the use calls doesn't matter in this case).
That's simple enough (you could even make a custom Lint rule to enforce you always do this correctly), but there's an even trickier place where this can happen.
What if you were to nest these components?
function ProfilePage({ username }: { username: string }) {
	const userAvatar = use(getUserAvatar(username))
	return (
		<div>
			<Avatar url={userAvatar} />
			<ProfileDetails username={username} />
			<hr />
			<ProfilePosts username={username} />
		</div>
	)
}

function ProfileDetails({ username }: { username: string }) {
	const favoritesCountPromise = getFavoritesCount(username)
	const friendsPromise = getFriends(username)
	const favoritesCount = use(favoritesCountPromise)
	const friends = use(friendsPromise)

	return <div>{/* some profile details */}</div>
}
Can you find the waterfall? It's not as obvious as the previous example, but it's there. The ProfilePage component will suspend until userAvatarPromise is resolved. Only then will the ProfileDetails component trigger the favoritesCountPromise and friendsPromise requests.
This is a problem because the ProfileDetails component is not even visible to the user until the ProfilePage component is resolved. This is a waterfall.
To solve this problem, you need to trigger the requests in the parent component and pass the promises down to the child components:
function ProfilePage({ username }: { username: string }) {
	const userAvatarPromise = getUserAvatar(username)
	const postPromise = getPosts(username)
	const favoritesCountPromise = getFavoritesCount(username)
	const friendsPromise = getFriends(username)

	const userAvatar = use(userAvatarPromise)

	return (
		<div>
			<Avatar url={userAvatar} />
			<ProfileDetails
				favoritesCountPromise={favoritesCountPromise}
				friendsPromise={friendsPromise}
			/>
			<hr />
			<ProfilePosts postPromise={postPromise} />
		</div>
	)
}

function ProfileDetails({
	favoritesCountPromise,
	friendsPromise,
}: {
	favoritesCountPromise: ReturnType<typeof getFavoritesCount>
	friendsPromise: ReturnType<typeof getFriends>
}) {
	const favoritesCount = use(favoritesCountPromise)
	const friends = use(friendsPromise)

	return <div>{/* some profile details */}</div>
}
Sheesh, that's annoying!! I thought the whole point was to be able to colocate our data requirements with the code that requires it. That's what's so cool about the use hook and the Suspense model!
Well, because of the promise caching we added before, you can actually get away with keeping things as they were before and simply adding a call to the cached function in the parent component instead of adding promise props everywhere:
function ProfilePage({ username }: { username: string }) {
	// preload some necessary data
	getFavoritesCount(username)
	getFriends(username)
	getPosts(username)

	const userAvatar = use(getUserAvatar(username))
	return (
		<div>
			<Avatar url={userAvatar} />
			<ProfileDetails username={username} />
			<hr />
			<ProfilePosts username={username} />
		</div>
	)
}

function ProfileDetails({ username }: { username: string }) {
	// these will get the cached promise that was created by the parent above
	const favoritesCountPromise = getFavoritesCount(username)
	const friendsPromise = getFriends(username)
	const favoritesCount = use(favoritesCountPromise)
	const friends = use(friendsPromise)

	return <div>{/* some profile details */}</div>
}
What's annoying about this is that you have to remember to call the function before you render the component. This is a bit of a leaky abstraction. You could make it a tiny bit better with a utility function you tack onto the ProfileDetails if you want:
function ProfilePage({ username }: { username: string }) {
	// preload some necessary data
	ProfileDetails.loadData(username)
	ProfilePosts.loadData(username)

	const userAvatar = use(getUserAvatar(username))
	return (
		<div>
			<Avatar url={userAvatar} />
			<ProfileDetails username={username} />
			<hr />
			<ProfilePosts username={username} />
		</div>
	)
}

function ProfileDetails({ username }: { username: string }) {
	// these will get the cached promise that was created by the parent above
	const { favoritesCountPromise, friendsPromise } =
		ProfileDetails.loadData(username)
	const favoritesCount = use(favoritesCountPromise)
	const friends = use(friendsPromise)

	return <div>{/* some profile details */}</div>
}

ProfileDetails.loadData = (username: string) => {
	return {
		favoritesCountPromise: getFavoritesCount(username),
		friendsPromise: getFriends(username),
	}
}
But then you'll run into issues if you decide you want to lazy load ProfileDetails. You'll have to remember to call ProfileDetails.loadData in the parent component before you render the ProfileDetails component.
Alternatively, you could restructure your components to avoid this problem using the composition pattern we learned about in the Advanced React Patterns workshop:
function ProfilePage({ username }: { username: string }) {
	const userAvatarPromise = getUserAvatar(username)
	const postPromise = getPosts(username)
	const favoritesCountPromise = getFavoritesCount(username)
	const friendsPromise = getFriends(username)

	const userAvatar = use(userAvatarPromise)
	const posts = use(postPromise)
	const favoritesCount = use(favoritesCountPromise)
	const friends = use(friendsPromise)

	return (
		<div>
			<Avatar url={userAvatar} />
			<ProfileDetails
				favoritesCount={<FavoritesDisplay>{favoritesCount}</FavoritesDisplay>}
				friendsList={friends.map(friend => (
					<Friend key={friend.id} friend={friend} />
				))}
			/>
			<hr />
			<ProfilePosts
				postList={posts.map(post => (
					<Post key={post.id} post={post} />
				))}
			/>
		</div>
	)
}
And maybe that's ok, but sometimes that just doesn't feel quite right for the UI we're building.
Really, the problem here is that we naturally follow a render-then-fetch pattern which is we don't fetch until we render. The pattern we should be following is a fetch-as-you-render pattern which is to say you trigger all fetch requests before you render anything. You can learn more about this in Render as you fetch (with and without suspense).
Another thing you'll want to think about in this regard is the fact that often we "code-split" our components using lazy loading with lazy (which we cover in the React Performance workshop). Combine this with colocating data fetching and you wind up in a situation where you have a waterfall because you have to first request the code, then the code runs, then that code requests the data.
Ugh, there must be a better way!!
There is 😎
Optimizations like this is where using Remix is a huge win. It's designed to help you avoid waterfalls naturally.
And the future deeper integration of Remix with React Server Components will make this even more powerful.
Additionally, it's data loading primitives are designed to help you avoid waterfalls without even thinking about it.
But if you're using raw suspense as we are in this workshop, you'll need to think about these things.

Cache headers

As often happens with optimizations, some of the best optimizations happen on the backend. Your app can be no faster than your slowest query. So finding ways to make your queries faster is a huge win.
One way you can speed up your backend is by applying caching at various layers of your tech stack. One of these layers is in HTTP and for certain kinds of data you can use cache headers to enable the client to cache the data and prevent network requests even across page refreshes.
To do this, you set the Cache-Control header on your HTTP responses. This header can have a variety of values (called "directives") that tell the client how to cache the response. The most common directive is max-age which tells the client how long it can cache the response.
Cache-Control: max-age=3600
This tells the client to cache the response for 3600 seconds (1 hour). This means that if the client makes a request for the same resource within 1 hour of the first request, it will use the cached response instead of making a network request.
As with all caching this comes with tradeoffs. If the data changes frequently, you might not want to cache it for very long. If the data is sensitive, you might not want to cache it at all (in which case, a server-side cache might be more appropriate).