react-router: [Bug]: How to navigate outside React context in v6?

What version of React Router are you using?

v6

Steps to Reproduce

In v6 docs, it mentions that we can use useNavigate() hook to do navigation, similar to in v5 we directly use useHistory() hook. However I am not sure how we can do the navigation outside React context in v6, cause in v5, your can manually provide a history object as a prop to Router component, and reference it in other places even it is not inside React context:

// myHistory.js
import { createBrowserHistory } from "history";

const customHistory = createBrowserHistory();

export default  customHistory

// otherFile.js
import history from './myHistory.js'

function doSomething() {
  history.push('/xxx')
}

But how we could achieve the same functionality in v6? I have see a few posts asking similar questions on stackoverflow, but currently there is no solution provided:

Expected Behavior

A common scenario from my experience was consider i have a redux thunk action creator that doing signup logic, and after sending request if success i wish the page can be navigate to home page:

// signupActions.js

export const signupRequest = (userData) => {
  return dispatch => {
    dispatch(signupPending())

    return axios.post('/api/user', userData)
      .then(res => dispatch(signupSuccess()))
      .then(() => {
        // doing navigation
        // navigate('/')
      })
      .catch(error => dispatch(signupFailure(error.message)))
  }
}

The action is outside React context so i am not able to use useNavigate() hook, Although i can do some refactor and move some logic to the React component, but i prefer to keep most business logic inside action since i wish the component are more responsible for UI rendering.

Actual Behavior

As mentioned above

About this issue

  • Original URL
  • State: closed
  • Created 3 years ago
  • Reactions: 53
  • Comments: 56 (10 by maintainers)

Most upvoted comments

Short answer: When your thunk is successful, change the state to something like "success" or "redirect" and then useEffect + navigate:

export function AuthForm() {
  const auth = useAppSelector(selectAuth);
  const dispatch = useAppDispatch();
  const navigate = useNavigate();

  useEffect(() => {
    if (auth.status === "success") {
      navigate("/dashboard", { replace: true });
    }
  }, [auth.status, navigate]);

  return (
    <div>
      <button
        disabled={auth.status === "loading"}
        onClick={() => dispatch(login())}
      >
        {auth.status === "idle"
          ? "Sign in"
          : auth.status === "loading"
          ? "Signing in..."
          : null}
      </button>
    </div>
  );
}

You can now use HistoryRouter (as of version 6.1.0) to maintain a global history instance that you can access anywhere:

import { createBrowserHistory } from 'history';
import { unstable_HistoryRouter as HistoryRouter } from 'react-router-dom';

let history = createBrowserHistory();

function App() {
  return (
    <HistoryRouter history={history}>
      // The rest of your app
    </HistoryRouter>
  );
}

history.push("/foo");

Can we reopen this issue?

@ryanflorence it still doesn’t answer the original question though, which is how to programmatically use navigation outside React components.

I believe it’s not about one particular example that can be fixed with a different workflow, but a more general and pretty demanded functionality that is missing from the new version, if the library is to be called a ‘fully-featured’ one. Would love to see a guide on how it can be done.

Related: https://github.com/remix-run/react-router/pull/8284, https://github.com/remix-run/react-router/issues/7970.

@timdorr after this much time has passed, I think it’s not really good to keep the unstable_ flag for the HistoryRouter. Why not just re-exporting history or createBrowserHistory in react-router-dom? This way devs can be sure they are using the same history version as react-router because it is coming directly from there.

source code reference: https://github1s.com/remix-run/react-router/blob/HEAD/packages/react-router-dom/index.tsx#L133

temporary plan, waiting for official support:

// history.ts

import { createBrowserHistory } from "history";

export const history = createBrowserHistory();

// BrowserRouter.tsx

import React from "react";
import { History } from "history";
import { BrowserRouterProps as NativeBrowserRouterProps, Router } from "react-router-dom";

export interface BrowserRouterProps extends Omit<NativeBrowserRouterProps, "window"> {
    history: History;
}

export const BrowserRouter: React.FC<BrowserRouterProps> = React.memo(props => {
    const { history, ...restProps } = props;
    const [state, setState] = React.useState({
        action: history.action,
        location: history.location,
    });

    React.useLayoutEffect(() => history.listen(setState), [history]);

    return <Router {...restProps} location={state.location} navigationType={state.action} navigator={history} />;
});

