Skip to content

useLocation re-renders before navigating #3110

Open

Description

@Sheraff

🚨 To anyone stumbling here: this is not a bug, it's an issue with the documentation. All of us migrating from react-router-dom are used to using useLocation for everything, but with tanstack/router, there are different hooks for different purposes: https://tanstack.com/router/latest/docs/framework/react/api/router

👇👇👇👇👇 THIS IS THE SOLUTION TO YOUR PROBLEM 👇👇👇👇👇

Important

TL;DR:
useLocation is about the URL, you most likely do not care about the URL. What you care about is probably:

  • the routes => useMatch, useMatches, useChildMatches, useParentMatches
  • the params => useParams, useSearch

👆👆👆👆👆 THIS IS THE SOLUTION TO YOUR PROBLEM 👆👆👆👆👆


Which project does this relate to?

Router

Describe the bug

Assuming a simple route like this:

export const Route = createFileRoute('/about')({
  component: AboutComponent,
});

function AboutComponent() {
  const {pathname} = useLocation()
  console.log(pathname)
  return (
    <div className="p-2">
      <h3>About</h3>
    </div>
  );
}

My naive assumption would be that the only value pathname can ever have here is "/about", but in reality, this will also re-render just before navigating away, with pathname having the value of the next page.

Alternatively, we can also use the following:

const pathname = useRouterState({ select: (s) => s.resolvedLocation.pathname });

But in that case, pathname will initially have the value of the previous page before immediately re-rendering with the current page.

One way to access the pathname that seems stable is this:

const pathname = useMatch({ strict: false, select: (s) => s.pathname });

Your Example Website or App

https://stackblitz.com/edit/tanstack-router-fpv611d5?file=src%2Froutes%2F_yo%2Fabout.tsx

Steps to Reproduce the Bug or Issue

  1. Go to '/about' in the demo above
  2. open the console to see what gets logged
  3. Go to any other page

This will show that the console.log(pathname) (similar to the one in the code sample above) logs the next route even though it's only in the route component of /about.

The about.tsx file contains other commented-out methods of "logging the pathname" that demonstrate the various render behaviors.

Expected behavior

I'm not entirely sure what is "expected behavior" here. But my naive intuition would be that for all of these methods, since they're used inside the "/about" route, they should only ever render with the "/about" pathname.

Screenshots or Videos

Screen.Recording.2025-01-04.at.16.43.05.mov

Platform

  • OS: macOS
  • Browser: Chrome
  • Version: 1.95.0

Additional context

This might be the same behavior that causes #2318 because their demo uses

    const location = useLocation();
          <ErrorBoundary
            resetKeys={[location.pathname]}

