-
Notifications
You must be signed in to change notification settings - Fork 7
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 + Transitions
We've been working to get React Router compatible with the new async transitions and useOptimistic APIs in React 19.
Back in early version of Remix + React 18, we wrapped internal router state updates with startTransition at Dan's request, and I think at the time that worked fine for scenarios where destination pages suspended, etc.
Now with React 19, we're hoping to make entire navigations more transition-friendly. The React 18 blog post stated (emphasis mine):
However, long term, we expect the main way you’ll add concurrency to your app is by using a concurrent-enabled library or framework. In most cases, you won’t interact with concurrent APIs directly. For example, instead of developers calling startTransition whenever they navigate to a new screen, router libraries will automatically wrap navigations in startTransition.
In React Router v7 we began exposing the promises from our navigation hooks (useNavigate, useSubmit) and our non-navigation APIs (useFetcher().load, useFetcher().submit) in anticipation of React becoming more promise friendly and allowing us to wrap those async functions.
Fast forward to React 19 introducing async transitions:
In React 19, we’re adding support for using async functions in transitions to handle pending states, errors, forms, and optimistic updates automatically
And per the useTransition docs, the action parameter is:
A function that updates some state by calling one or more set functions
"transition friendly" is simply:
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 useNavigation to show loading states, so we added some useOptimistic state inside the router to get that to surface. We've been putting this behind a new opt-in flag and it's been working great.
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 useTransiton docs is the word "mutation" mentioned. Why can't a loading navigation be a transition? The Tabs example in the docs is just a loading transition, isn't it?
history.go/popstate navigations
During our testing, we found that everything worked as expected except navigate(-1). After a few days of scratching our heads we started the original thread and Ricky let us know about the internal popstate behavior (react/react#26025). This was incredibly surprising to discover - as we were convinced React wouldn't be messing with browser routing logic internally (especially without documentation).
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 history.go(n) has cached data? history.go(-10)? history.go(-100)?history.go(-1) after the cache has expired? Is the expectation that the router manages all of this so as not to have to call any user code?
React Router does not behave as a cache, and we leave that to the users loader functions. They can cache data there but it'll still be an await Promise.all(loadersToRun) on our end so it'll immediately be async. So the assumption being made inside React is incompatible with React Router apps using loaders.
Here's a stackblitz showing a transition-aware forward navigations working as expected, and a navigate(-1) wrapped in a transition not working as expected going back: https://stackblitz.com/edit/github-c3cj3ant-phzfymaj
We think that if a developer can call startTransition(() => navigate('/path')), then they should be able safely expect startTransition(() => navigate(-1)) to work the same way.
❓ 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 FALLBACK_THROTTLE_MS that has some relevant comments along the same vein:
I'm fine with defaults, as long as there are escape hatches. This should 100% be configurable, with the option to disable it entirely. It's not really React's responsibility to make this kind of choice for every app.
Since when has React become opinionated like this? Usually React always provided the building blocks and then let us choose how to use them. Why are we suddenly making decisions for devs and even library authors?
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 Transitions
Also 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 startViewTransition also supports back navigation transitions without issue. We are planning to deprecate this in favor of <ViewTransition> eventually, but it feels like it could be a step back in UX for existing users if they lose back animations? As a user, if I clicked from a product grid to a product page and the image expanded into view, I would expect the reverse if I clicked back. It wouldn't very app-like otherwise (and I doubt my product/design team would accept "It can't be done" as a valid reason 😂).
❓ 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.
It's worth noting that applying the above window.event hack makes <ViewTransition> work fine on back navs in our testing in React Router.
All reactions
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 ...
Replies: 6 comments 25 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?
All reactions
In general yes - async transitions are for posting mutations, and sync transitions are for navigations (using GET in render with Suspense). I posted a longer explanation here, and I'll update the docs to explain this better.
But note that your demo was not using async transitions to load the (simulated) loader data, so that's not really the issue there.
All reactions
Closing the loop here for future readers - most of the discussion around this happened in the below threads but the tl;dr; is:
Just to clarify, you can [use async transitions for navigations]. But I think there are very good reasons why you don't want to. And I also think there are good reasons why the router doesn't want to provide optimistic state for the router state. link
But it's been shown that everything mostly works as expected:
Yeah the fixed sandbox works mostly as expected. The remaining issue is that if the user chooses to await in the loader, then the transition count won't update, which is a footgun but you can document that too. The intent is that if you're blocking the transition, you're either using optimistic state on the previous page, or doing the overlay like you described. Most of the time the default for new navs should be to a page with skeletons. link
All reactions
❓ Question 2 - Is React's expectation that every history.go(n) has cached data? history.go(-10)? history.go(-100)? history.go(-1) after the cache has expired? Is the expectation that the router manages all of this so as not to have to call any user code?
All reactions
No, that's why for popstate we only attempt to synchronously render, and if we can't then we will go back to treating it as a transition. If the data is cached, then you get a fast, immediate back navigation which the browser can restore scroll, form, and video state for. If the data isn't cached, then it falls back to being treated like a normal transition navigation.
All reactions
❓ 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?
All reactions
Maybe? But I think it's premature to ask for a feature before fullying understanding what's going on, and why things aren't working as expected. This behavior really shouldn't be observable by user or router code, so I think we should fix the bug and structure the router transitions correctly before jumping straight to configuration options. It's probably the case that this is the behavior you will want in the end, and it just looks like it isn't because of bugs.
All reactions
❓ 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.
All reactions
No. The ViewTransition API is async, which means if back navigations are animated then we'd never support correct platform back navigations when you used View Transition. Instead, we attempt to do what the browser wants (reveal the last page immediately and synchronously) and if that fails, then we go back to a transition which can animate.
All reactions
Isn't disallowing view transitions on back navs already not supporting correct platform back navigations in a way?
All reactions
That's a cross-document view transition. For same-document, the Chrome VT folks recommended not animating popstate because the browser may already include an animation, causing double animations. There's a flag we could check in the navigation API to only disable them when it's known that there's a browser animation, but the Navigation API is not fully supported yet.
Some, but not all, browsers provide their own transition when the user performs a swipe gesture to navigate. In that case you shouldn't trigger your own view transition as it would lead to a poor or confusing user experience. The user would see two transitions—one provided by the browser and the other one by you—running in succession.
Therefore, it is recommended to prevent a view transition from starting when the browser has provided its own visual transition. To achieve this, check the value of the hasUAVisualTransition property of the NavigateEvent instance. The property is set to true when the browser has provided a visual transition. This hasUIVisualTransition property also exists on PopStateEvent instances.
All reactions
Huh TIL - thanks for the link. I almost wish it was the other way personally - if my app has a specific back navigation animation that makes it feels smooth/slick/native/whatever, I would prefer that take priority over a generic thing baked into the browser that's not aligned with my app animations. I would love the browser to detect the presence of an app animation and skip it's own animation, but I understand why that wouldn't always be possible (i.e., starting an animation as part of a swipe but not triggering the navigation until a threshold is reached). I'll have to poke around a bit more to see how different browsers handle that.
All reactions
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 demo
The issue in the demo is that you're awaiting the loader in the middle of two transitions:
Screenshot 2025年11月19日 at 5 05 57 PMSo 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 useTransition or useOptimistic to your code, because it doesn't work as expected. The transition will immediately commit instead of waiting until the navigation completes.
Here's an example sandbox with how I would expect to be able to drop in a useOptimistic and see the optimistic state until the page navigation completes. The code is using useOptimistic so that every time you click a link, it shows that that link is loading:
<Link href="/a" navigateAction={async () => { setPendingRoute('/a'); router.navigate('/a'); }} > Go to A {pendingRoute === '/a' && '...'} </Link>
But in the sandbox, the '...' is never shown (it's actually rendered and flips back faster than you can see, which the console shows). This is because your await is in the middle of two transitions, instead of being inside an async transition which tells react to include the promise in the transition.
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 bad
Even 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:
- sync transitions: navigations, GET in render
- async transitions: mutations, POST in action
Why useOptimistic works in popstate
Above I mentioned that the useOptimistic works even for the popstate back navigation, which you can see in my sandbox. So why does it work there, and not in your original demo?
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.
All reactions
why can't both be an option?
Some feedback here: I'm just trying to help you understand the mental model for the new world of transitions. A lot of it overlaps with what you've already done in the router, but in many cases that existing stuff if either unnecessary with transitions, or incongruent with them. Both can be an option, I'm trying to explain the limitations of doing it one way or another. I think this would be a lot more productive if this discussion was framed more like "what are the issues" instead of "why are you telling me what I can and can't do".
In the stackblitz demos, the user manually wraps the
navigate()call instartTransition().
This kinda gets to the heart of the problem here - in the demo, this transition broken because you're not awaiting the navigation. If the user doesn't await the navigation in the transition, then they're not actually using the transition. You can actually remove it and the app performs the same.
This is what you should be able to do (stackblitz):
Component() { let navigate = useNavigate(); // User can add their own transition pending or useOptimisitic. // and it should stay pending through the entire navigation. const [isPending, startTransition] = React.useTransition(); return ( <> <h1>Home</h1>{' '} <button onClick={() => startTransition(async () => { // 🚩 BUG: if you don't await this, the isPending // immediately flips back to `false`. navigate('/a'); // await navigate('/a'); })} > Go to /a {isPending && '...'} </button> </> );
I don't follow - here's a demo using suspense/await on the /a page
Yes, because it's not awaiting in the loader. Awaiting the loader means you can't opt-in to skeletons with Suspense/Await.
Fetching in render is one way to fetch data, but we don't think it's the only way.
I agree! And to be clear - I'm not saying you should fetch in render. I'm saying you should use the pattern you already support - start the request in the loader, and then <Await> the existing promise in render. So you still get prefetching, but you just move the await from the loader to <Await> in render.
All reactions
But we don't want to force users to migrate to an RSC-setup just to use some of these new features.
I don't think this needs to be the case - I think what you would want to do for a gradual migration is to use the sync transitions in the router (not awaiting the loader in a transition) and then documenting that if you are using async react, then you should not await in your loader and us <Await> instead.
All reactions
I'm not trying to be antagonistic here. I think this has been an incredibly productive convo personally. Sorry if it wasn't framed as "what are the issues" but that's certainly what I've been trying to figure out because it feels like what we have is working and playing nicely with React features as long as we are allowed to use async transitions for GET requests.
It sounds like the main concern is users forgetting to await the navigate call in a transition? I think we're ok with that and will make sure we document it but also most of the time it will be Link/Form doing it for them under the hood.
As long as you await or return, it seems like it behaves as expected? The sandbox you shared below had some minor issues since it was returning the promise directly as the return value (return promise instead of return { promise }) which causes the router to await the entire thing, but then the component was looking for a keyed value (resolve={useLoaderData().promise}) which would be undefined, so I think it wasn't really replicating the intended behavior.
Here's a trimmed down fork of that with the loader/Await fixed up: https://stackblitz.com/edit/github-c3cj3ant-hffeby3r.
If we assume the user awaits the navigate call, I think it satisfies the remaining bullets you included at the top? Local optimistic state works. Entangled counter updates work. The destination route can choose whether to block or not block via Suspense. Is there an issue I'm not seeing with behavior routing from / -> /a here?
I'm saying you should use the pattern you already support - start the request in the loader, and then the existing promise in render. So you still get prefetching, but you just move the await from the loader to in render.
I do understand what the ideal "modern React" approach would be, but that would be a breaking change for our users (most notably around interruption logic between navigations and fetchers) and probably also a pretty big architectural refactor we don't really have the appetite/time for at the moment.
All reactions
Ah, nice catch on returning the object instead of the promise.
Yeah the fixed sandbox works mostly as expected. The remaining issue is that if the user chooses to await in the loader, then the transition count won't update, which is a footgun but you can document that too. The intent is that if you're blocking the transition, you're either using optimistic state on the previous page, or doing the overlay like you described. Most of the time the default for new navs should be to a page with skeletons.
There may be other issues other than the performance, popstate bug, and stalling transitions for new navs but we can follow up if there are any bug reports.
All reactions
Awesome - thanks for all the back and forth on this! We'll likely be shipping our unstable opt-in flag in the next week or so 👍
All reactions
-
❤️ 2
@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
All reactions
That's the first demo showing how we are intending to use startTransition(() => Promise) for navigations with our new flag to also add useOptimistic to surface mid-navigation router state updates. The counter is also a transition and is properly entangled/synced with the navigation transition, as expected. Ignoring popstate (see next demo), everything works great so it doesn't seem there's any technical limitation to that usage of it. That's how we would like to ship "transition friendly navigations" in React Router.
Clicking the browser back button is not part of a transition, so it does show the router loading state, but it does not entangle the counter. This is expected/unavoidable, and behaves the same as if a user programmatically called navigate(-1) without a transition.
The second demo is slightly different to show the popstate issue on programmatic back navigations. It's similar with a transition-enabled counter to test entanglement with the navigation. It has a button that does startTransition(() => navigate('/a')) and a second one that does startTransition(() => navigate(-1)) and our expectation was they would work the same.
Expected behavior:
- Forward nav via
startTransition(() => navigate('/a'))shows router "loading" state and properly entangles mid-navigation counter updates - Backward nav via
startTransition(() => navigate(-1))shows router "loading" state and properly entangles mid-navigation counter updates
Actual behavior:
- ✅ Forward nav via
startTransition(() => navigate('/a'))shows router "loading" state and properly entangles mid-navigation counter updates - ❌ Backward nav via
startTransition(() => navigate(-1))does not show router "loading" state, but it does entangle mid-navigation counter updates- If the counter is clicked mid navigation, then the router loading state surfaces
This is what started the bsky thread - it was a surprising discovery and feels a bit like a footgun that navigate(-1) would work but if a user decides to add a transition, then it breaks the router-driven pending UI (useNavigation) - but all other navigations work inside transitions.
This is not really the primary issue any longer through. The convo evolved into "you cannot use async transitions for navigations" which is a much bigger issue for us and would prevent us from shipping transition friendly navigations in the short term.
All reactions
Yeah, I think that specific issue (setting useOptimistic in popstate is dropped if we can't complete synchronously) is a bug either in the router or in React, and I'll need to dig into to see what the issue is. I think the sync popstate thing is not what the real issue is, because as you can see in the sanbox I sent, optimistic state can work even with popstate.
The convo evolved into "you cannot use async transitions for navigations"
Just to clarify, you can. But I think there are very good reasons why you don't want to. And I also think there are good reasons why the router doesn't want to provide optimistic state for the router state.
I'm happy to go into to more detail on those (this sandbox shows some of the issues), but if the take away is going to be that I'm blocking or telling you what you can't do then I think it's fine to just ship what you have with a work around until we fix the useOptimistic bug (though users are going to hit the issues in the sandbox).
All reactions
This comment has a break down of the specific bug users will hit, which is also detailed in that sandbox.
All reactions
Just to clarify, you can.
Glad to hear this - this will allow us to ship the working solution we have today 👍
All reactions
-
👍 1