I had this problem and created custom HistoryRouter like that:

import { Update } from "history";
import { useLayoutEffect, useReducer } from "react";
import { Router } from "react-router-dom";

// your local created history
import { history } from "./history";

const reducer = (_: Update, action: Update) => action;

export const HistoryRouter: React.FC = ({ children }) => {
  const [state, dispatch] = useReducer(reducer, {
    action: history.action,
    location: history.location,
  });

  useLayoutEffect(() => history.listen(dispatch), []);

  return (
    <Router navigationType={state.action} location={state.location} navigator={history}>
      {children}
    </Router>
  );
};

@ryanflorence do you think this is something react router v6 will support natively? We are also running into this issue. Crucial use case for us: if an API returns 401, token has expired, we want to redirect user to login screen with a message. We previously were able to do this pretty easily. This has become challenging now that the history object is no longer being exposed (and we can’t use hooks in the API request code block)

@amazecc and @huczk thanks for your comments 😃

It works 👍

history.ts

// make sure to install `history` https://github.com/remix-run/history/blob/main/docs/installation.md
import { createBrowserHistory } from "history"

export const myHistory = createBrowserHistory({ window })
HistoryRouter.ts
// implementation from https://github1s.com/remix-run/react-router/blob/HEAD/packages/react-router-dom/index.tsx#L133-L137
import { ReactNode } from "react"
import { useLayoutEffect, useState } from "react"
import { History } from "history"
import { Router } from "react-router-dom"

export interface BrowserRouterProps {
  basename?: string;
  children?: ReactNode;
  history: History;
}
export function HistoryRouter({
  basename,
  children,
  history
}: BrowserRouterProps) {
  let [state, setState] = useState({
    action: history.action,
    location: history.location
  })

  useLayoutEffect(() => history.listen(setState), [history])

  return (
    <Router
      basename={basename}
      children={children}
      location={state.location}
      navigationType={state.action}
      navigator={history} />
  )
}

Than all you need is to wrap the <App /> with <HistoryRouter/>.

import { HistoryRouter } from "./HistoryRouter"
import { myHistory } from "./history"

ReactDOM.render(
    <HistoryRouter history={myHistory}>
        <App />
    </HistoryRouter>
  document.getElementById("root")
)

and use your myHistory where you want:

import axios from "axios"
import { myHistory } from "./history"

// Configure axios instance
const backendApi = axios.create(...)

backendApi.interceptors.response.use(function(response) {
  return response
}, async function (error) {

  if (error.response?.status === 403) {
    myHistory.replace(`/forbidden`) // Usage example.
    return Promise.reject(error) 
  }

  return Promise.reject(error)
})

This is a really common use case, so do we have a solution for this? Ideally, something that could be done in 1 or 2 lines of code like how it should be like, instead of requiring devs to moving backwards to unstable_HistoryRouter, which is obviously something v6 discourages devs from doing?

At the moment, the best solution for me if I stick to v6 is not to replace the root <Router /> (I don’t think using unstable_HistoryRouter is what I look forward to in production) but to manually pass a navigate hook from each component. This creates a lot of code redundancy and is much more prone to errors. Same thing for the solution presented by @ryanflorence, especially when you have to deal with the extra state/useEffect hook to handle simple programmatic navigation.

@timdorr According to the docs, the history package is no longer a peer dependency we need to have installed and it’s rather a direct dependency of react-router-dom (link)

So you are suggesting that we import a transitive dependency, which I do not like very much as a solution. (pnpm would also throw an error on this)

What if the react-router-dom package exported history-related utils itself instead?

have any offical sulution? history module is not available in v 6.4

Meanwhile, in Remixland… image

There’s the newly introduced “redirect” method to redirect from outside of the component. it requires version 6.4 I think it might help …

https://reactrouter.com/en/main/fetch/redirect

Before react router v6.4, I used this way to navigating outside of components.

import {createBrowserHistory} from 'history'

const history = createBrowserHistory({ window });

export const rootNavigate = (to: string) => {
  history.push(to);
};

createRoot(document.getElementById('root') as HTMLElement).render(
  <React.StrictMode>
      <HistoryRouter history={history}>
          <App />
      </HistoryRouter>
  </React.StrictMode>
);

But after update, history router no more available.

Is there a way now to navigating outside of components, without rollback to 6.3 version?

I’ll make a quick guide on this, it’s going to be a common question that we’ve already anticipated.

You can now use HistoryRouter (as of version 6.1.0) to maintain a global history instance that you can access anywhere:

import { createBrowserHistory } from 'history';
import { unstable_HistoryRouter as HistoryRouter } from 'react-router-dom';

let history = createBrowserHistory();

function App() {
  return (
    <HistoryRouter history={history}>
      // The rest of your app
    </HistoryRouter>
  );
}

history.push("/foo");

React Router v6 introduces a new navigation API that is synonymous with <Link> and provides better compatibility with suspense-enabled apps.

as described in the official docs, does this mean that i’d still be using history api and not navigate api, if i use unstable_HistoryRouter so there is now way to do it with navigate api outside react components ?

The unstable_ prefix just signifies you could encounter bugs due to mismatched versions of history from what react-router requests itself. While the practical implications are probably going to just create type issues (different types between versions of history), there could also be API or behavior changes that cause more subtle bugs (such as how URLs are parsed, or how paths are generated).

As long as you’re keeping an eye on the version of history requested by react-router and ensuring that’s in sync with the version used by your app, you should be free of issues. We don’t yet have protections or warnings against the mismatch, hence we’re calling it unstable for now.

@luwanhuang The above code only works when user switch to new router path.

We have refactored our code, use window.postMessage('NotAuthorized'); in axios.interceptors code, then listen to the message and do navigation.

axios.interceptors.response.use(
    (config) => {
        return config;
    },
    (error: ErrorResponse) => {
        if (error.response.data.statusCode === 401) {
            localStorage.removeItem('userInfo');
            window.postMessage('NotAuthorized');
        }
        return Promise.reject(error);
    }
);


export default function HomeView() {
    const navigate = useNavigate();
    const location = useLocation();

    function onNotAuthorized(event: MessageEvent) {
        if (event.data === 'NotAuthorized') {
            navigate('/login');
        }
    }

    useEffect(() => {
        sessionStorage.setItem('lastPage', location.pathname);

        window.addEventListener('message', onNotAuthorized, false);

        return () => {
            window.removeEventListener('message', onNotAuthorized, false);
        };
    }, [location]);

    return <LayoutView />;
}

Navigate is a component, not a function. I don’t think you can use it like that. https://reactrouter.com/docs/en/v6/components/navigate

Thanks for your reply, I just deleted my comment and double checked my code, and find out the real workaround is the following :

// api.ts
axios.interceptors.response.use(
    (config) => {
        return config;
    },
    (error) => {
        if (error.response.data.statusCode === 401) {
            localStorage.removeItem('userInfo');
        }
        return Promise.reject(error);
    }
);

then use useEffect in a global functional component, eg HomeView to detect if the app need to redirect to ‘/login’ page ,

export default function HomeView() {
    const navigate = useNavigate();

    useEffect(() => {
        const userInfo = localStorage.getItem('userInfo');
        if (!userInfo) {
            navigate('/login');
        }
    }, []);

    return <LayoutView />;
}

Let me summarize the key idea: even if you can’t do navigation outside React context use react-router v6, but you can change a shared state(in our case, it’s localStorage.userInfo) there, then you can do navigation in a global/shared functional component.

This is what I came up with for a simple solution… seems to work OK.

export let extNavigate: (
  value: string | PromiseLike<string>
) => void | undefined
const useExtNavigate = () => {
  const navigate = useNavigate()

  useEffect(() => {
    ;(async () => {
      while (true) {
        const navigatePath = await new Promise<string>((resolve) => {
          extNavigate = resolve
        })
        navigate(navigatePath)
      }
    })()
  }, [navigate])
}

Requirement: you need to call useExtNavigate from somewhere inside your React Router context, only then can you use this by calling extNavigate('/path')

@amazecc and @huczk thanks for your comments 😃

It works +1

Great, thanks from me too. I am actually using myHistory.replace/.push INSIDE of react components, as it does not trigger a re-render of all components (like useNavigate does), when the route changes. Hope this gets official support…