FUNSTACK Router wraps navigations in React's startTransition, which means the old UI may stay visible while the new route loads. This page explains how this works and how to control it.
When the user navigates, the Router updates its location state inside startTransition(). This means React treats navigations as transitions: if an existing Suspense boundary suspends (e.g., a component loading data with use()), React keeps the old UI visible instead of immediately showing the fallback. This behavior is what React recommends for Suspense-enabled routers.
Consider a route with a loader that fetches data. The component uses use() to read the promise. Because the navigation happens inside a transition, the previous page remains on screen until the data is ready:
const routes = [
route({
path: "/user/:id",
loader: ({ params }) => fetchUser(params.id),
component: UserDetailPage,
}),
];
// The route component receives the Promise and provides a Suspense boundary
function UserDetailPage({ data }: { data: Promise<User> }) {
return (
<Suspense fallback={<p>Loading...</p>}>
<UserDetail data={data} />
</Suspense>
);
}
// A child component uses use() to read the data
function UserDetail({ data }: { data: Promise<User> }) {
const user = use(data);
return <div>{user.name}</div>;
}
// When navigating from /user/1 to /user/2:
// → The /user/1 page stays visible while /user/2 data loads
// → Once loaded, the UI swaps to /user/2 instantlyuseIsPendingWhile a transition is in progress, the useIsPending() hook returns true. Use it to give users visual feedback that something is loading — for example, dimming the current page or showing a loading bar.
import { useIsPending, Outlet } from "@funstack/router";
function Layout() {
const isPending = useIsPending();
return (
<div>
{isPending && <div className="loading-bar" />}
<div style={{ opacity: isPending ? 0.6 : 1 }}>
<Outlet />
</div>
</div>
);
}The isPending flag is also available as a prop on route components, so you can use it without calling the hook:
function Layout({ isPending }: { isPending: boolean }) {
return (
<div style={{ opacity: isPending ? 0.6 : 1 }}>
<Outlet />
</div>
);
}Sometimes you want to show a loading fallback immediately instead of keeping the old UI visible. This is especially useful when navigating between pages that share the same route but with different params — for example, going from /users/1 to /users/2, where showing stale data from user 1 while user 2 loads would be confusing.
The technique is to add a key prop to a <Suspense> boundary that changes with the route params. When the key changes, React unmounts the old Suspense boundary and mounts a new one, immediately showing the fallback:
import { Suspense } from "react";
function UserDetailPage({
params,
data,
}: {
params: { id: string };
data: Promise<User>;
}) {
return (
<Suspense key={params.id} fallback={<LoadingSpinner />}>
<UserDetail data={data} />
</Suspense>
);
}Because the key changes from "1" to "2" when navigating between users, React discards the old Suspense boundary entirely. The new boundary has no resolved content yet, so it shows the fallback right away — bypassing the transition behavior.
Use this pattern when stale content would be misleading. For navigations where the old page is still a reasonable placeholder (e.g., navigating between completely different pages), the default transition behavior is usually the better experience.
FUNSTACK Router allows you to save state in a navigation entry, which is useful for form state or other UI state that should persist when the user navigates back and forth. You can update this state using the setState and resetState functions passed to route components. These functions use a replace navigation internally, so they trigger a transition.
In rare cases, you may want to update navigation state without triggering a transition. setStateSync and resetStateSync are designed for this purpose. When you call them, the Router updates the current history entry using the Navigation API's updateCurrentEntry() method, which does not trigger a navigation. The Router detects this and applies the update synchronously, outside of startTransition. As a result:
useIsPending() (and the isPending prop) will not become true.When it comes to `setState` vs `setStateSync`, you can think in the same way as you would with wrapping state updates in `startTransition` or not. A general guideline is to just use setState (with transition) when you don't have a specific reason to avoid it. The exception is when the state change already happened on the screen and you just want to reflect it in the navigation entry state.
A typical example of this is a form where you want to save the current input value in the navigation state, so that if the user navigates away and then back, their input is preserved. In this case, you would call setStateSync in the input's onChange handler, because the state update is already reflected in the input's value and you don't want to trigger a transition for this:
function MyForm({ state, setStateSync }: { state: State; setStateSync: (state: State) => void }) {
const [inputValue, setInputValue] = useState(state.inputValue ?? "");
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value;
setInputValue(newValue);
setStateSync({ inputValue: newValue }); // Save to navigation state without transition
};
return <input value={inputValue} onChange={handleChange} />;
}In this example, using setState (with transition) would cause the UI to enter a pending state on every keystroke, which would be a poor user experience. By using setStateSync, the navigation state updates seamlessly without triggering transitions or pending states.