There are two primary approaches to page transitions (ignoring suspense's ditched attempt at a third)
- Indefinitely wait on the old screen
- Transition immediately to spinners/skeleton
Right now Remix has picked #1, but with a new export to a route module, we could support both.
Today, if you have this, Remix will wait for all data to load before displaying the page
export function loader({ params }) {
return User.find(params.userId);
}
export default function UserProfile() {
let user = useRouteData();
return <UserProfile user={user} />;
}
Like ErrorBoundary
, we could add a Pending
export:
export function loader({ params }) {
return User.find(params.userId);
}
export function Pending() {
return <UserProfileSkeleton />;
}
export default function UserProfile() {
let user = useRouteData();
return <UserProfile user={user} />;
}
If a route module exports a Pending
component, Remix could switch to mode #2 and immediately display this screen when the location changes, dispalying <Pending/>
until the route data all lands.
Remix will wait for any routes that don't export a Pending
before displaying any other routes' Pending
export. Some scenarios:
- All routes have
Pending
: can transition immediately. - Parent without Pending, child route with Pending: wait for Parent, then transition immediately for Child.
- A-Pending -> B-No -> C-Pending: Wait for B, transition to Pending
In summary, wait for all routes w/o pending, then transition.
On web and native, both types of transitions are common, and both have their tradeoffs depending on the data being fetched or the type of app you're building. In apps with very "app like" layouts with lots of persistent UI between location changes (rather than typical "pages" on the web with very little persistent UI), immediate transitions to skeleton UI is feels much better. For example, in Discord, it would feel weird to click on a channel and not go immediately to a shimmer/skeleton page. Conversely, we all know how terrible many webpages feel when clicking a link results in 12 spinners bouncing around before the page is built.
There's room for both transitions.
What's really interesting with Remix is that the Pending
components in layouts can still render an outlet. This means that if a parent route's data is not as important as a child's you don't have to block the transition on it.
For example, load up a youtube video on a slower connection and you'll notice the primary content loads first, then the layout shows up around it.
Consider a typical master/detail view with these routes:
routes/
- users.tsx
- users/
- index.tsx
- $user.tsx
And let's say the UI has a sidebar of the users on the left, and the profile on the right:
<Users> <$User>
|----------------------------------|
| bob | Bob Thornton |
| sally | |
| curtis | |
...
The most important data at /users/bob-thornton
is Bob's profile, not the user list.
So to get the users's profile displaying as fast as possible it could look like this:
// routes/users.tsx
export function loader() {
return Users.findAll();
}
export function Pending() {
return (
<>
<UsersSidebarSkeleton/>
<Outlet/> {/* <-- Can still render an outlet! */}
</>
)
}
export default function UsersLayout() {
let users = useRouteData()
return (
<>
<UsersSidebar users={users}>
<Outlet/>
</>
)
}
// routes/users/$userId.tsx
export function loader({ params }) {
return Users.find(params.userId);
}
// no pending export we want to wait for this
export default function User() {
let user = useRouteData()
return (
<UsersProfile user={user}>
)
}
Now if you're looking at /recent-activity
and click on a user's name, navigating to /users/sally-mae
Remix will:
- Keep the recent activity screen up
- Start loading
Users.findAll()
andUsers.find("sally-mae")
in parallel for bothusers.tsx
andusers/$userId.tsx
- As soon as
users/$userId.tsx
is complete, Remix will transition to the page - If
users.tsx
has not finished loading (probably more expensive anyway), the sidebar will just keep shimmering until it's loaded, but Sally's profile will be up! - Instead if
users.tsx
finished beforeusers/$userId.tsx
, then you're transitioned to a fully formed page.
-
Transition hook: just like
hasLoader
andhasAction
, we can easily know which loaders to wait on in the transition hook with ahasPending
. -
Transition hook: while we still kick off the fetches for the routes with pending ui and loaders, we don't await them. When they land, we just setState into
routeData
. -
Client side redirects will be a little tricky, to get consistent behavior across document requests, no pending UI, and pending UI, we'd need to wait for all loaders to land (even the pending ones) before deciding to redirect. This means a child route without pending UI could render, and then a parent route with pending ui could redirect after. Also, a child route with pending UI that redirects, should stay in the pending state until all routes data has landed and then decide where to redirect.
-
<RemixRoute>
: With a mix ofhasLoader
andhasPending
, when it comes time to render the route, we can put a placeholder in therouteData
state, or add a new piece of state to track which routes we're still waiting on, to decide to render the pending UI or not. -
Server rendering: if a route has pending UI, do we skip the loader on the server render and go straight to pending? Ignore pending and always render the full page on the server? Add a way to let apps decide? I think we just render the whole thing. This would bring consistent results across document/fetch requests and then we could add a way to skip it later if we decide it's worth it.