Routers and Transitions #5
-
|
This is a follow up to the bluesky thread here. It spidered out into multiple little threads and areas of confusion/clarification so I'll try my best to present a coherent summary here and specifically call out areas of confusion along the way. Maybe we can thread each question below for easier sub-discussions? React Router + TransitionsWe've been working to get React Router compatible with the new async transitions and Back in early version of Remix + React 18, we wrapped internal router state updates with Now with React 19, we're hoping to make entire navigations more transition-friendly. The React 18 blog post stated (emphasis mine):
In React Router v7 we began exposing the promises from our navigation hooks ( Fast forward to React 19 introducing async transitions:
And per the
Therefore, we were under the impression that we could just do this to make our navigations "transition friendly": startTransition(() => navigate('/'));That works great! We had to then make sure that some of our router state could surface mid-navigation for things like Here's a stackblitz using an experimental release of our new opt-in flag showing a transition-aware navigation with an entangled counter: https://stackblitz.com/edit/github-c3cj3ant-f9eriqwh ❓ Question 1 - In the thread, Ricky stated that "startTransition(() => Promise) is for posting mutations, not for getting data". Is that strictly true? And if so, is our approach incorrect? FWIW nowhere in the history.go/popstate navigationsDuring our testing, we found that everything worked as expected except The reasoning behind this feels idealistic and sort of fundamentally impossible in our eyes. In an ideal world, we'd have data available for back-navigations and they'd be fully synchronous, and therefore the browser could restore scroll position form inputs. But in reality, this feels fundamentally impossible. ❓ Question 2 - Is React's expectation that every React Router does not behave as a cache, and we leave that to the users Here's a stackblitz showing a transition-aware forward navigations working as expected, and a We think that if a developer can call ❓ Question 3 - At the very least - would React be open to making this behavior configurable by the user/router layer? Instead of something rigidly enforced by React? There is an open issue around
At the moment, we plan to document this as a React limitation and potentially even point out that it can be bypassed via: window.addEventListener(
'popstate',
() => {
window.event = null;
},
{
capture: true,
}
);View TransitionsAlso from the thread, it was mentioned React doesn't animate back navigations with ViewTransition. This also feels overly restrictive? To my knowledge, MPA view transitions work fine with back navigations? And the view transition support we have built into React Router today using ❓ Question 4 - Does React have any intention of trying to make It's worth noting that applying the above |
Beta Was this translation helpful? Give feedback.
Replies: 6 comments 8 replies
-
|
❓ Question 1 - In the thread, Ricky stated that "startTransition(() => Promise) is for posting mutations, not for getting data". Is that strictly true? And if so, is our approach incorrect? |
Beta Was this translation helpful? Give feedback.
-
|
❓ Question 2 - Is React's expectation that every |
Beta Was this translation helpful? Give feedback.
-
|
❓ Question 3 - At the very least - would React be open to making this behavior configurable by the user/router layer? Instead of something rigidly enforced by React? |
Beta Was this translation helpful? Give feedback.
-
|
❓ Question 4 - Does React have any intention of trying to make ViewTransition work on back navigations? This feels like more of an unfortunate consequence of the decisions made around popstate having to be synchronous, and not so much a technical limitation. |
Beta Was this translation helpful? Give feedback.
-
|
I think there may be some confusion here, because I think you may have interpreted what as said to mean "don't use transitions for navigations", which is not what I meant. For navigations, you should always do it in a sync transition: // this is for the history API
// the navigation API is a bit different
function navigate(url) {
startTransition(() => {
setRouterState(() => {
url,
// other router state
})
});
}Then, any data that you need to GET for the new route can be handled by suspense. This allows React to immediately navigate to the next page with suspense placeholders, and allow the browser to start downloading resources such and fonts and CSS that it will need in order to render. It's important not to delay the render for the next page here because it will slow down navigations and make your app feel slow. This matches the recommendation in the docs for building a suspense enabled router. Problems in the demoThe issue in the demo is that you're awaiting the loader in the middle of two transitions:
So you're effectively doing this: function navigate(url) {
// transition one
startTransition(() => {
addOptimisticState('loading');
setRouterState(() => {
url,
// other router state
})
});
// await outside transition
await loaders(url);
// transition two
startTransition(() => {
setRouterState(() => {
url,
// other router state
})
});
}This isn't even using an async transition to GET the loader data and render the next route in the same UI transition, it's just scheduling two independent transitions. This is busted. You can see how it's busted if you try to add a Here's an example sandbox with how I would expect to be able to drop in a <Link
href="/a"
navigateAction={async () => {
setPendingRoute('/a');
router.navigate('/a');
}}
>
Go to A {pendingRoute === '/a' && '...'}
</Link>But in the sandbox, the You can fix by using an async transition, like in this sandbox. And now you can see the optimistic state with '...' on the link, all the way through the new page rendering. Importantly, this also even works for the back navigation! But we'll get back to that later. Why transitions for GETs are badEven though this works with an async transition (which is in user land in the sandbox, but you could fix it in the router), using an async transition for this use case is bad UX. This makes the updates in the transition wait for the promise to finish before rendering any of the updates in the transition. This means you can't start rendering or fetching anything else on the next page until after the loader finishes. Even if all your data is in the loader, you're still not able to parallelize fetching resources like fonts and stylesheets. You can do it in React Router if you want, but it just means alternative routers will be able to provide faster navigations. There are exceptions of course, but that's why in general you should GET data in render with Suspense, and save async transitions for POSTing data to the server. We could call it out better in the docs, but the docs for async transitions include examples like (link): function onSubmit(newQuantity) {
startTransition(async function () {
// mutation
const savedQuantity = await updateQuantity(newQuantity);
startTransition(() => {
setQuantity(savedQuantity);
});
});
}For mutations, it makes sense to not start rendering the next page by default until the mutation finishes, because you might fetch the data you're mutating before it's mutated. Though you can still pre-render that page with Activity to speed up the post-mutation navigation, ensuring that the loaders are refreshed after the mutation. So:
Why useOptimistic works in popstateAbove I mentioned that the The key thing here is that we don't force updates in popstate to flush synchronously, we attempt to render them synchronously. If something suspends, or the transition can't otherwise complete (such as a pending action), then we give up and go back to the transition. It's like an optimistic sync render to see if everything is cached. In the demo, this means the in the popstate, react attempts to synchronously render, and can't complete. So we go back to rendering it as a transition. This means the transition is still pending, and the optimistic state we set at the beginning of the transition is still used. So in typically scenerios, the sync rendering of popstate really isn't observable to end users. So why doesn't the original useOptimistic work?In your demo, you're setting the optimistic state inside of the popstate event itself: window.addEventListener('popstate', () => {
startTransition(() => {
setOptimistic(event.detail);
setState(event.detail);
});
});This is a bit of an edge case because users would typically set the optimistic state before navigating, not in the middle of one. But the reason this doesn't work might just be a bug in React where we're dropping the optimistic update during the attempt to synchronously render the transition, and not restoring it when reverting back to the transition. I'll file a bug for this. |
Beta Was this translation helpful? Give feedback.
-
|
@brophdawg11 can you help me understand what the unexpected behavior is in your React Router sandbox? This is what I'm seeing, which seems to be expected? It doesn't have the missing "loading" state like your demo repro did: Screen.Recording.2025-11-19.at.9.23.14.PM.mov |
Beta Was this translation helpful? Give feedback.

I think there may be some confusion here, because I think you may have interpreted what as said to mean "don't use transitions for navigations", which is not what I meant. For navigations, you should always do it in a sync transition:
Then, any data that you need to GET for the new route can be handled by suspense. This allows React to immediately navigate to the next page with suspense placeholders, and allow the browser to start downloading resources such and fonts and CSS that it will …