Activity

  1. hobi9 commented on Jan 19, 2025

    @hobi9

    I’m on version 1.97.3 and noticed an issue with useMatch. When using the following code:

    const pathname = useMatch({ strict: false, select: (s) => s.pathname });

    It initially returns '/' but does not update when the route changes. As a temporary workaround, I switched to this approach:

    const { pathname } = useMatches().at(-1)!;

    This resolves the issue for now. Is this the intended behavior, or is this a bug in the useMatch implementation?

  2. Sheraff commented on Jan 19, 2025

    @Sheraff
    CollaboratorAuthor

    I’m on version 1.97.3 and noticed an issue with useMatch. When using the following code:

    const pathname = useMatch({ strict: false, select: (s) => s.pathname });

    It initially returns '/' but does not update when the route changes. As a temporary workaround, I switched to this approach:

    const { pathname } = useMatches().at(-1)!;

    This resolves the issue for now. Is this the intended behavior, or is this a bug in the useMatch implementation?

    This seems like a different thing. Is your useMatch on a layout by any chance? If so, I think that's intended.

  3. eylamk commented on Jan 24, 2025

    @eylamk

    I hope someone is going to take care of it... this is a tough one causing lots of redundant re-renders.

  4. misium commented on Feb 6, 2025

    @misium

    This bug got me good today. Looks like it overlaps with #1691.

    Reproducing

    (We can probably ignore the beforeLoad option in the Route, but leaving it in comment as I didn't test it exhaustively.)

    export const Route = createFileRoute('/_auth')({
      // beforeLoad: async ({ context, /* location */ }) => {
      //   try {
      //     return await context.queryClient.ensureQueryData(checkLoginQuery);
      //   } catch (e) {
      //     throw redirect({ to: '/login', search: { redirect: /^(\/?(login\/?)?)?$/.test(location.pathname) ? undefined : location.href } });
      //   }
      // },
      component: AuthWrapper
    });
    
    function AuthWrapper() {
      const checkLogin = useQuery(checkLoginQuery);
      const location = useLocation();
    
      if (checkLogin.isError)
        return <Navigate to='/login' search={{ redirect: /^(\/?(login\/?)?)?$/.test(location.pathname) ? undefined : location.href }} />
    
      return <Outlet />
    }
    

    Once checkLogin.isError is true, <Navigate> is rendered, changing the location object, all as expected. But as @Sheraff pointed out, our current route's <AuthWrapper> unexpectedly re-renders despite location changing, which returns <Navigate> again, changing the location object again, etc. in an infinite loop.

    Expected Behavior

    Like OP, I naively expected useLocation() to match the current rendered route!

    Current Fix

    Digging in, it looks like <Navigate> causes location.state.key to change, even if location.href etc. isn't changing. Once location changes, the component is re-rendered, and we're in a loop. So to break the loop, I just forced location to return only the stable values I needed, which stops the re-rendering, in my case:

    const { pathname, href } = useLocation({
        select: ({ pathname, href }) => ({ pathname, href }),
        // structuralSharing: true // this probably SHOULD be on by default but wasn't necessary for the fix
      });
    

    Returning <Navigate> multiple times will NOT change pathname and href, so the component no longer re-renders and the loop is broken.

  5. xsjcTony commented on Feb 8, 2025

    @xsjcTony

    I'm not entirely sure what is "expected behavior" here. But my naive intuition would be that for all of these methods, since they're used inside the "/about" route, they should only ever render with the "/about" pathname.

    This is indeed so true.

    Based on the conversation, and ours in the Discord channel, the key point is that Is this really the intended behaviour that the previous route's component doesn't unmount until pendingComponent is shown.

    Honestly, myself doesn't see any benefit from this default behaviour at all, where it only adds anti-intuitive behaviour of the useLocation() hook.

    If a navigation is happening, the result is guaranteed to be either the error component or the new route's component, unless I'm missing something. In each case, the old route's component won't be needed at all.

    🤔 So why don't we just unmount the previous route's component before the async navigation starts? At least I think we should have an opt-in way or make it the default behaviour when pendingMs is 0?


    I don't want to ping any maintainers (and I don't know who exactly to ping) here straight away as they are already pretty busy, but hopefully someone from the official team can give an explanation about the intention behind the scene and what's the recommended way...🙏


    And regarding documentation, since a lot of guys are migrating from react-router-dom, I think we should at least

    • Add a section about the exact navigation lifecycle and how it works with the route's component.
    • Add a pitfall section in useLocation() hook, as it's anti-intuitive that it re-renders in the previous route's component when navigating.
  6. tannerlinsley commented on Feb 8, 2025

    @tannerlinsley
    Member

    This is valuable feedback. I’ll discuss with the team and see what we can come up with to alleviate it. At the very least I would immediately accept a PR to improve the docs.

  7. eylamk commented on Mar 24, 2025

    @eylamk

    Is there any update on this issue? It's a significant problem—every time a navigation is performed, the component for the 'from' route is rendered using the 'to' URL. If the 'from' route’s component includes a path parameter, it will be rendered without it, which causes an error.
    @tannerlinsley, please prioritize it.

  8. GarrisonBates commented on Jul 29, 2025

    @GarrisonBates

    +1 for this issue, I just ran into some really strange behavior when migrating from React Router which led to infinite redirect loop, caused by this. Was very unexpected.

  9. AnderssonPeter commented on Sep 2, 2025

    @AnderssonPeter

    I think I have found something that works more like useLocation in react router.

    import { useRouterState } from "@tanstack/react-router";
    import { useRef, useMemo } from "react";
    
    export default function useStableLocation() {
        const { isLoading, pathname } = useRouterState({
            select: state => ({ isLoading: state.isLoading, pathname: state.location.pathname })
        });
        const matchingLocation = useRef<string>(pathname);
        return useMemo(() => {
            if (!isLoading && matchingLocation.current !== pathname) {
                matchingLocation.current = pathname;
            }
            return matchingLocation.current;
        }, [isLoading, pathname]);
    }
  10. ILliDan04 commented on Nov 9, 2025

    @ILliDan04

    Recently encountered related bug, lead to fatal behaviour in our case. We have 2 pages A and B. When B mounts, webcam access is requested. Once the user finishes its session on page B, navigate is performed back to page A. But for whatever reason, page B is re-mounted again, and camera permission is asked again. Only then redirect to page A is performed. So we are left with page A and camera permission modal on it.
    When adding const { pathname } = useLocation() to page B, console.log(pathname) outputs page A after calling navigate.
    What's also worth noticing, that page B is not just rerendered, but fully remounted again, so that all states/refs/effects are re-initialized again.
    Current workaround is to add this to root of page B:

      const location = useLocation();
      if (!location.pathname.includes("page-b")) {
        return null;
      }
    

    Looks like unintended behaviour. Please correct me if I am wrong.

  11. ryan-tote commented on Feb 24, 2026

    @ryan-tote

    We recently did a migration from react router to tanstack router and this was a pain point for us. It seems like useLocation should return the resolvedLocation by default since that is what users would expect 99% of the time. Would be nice if useLocation took a prop of

    {
        resolved: boolean
    }

    that defaults to true and then if someone does want the current behaviour they can always change it to false.

    My current custom hook.

    export const useMatchedLocation = () => {
      const matchedRoute = useMatches().at(-1);
    
      const resolvedLocation = useRouterState({
        select: (state) => {
          return state.resolvedLocation ?? state.location;
        },
      });
    
      if (!matchedRoute) {
        throw new Error("No matched route found");
      }
    
      const searchStr = Object.keys(matchedRoute.search).length
        ? `?${Object.entries(matchedRoute.search)
            .map(([key, value]) => {
              return `${key}=${String(value)}`;
            })
            .join("&")}`
        : "";
    
      return {
        ...resolvedLocation,
        pathname: matchedRoute.pathname,
        search: matchedRoute.search,
        searchStr,
      };
    };
  12. AnderssonPeter commented on Feb 25, 2026

    @AnderssonPeter

    I think I have found something that works more like useLocation in react router.

    import { useRouterState } from "@tanstack/react-router";
    import { useRef, useMemo } from "react";

    export default function useStableLocation() {
    const { isLoading, pathname } = useRouterState({
    select: state => ({ isLoading: state.isLoading, pathname: state.location.pathname })
    });
    const matchingLocation = useRef(pathname);
    return useMemo(() => {
    if (!isLoading && matchingLocation.current !== pathname) {
    matchingLocation.current = pathname;
    }
    return matchingLocation.current;
    }, [isLoading, pathname]);
    }

    We changed our implementation to the following:

    export default function useStableLocation() {
        const { isLoading, resolvedLocation, location } = useRouterState({
            select: state => ({
                isLoading: state.isLoading,
                resolvedLocation: state.resolvedLocation?.pathname,
                location: state.location.pathname
            })
        });
    
        return useMemo(() => {
            const currentLocation = isLoading ? (resolvedLocation ?? location) : location;
    
            return {
                location: currentLocation
            };
        }, [isLoading, resolvedLocation, location]);
    }

    The one i provided before had some issue but i can't remember what.

  13. Joseph7451797 commented on Jul 14, 2026

    @Joseph7451797

    We hit this in production as a hard crash (React error #185, "Maximum update depth exceeded"), so adding a data point plus a minimal reproduction, since this issue seems to be the canonical home for the "old component re-renders with the NEW location before unmounting" behavior (#3340 was closed into here).

    Versions: @tanstack/react-router 1.170.16 / @tanstack/router-core 1.171.13 (the Navigate implementation on current main is identical as of today).

    What happens

    A render-time redirect guard (the classic if (!user) return <Navigate to="/" search={{ redirect }} replace /> pattern) can self-loop into React #185. Four ingredients, each individually deliberate:

    1. <Navigate> fires from a layout effect guarded by a reference comparison (previousPropsRef.current !== props, added in fix(react-router): only navigate once in StrictMode #3465 to dedupe StrictMode double-invocations). JSX produces a fresh props object every render, so any re-render/remount of the parent fires navigate() again.
    2. Since Fix: Prevent Flicker When Navigation Happens During Render #5905, that effect is useLayoutEffect (anti-flicker), so re-fires are synchronous — React counts them against the nested-update limit.
    3. commitLocation handles a navigation to the current URL with if (isSameUrl && isSameState()) this.load() (a feature, per Router does not trigger loader or beforeLoad when navigating to same url #1324), and load() synchronously updates router state.
    4. Per this issue, during a pending transition (e.g. the destination route's chunk/loader still resolving) the old match stays mounted and re-renders with the new location. A guard that derives its redirect target from the current location (carrying an existing ?redirect= param forward) now renders a <Navigate> whose target equals the URL the router is already at.

    Cycle: layout effect → navigate() to the current URL → load() → state update → re-render/remount of the still-mounted guard → fresh props object → layout effect fires again → … → React throws #185, surfacing via onunhandledrejection.

    Production stack trace excerpt (minified React 19, abbreviated — the loop frames):

    useNavigate.js:44  navigate(props)                      // <Navigate> layout effect
    router.js:399      if (isSameUrl && isSameState()) this.load();
    router.js:540      this.startTransition(async () => {
    Transitioner.js:23 setIsTransitioning(true);
    react-dom: dispatchSetStateInternal → … → Error #185 (Maximum update depth exceeded)
    

    Minimal reproduction (vitest + jsdom, ~50 lines)

    Visiting /dashboard while logged out throws #185 ~50 times as unhandled rejections; the same happens in a real browser given enough latency on the destination chunk.

    import { render, waitFor } from '@testing-library/react'
    import {
      createMemoryHistory, createRootRoute, createRoute, createRouter,
      Navigate, Outlet, RouterProvider, useLocation,
    } from '@tanstack/react-router'
    import { z } from 'zod'
    
    function AuthGuard() {
      const location = useLocation()
      const user = null // permanently logged out — no auth-state flipping involved
      if (!user) {
        // Capture the requested path so login can return to it; when re-rendered
        // at `/` (new location, old tree not yet unmounted), the existing
        // `redirect` param is carried forward — the target now equals the current URL.
        const params = new URLSearchParams(location.searchStr)
        const redirect = location.pathname.slice(1) || params.get('redirect') || undefined
        return <Navigate to="/" search={redirect ? { redirect } : {}} replace />
      }
      return <Outlet />
    }
    
    it('<Navigate> same-url self-loop crashes with React #185', async () => {
      const rootRoute = createRootRoute()
      const indexRoute = createRoute({
        getParentRoute: () => rootRoute,
        path: '/',
        validateSearch: z.object({ redirect: z.string().optional().catch(undefined) }),
        // Stands in for a lazy chunk still downloading: keeps the router in a
        // pending transition, so the old match (AuthGuard) stays mounted.
        loader: () => new Promise((resolve) => setTimeout(resolve, 100)),
        component: () => <div data-test="login" />,
      })
      const guardRoute = createRoute({
        getParentRoute: () => rootRoute,
        id: 'authenticated',
        component: AuthGuard,
      })
      const dashboardRoute = createRoute({
        getParentRoute: () => guardRoute,
        path: 'dashboard',
        component: () => <div data-test="dashboard" />,
      })
      const router = createRouter({
        routeTree: rootRoute.addChildren([indexRoute, guardRoute.addChildren([dashboardRoute])]),
        history: createMemoryHistory({ initialEntries: ['/dashboard'] }),
      })
    
      const { queryByTestId } = render(<RouterProvider router={router} />)
      await waitFor(() => expect(queryByTestId('login')).toBeInTheDocument(), { timeout: 3000 })
      // observe ~50 unhandled rejections: "Maximum update depth exceeded"
    })

    Workaround we shipped

    A drop-in <Navigate> replacement that skips the call when the built target already equals router.latestLocation — the exact comparison commitLocation's isSameUrl uses — so a committed redirect can never re-trigger itself:

    export function SafeNavigate(props: NavigateOptions) {
      const router = useRouter()
      useLayoutEffect(() => {
        const target = router.buildLocation(props)
        if (trimSlash(router.latestLocation.href) === trimSlash(target.href)) return
        router.navigate(props)
      }, [props, router])
      return null
    }

    Suggestion

    Would you accept a PR making the declarative <Navigate> component skip same-URL navigations (or compare its props by value rather than reference)? The same-URL → load() behavior makes sense for the imperative navigate() (that's #1324's use case: an explicit call should re-run loaders), but for the declarative component it means "still rendered + already there" escalates into a synchronous self-loop and a crash. Happy to contribute the fix and port the reproduction above into the test suite.

  14. Maher-Reven commented on Sep 24, 2026

    @Maher-Reven

    There are two answers circulating in this thread that contradict each other — the pinned one at the top recommends useMatch({ strict: false }), and @hobi9 reported that it returns / and never updates. Both are right, and which one you get depends on where you call it. Here are the measurements.

    Setup

    @tanstack/react-router 1.170.39, React 19.3.0, two routes, a probe component that records every accessor on every render. Navigating /about → /other by clicking a <Link>, with the mount renders discarded so only the navigation shows.

    The outgoing component still renders with the new location

    This part reproduces exactly as reported. The /about component renders once more before unmounting:

    accessor value during that last /about render
    useLocation().pathname /other
    useRouterState({ select: s => s.location.pathname }) /other
    useRouterState({ select: s => s.resolvedLocation.pathname }) /about
    useMatch({ strict: false, select: s => s.pathname }) /about
    useMatches().at(-1).pathname /about

    resolvedLocation is not a clean substitute

    It is correct on the way out but stale on the way in. The first render of the newly mounted /other component still reports /about, and only corrects on a later render:

    render 2  [other-component]  resolvedLocation = /about   ← stale
    render 3  [other-component]  resolvedLocation = /other
    

    So swapping useLocation for resolvedLocation moves the wrong value from unmount to mount rather than removing it. That matches the note in the original post, and it is worth knowing before adopting it as a fix.

    Why useMatch({ strict: false }) disagrees with itself

    It resolves to the closest match in the component tree, so the answer depends on where the hook lives. Same run, same navigation, one probe inside the route component and one in the root layout:

    called from useMatch({ strict: false }) useMatches().at(-1)
    inside the /about route component /about ✅ tracks the route /about ✅
    root layout, outside <Outlet /> / on every render /about → /other ✅

    In the root layout the closest match is the root route, whose pathname is /, so it is constant by construction — exactly what @hobi9 saw. Not a regression, and not a bug in useMatch; it is the documented "closest match" semantics meeting a component that sits above every route.

    Practical summary for anyone arriving here:

    • Inside a route component → useMatch({ strict: false, select: s => s.pathname }).
    • In a layout above the routes → useMatches().at(-1), which was @hobi9's own workaround and is the one that holds in both positions.
    • useLocation is about the URL, which during a navigation is already the destination.

    Docs

    @tannerlinsley said he would immediately accept a PR improving the docs here. I am happy to write it — a short "reading the current route" section covering the table above, the two positions, and why resolvedLocation is not the shortcut it looks like. Say the word and I will open it against the useLocation / useMatch API pages.

    That would not address the underlying behaviour that @Joseph7451797 and others are hitting as production crashes — the outgoing component rendering with the new location is still the thing to fix — but it would stop people finding this issue and then picking the workaround that is wrong for their call site.

    Measured with Playwright against a production build, Chrome, macOS arm64.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions