Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[v6] Adding useHistory hook #7476

Closed
asterikx opened this issue Jul 4, 2020 · 30 comments
Closed

[v6] Adding useHistory hook #7476

asterikx opened this issue Jul 4, 2020 · 30 comments

Comments

@asterikx
Copy link

asterikx commented Jul 4, 2020

I think the history object should be exposed in some way (also for backward-compatibility reasons).

My use case requires to listen to navigation changes before they happen (I have toast messages on my page that I need to dismiss before navigating to another route. Dismissing them after navigating to another route does not work as I might be dismissing toast messages on the page that I'm navigating to as well.)

history.listen seems to fit my use case (see here), but unfortunately, history cannot be accessed in v6.

@mwmcode
Copy link

mwmcode commented Aug 18, 2020

Also #5210

@sebascomeau
Copy link

I also need a way to display a Progress bar before the history change then in the final route element calling my progress context to stop the progress when the loading is done. Right now I have a useEffect on my ProgressContext and ViewComponent. The context useEffect is triggerd when it's mounted and also when the location's pathname changed. The useEffect in the view component call stop when the data fetch is done. The useEffect of the view is called before the context then my progress bar is never stopped. Adding a listener on the history could fix my issue by thriggering the context useEffect before the action location change then the view stopping it. Maybe I'm implementing this wrong.

@sebascomeau
Copy link

I found a solution to my probleme by creating a component that return <></> and put this component early in the app tree. Im using useLayoutEffect with my context function to start the progress when pathname change.

import React, { useLayoutEffect } from 'react';
import { useLocation } from 'react-router-dom';
import { useProgress } from './contexts';

const AppEffects = () => {
  const { pathname } = useLocation();
  const { startProgress } = useProgress();

  /**
   * Starts the page progress bar whenever the router path changes.
   */
  useLayoutEffect(startProgress, [startProgress, pathname]);

  return <></>;
};

export default AppEffects;

I need to call context stopProgress in the view compoents when the loading is done.

@malj
Copy link

malj commented Nov 8, 2020

I also needed history.listen so I ended up reimplementing v6 BrowserRouter:

import { createContext, useContext, useRef, useState, useLayoutEffect } from "react"
import { createBrowserHistory } from "history"
import { Router } from "react-router-dom"

const HistoryContext = createContext()

export const useHistory = () => useContext(HistoryContext)

export default function BrowserRouter({ children }) {
    const { current: history } = useRef(createBrowserHistory({ window }))
    const [{ action, location }, setHistoryState] = useState({
        action: history.action,
        location: history.location,
    })

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

    return (
        <Router action={action} location={location} navigator={history}>
            <HistoryContext.Provider value={history}>
                {children}
            </HistoryContext.Provider>
        </Router>
    )
}

My use case is a navigation overlay which needs to close each time user clicks a link. Using @sebascomeau's solution with useEffect and location.pathname isn't ideal, because the overlay should close even when clicking on the current page link (i.e. when pathname stays the same, but history changes).

Alternatively, you could consider exposing just the history change event via a hook, e.g. useHistoryListener:

import { useHistoryListener } from "react-router-dom"

useHistoryListener(state => {
    // the same callback you'd pass to `history.listen`
})

@GeoMarkou
Copy link

Some things feel impossible in v6 moving from v5

  1. We have a form on the page. If the user presses Back on their browser they will lose all their hard work so we display a Prompt. With usePrompt() and Prompt there is no longer an option to pass in a function for the 'message' parameter to customise whether to block at all depending on the URL or other state.

Before:

// Generic component used on every page with a form, it was even possible to display a custom UI for a better-looking prompt by storing location
<Prompt when={ true } message={location => {
    if (location.state?.noprompt) {
        return;
    }

    return NavigationBlockMessage;
}}>

const SubmitForm = async () => {
    await $.ajax({
        ...
    });

    // Simply pass in noprompt to the location.state when we don't need a dialog
    history.push('/complete', { noprompt: true });
}

v6:

const [ SubmitFormWorkaround, SetSubmitFormWorkaround ] = React.useState(true);
usePrompt(NavigationBlockMessage, SubmitFormWorkaround);

const SubmitForm = async () => {
    await $.ajax({
        ...
    });

    // Now we have to do a complex UseState workaround to not display a popup prompt...
    // This will also break if the user clicks other links while loading since the prompt has been disabled in general
    SetSubmitFormWorkaround(false);
}

React.useEffect(() => {
    if (!SubmitFormWorkaround) {
        navigate('/complete');
    }
}, [SubmitFormWorkaround]);

@rkingon
Copy link

rkingon commented Feb 12, 2021

I too am encountering this issue while trying to use React Albus (https://github.com/americanexpress/react-albus).

Maybe, a solid option would be to have the app create the browser history instance, then pass it to <BrowserRouter> as a prop? this way other areas of the app can use it without being coupled to React Router?

@amal-shaji
Copy link

amal-shaji commented Feb 25, 2021

I'm seeing this issue as well on top of an extremely wierd related issue with useLocation hook.

Any useEffect with location.pathname or any other property of location as a dependency will get rerun even when the location and location.pathname had never changed(actually location changes without route change) .

#7059 (comment)

I'm also working on overlays similar to @malj and I migrated from a perfectly functioning history.location as the useEffect dependency for the same code.

v6 unfortunately seems like two steps forward and three backwards because of this and the lack of history access.

@sebascomeau seems to be using location.pathname as a dependency. Does this work for you on 6.0.0-beta-0

@aubreyzulu
Copy link

aubreyzulu commented Apr 14, 2021

use this simple solution `

const App = ()=>{
const { pathname } = useLocation();

  useEffect(() => {
// when ever the location(pathname) changes do some stuffs in here 
    console.log(pathname);
  }, [pathname]);
} 

`

@ssh-peppe
Copy link

I second this. Another problem is that the navigation action (push, pop, replace) is not exposed. This means that if you have code that relies on it then migrating to v6 becomes hard or impossible. E.g. you might have code that always loads fresh data on a forward navigation, but restores the data from an internal cache and restores the scroll position on back navigation. Which one you should use is impossible to know without access to the navigation access. Whether it's the history as such, or functionality that's lost by not exposing it, is another matter.

@kraegpoeth
Copy link

I would love to be able to navigate to another route from inside my store (zustand js). window.location.href, is a hard refresh, so definately a no go. I cannot even pass a custom history into react-router's anymore as was possible in v5.

Only allowing navigation from hooks is too limited imo.

Other than that i really like the v6 - keep up the good work! 💪

@mjackson
Copy link
Member

My use case requires to listen to navigation changes before they happen

@asterikx If you really need to run some code before the URL changes, your best bet is to run it in your <Link onClick> handler. When the history.listen callback fires, the URL has already changed. So if you're setting up your own history.listen callback with the instance you get from useHistory, you're not going to be notified of the location change until after your <BrowserRouter> has already set state with the new location. But this won't even work for location changes that are initiated manually with the back button or by pasting a URL directly into the address bar.

What I think you mean by "listen to navigation changes before they happen" is you want to run some code before we set state with the new location. We don't currently have a hook for this, but maybe we could. We could probably even give you prevLocation and nextLocation.

function App() {
  useBeforeLocationChange((prevLocation, nextLocation) => {
    // run some sync code...
  });
}

Would that work for you?

@ssh-peppe
Copy link

ssh-peppe commented Jul 16, 2021

@mjackson The thing is, by hiding the history - which I totallly understand as a design decision as it's kind of the inner plumbing but not necessarily something you want publicly exposed as a formal API - you've also removed crucial things like the navigation action. There is now no way to know if the user navigated forward or backward (or using "replace") to the current page. And since what you do on a page can depend on it, that is a huge problem. Some examples:

  • A page might load fresh data and show a loading indicator if you navigate forward (even if cached data exists), but immediately restore cached data (and the scroll position) if you navigate back
  • Animations in a site or app can differ depending on the navigation direction. Forward might slide in a page from the right side, back might slide the current page out towards the right and reveal the previous page from the left.
  • A framework that implemetns scroll position restoration depends on knowing the navigation direction

A hook for the navigation action would help in this, but not having any other way to get these things than hooks is also a bit of an issue since hooks don't work nicely with class based components, which both still exist due to legacy reasons but also have their place in other cases e.g. where inheritance is very useful as a design pattern.

If you have an existing class-based component and you suddenly can't get access to a million things that you had access to before, since everything is suddenly hooks-based (and some things like the navigation action is not even exposed anymore), that's a really big issue.

@rkingon
Copy link

rkingon commented Jul 16, 2021

FWIW - I was originally +1 on useHistory, but then I just figured out how to write the code using the available APIs and i think it's actually better than what it was 😂

@mjackson
Copy link
Member

@ssh-peppe We could possibly add another hook for knowing which action triggered the navigation (something like useNavigationAction()) but in all the years I've spent working in React Router and the history library I have yet to find a good use case for the action. The use cases you mentioned are all valid, but they are also possible to build using location.key.

  • Store data in session/memory storage (depending on whether or not you want to keep it when visiting other domains) keyed by location.key. When a new location comes through, check storage. If it has an entry for location.key, you know it was a POP. Otherwise it was a PUSH/REPLACE.
  • Similarly for animations, keep a variable with the location.key of the last page you saw. If it matches the new location.key coming through, do a "back" animation. Otherwise, do a "forward" one.
  • Similarly for scroll restoration, store the last known scroll position of a page by location.key. When you see that location come through on navigation, use that scroll position. Just make sure you wait for data to load too! (We are planning on introducing some data loading hooks to the router that will make this easier in the near future)

As far as hooks go, they are a requirement for using v6. There are many well-known strategies for using hooks in class-based components. For example, make a hook-based component that gives you whatever data you need to know in a render prop or higher-order component.

import { useLocation } from "react-router-dom";

function GetTheLocation({ children }) {
  return children(useLocation());
}

class NoHooksComponent extends React.Component {
  render() {
    return (
      <GetTheLocation>
        {location => (
          // Render whatever you want with the location here.
        )}
      </GetTheLocation>
    )
  }
}

@mjackson
Copy link
Member

@rkingon Thanks for sharing 😅 I feel like the approach we are taking in v6 is going to eliminate a lot of bugs in people's apps!

@ssh-peppe
Copy link

@ssh-peppe We could possibly add another hook for knowing which action triggered the navigation (something like useNavigationAction()) but in all the years I've spent working in React Router and the history library I have yet to find a good use case for the action. The use cases you mentioned are all valid, but they are also possible to build using location.key.

AFAIK location.key does not work with the hash router, and does not allow you to differentiate between replace and push. And even if those were not issues, kludges using location.key are not exactly elegant solutions to something that has completely legit use cases and had a usable API in previous versions of react router.

Generally speaking, it would be great if react router would care more about API stability than it has in the past few years.

@mjackson
Copy link
Member

@ssh-peppe

AFAIK location.key does not work with the hash router

It will in v6. But honestly, you probably shouldn't be building anything with hash router anyway. It's only for legacy apps.

Generally speaking, it would be great if react router would care more about API stability than it has in the past few years.

We have had no major breaking API changes since we released version 4 on Sep 13, 2016, almost 5 years ago. The only reason version 5 was a major release was because of a dependency issue, not because of a breaking change. In addition, version 3 has received critical bug fixes all along the way.

We care deeply about API stability, and our release history shows it.

@ssh-peppe
Copy link

It will in v6. But honestly, you probably shouldn't be building anything with hash router anyway. It's only for legacy apps.

Absolutely, but that choice isn't always one you can make. It's often the case that organizations have huge amounts of existing apps that have been using hash based routing for whatever historical reason, and URLs and users have those bookmarked, the backends can't easily be updated, etc. and you have no choice but to use a hash router.

@mjackson
Copy link
Member

mjackson commented Aug 3, 2021

you have no choice but to use a hash router

... which is why we will still be shipping a <HashRouter> in v6. Aren't you glad we aren't breaking/deprecating that API? 😅

It doesn't seem like there's anything really to do on this issue, so I'm going to close. If anyone has a compelling use case for useHistory, let's discuss in another issue. I don't see any here.

@mjackson mjackson closed this as completed Aug 3, 2021
@joegrenan
Copy link

joegrenan commented Aug 14, 2021

you have no choice but to use a hash router

... which is why we will still be shipping a <HashRouter> in v6. Aren't you glad we aren't breaking/deprecating that API? 😅

It doesn't seem like there's anything really to do on this issue, so I'm going to close. If anyone has a compelling use case for useHistory, let's discuss in another issue. I don't see any here.

My compelling case is for applications that rely heavily on URL's and search param state, like search engines. Listening to the history in this case, and then firing off events based on the search params is very useful, as for example, if a search bar is nested within a header component, but the URL needs to change from within a button component (nested deep within a body component), using the search function from within the header component becomes difficult for the button component.

But...

If history could be listened to, the button would easily be able to push a new URL, and the parent component could simply render the changes to both the search bar, the results, and the corresponding buttons.

Is there a better alternative?

@rkingon
Copy link

rkingon commented Aug 15, 2021

@joegrenan are you familiar with React’s Context Api?

If the search bar gives the query to context provider, then button can easily be a consumer.

Personally, that is how I would set it up and not have the search bar update your url- just sent the signal to the provider

@ax0n-pr1me
Copy link

I am getting an odd issue that may or may not be related...

https://stackoverflow.com/questions/68825965/react-router-v6-usenavigate-doesnt-navigate-if-replacing-last-element-in-path

Best,
Jesse

@ssh-peppe
Copy link

... which is why we will still be shipping a <HashRouter> in v6. Aren't you glad we aren't breaking/deprecating that API? 😅

Not really, because by breaking/deprecating the API with regards to the history action and with regards to programmatic navigation without getting access to a navigation function using a React hook, and a few other things, this is going to be a major headache for several of our apps.

@eherz-abbvie
Copy link

eherz-abbvie commented Oct 25, 2021

I have to upvote this issue - I do feel like we are losing important functionality here.

Store data in session/memory storage (depending on whether or not you want to keep it when visiting other domains) keyed by location.key. When a new location comes through, check storage. If it has an entry for location.key, you know it was a POP. Otherwise it was a PUSH/REPLACE.

@mjackson In regards to this approach, I understand what you're saying, but the question for me is when are you checking location.key? This is the primary concern for me - a useEffect hook will only detect that the location changes during execution of the render method. Then, and only then will it realize that it needs to run. In my app, it is the navigation action that should cause a reset of some state.

Consider my use case - a component A has a routes block inside of it which changes its child component B based on the route. Route change causes component B to change, but component A remains the same component instance. Component B relies upon state passed from component A via props, so component A wants to reset state whenever there is a navigate.

Currently, what happens is that, upon navigation, the routing happens, then component A and component B render; at which point component A (by looking at the location during execution of the render method) finds that a 'navigate' has happened, and then subsequently another render due to the state update. It works, but it causes a visual 'flash' that is very nasty.

I see navigation as an asynchronous user input, and I don't see why it should be distinguished from any other user action, say, a button click, or something like that. In my mind, component A should be reacting to user input prior to the navigation happening ; but this is not practical behavior to encode into the button click handler..

@mrhut10
Copy link

mrhut10 commented Mar 29, 2022

I also needed history.listen so I ended up reimplementing v6 BrowserRouter:

import { createContext, useContext, useRef, useState, useLayoutEffect } from "react"
import { createBrowserHistory } from "history"
import { Router } from "react-router-dom"

const HistoryContext = createContext()

export const useHistory = () => useContext(HistoryContext)

export default function BrowserRouter({ children }) {
    const { current: history } = useRef(createBrowserHistory({ window }))
    const [{ action, location }, setHistoryState] = useState({
        action: history.action,
        location: history.location,
    })

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

    return (
        <Router action={action} location={location} navigator={history}>
            <HistoryContext.Provider value={history}>
                {children}
            </HistoryContext.Provider>
        </Router>
    )
}

My use case is a navigation overlay which needs to close each time user clicks a link. Using @sebascomeau's solution with useEffect and location.pathname isn't ideal, because the overlay should close even when clicking on the current page link (i.e. when pathname stays the same, but history changes).

Alternatively, you could consider exposing just the history change event via a hook, e.g. useHistoryListener:

import { useHistoryListener } from "react-router-dom"

useHistoryListener(state => {
    // the same callback you'd pass to `history.listen`
})

this was great thank you, however in my cause had to delay the update by using a useEffect rather than the ureLayoutEffect you used.

as we are using another listener to replace the route back to previous ( with a warning modal ) if detected they are navigating to something unrelated to the work flow of the form the are editing/entering.

@rsslldnphy
Copy link

Chiming in on this with a use case I think is reasonable and I do not know how to implement without access to the history object:

On our website, on mobile, we have links that cause a MUI drawer panel to slide in from the side of the screen. The panels have a header with a "back" button to slide them closed again, but it's very easy for the user to click the browser back button inadvertently. In this case we don't want to go back to the previous page, we just want to slide the panel closed again. To do this, as far as I can tell, we need to listen on the history object for POP events so we can intercept them when a drawer panel is open.

Some screenshots to better illustrate what I mean. The page is a list of sessions; when you click on a session a panel containing detailed information for that session slides in from the left.

image

image

@GeoMarkou
Copy link

GeoMarkou commented May 13, 2022

On our website, on mobile, we have links that cause a MUI drawer panel to slide in from the side of the screen. The panels have a header with a "back" button to slide them closed again, but it's very easy for the user to click the browser back button inadvertently.

In this case wouldn’t it be possible to store the selected session id in the URL? Either using a Route or a useLocation or useParams hook with conditional rendering. Then it should “just work” when the user presses back, or even refreshes the page.

example: /somewebsite/sessions/123
Or: /somewebsite/sessions?id=123

@rsslldnphy
Copy link

Thanks for the suggestion @GeoMarkou - will give this a go and report back.

@rsslldnphy
Copy link

yep that worked nicely, thanks again @GeoMarkou !

@arturohernandez10
Copy link

I'm using jotai for state management. With attempts to solve rendering issues. From the the top of their concepts page:

Jotai was born to solve extra re-render issues in React. An extra re-render is when the render process produces the same UI result, where users won't see any differences.

In jotai mounting or unmounting a component, does not always trigger a state change. Instead jotai provides an explicit way to create a hierarchy of atoms by referencing other atoms in code. Like functions referencing other functions. An update to an atom triggers changes to all the atoms according to dependency order, not (un)mount. Except that an atom cannot be updated without a navigation hook. As it stands, the lack of a listener in react-router means that there is no way to trigger an explicit state change triggered by navigation.

I may still file this issue under Jotai. They offer a complete API that works and covers all the use cases needed. But could it be that react-router is lacking here. I appreciate how providing less integration options makes it easier to offer new features. But integration is in itself a very important feature. And I think it has been overlooked.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests