React router, how to restore state after browser back button?
Asked Answered
D

5

38

I'm trying to figure out how to make my React SPA app maintain state when the user navigates with the back/forward browser buttons. E.g. the user is filling a form, then he moves back and forward and the form is automatically restored.

I reviewed many JavaScript routers around but none seem to address this issue properly... (or even at all).

Currently I'm using React Router 4 and this is the pattern I'm adopting:

  1. when programmatically leaving the page, I first save current page's state with history.replace(this.state); then I move out of the page with history.push(newLocation)
  2. when a page component is created (componentWillMount) I check for this.props.location.state !== undefined and if so, I restore it with this.setState(this.props.location.state)

My question is: is the above pattern correct? suggested? Is there any better and widely adopted way?

Demantoid answered 26/11, 2017 at 11:36 Comment(5)
According to the docs, both history.push and history.replace have the same function signature, (path, [state]). Then why are you using history.replace with state as 2nd parameter ? I think you must use simply history.push(newLocation, this.state) instead of the 2 callsGoogly
is the state parameter in history.push to be meant as "the state of the current page that will get restored in case of back button" or is it "the state that the new page will take" ? This is very confusing.Demantoid
More like the second one I guess.. It's some state that you want to pass to the next page. So in your case, I think you can pass around the state of the formGoogly
ok @Googly got it, I pass the state the new page will get. What I don't understand is how I deal with the back button. When the user clicks it, I don't have any chance to run any code. Should I use history.replaceState() after every this.setState() to keep the current state updated (synchronized)?Demantoid
I think you would have to use some state management library like Redux to do that, cos maintaining state across routes is not exactly something suppoted by RR.. You pass states to next pages, but that's about itGoogly
D
19

after a while I found a reasonable workaround:

  • in react, after every this.setState() I keep state synchronized with history using window.history.replaceState({ key: history.location.key, state: this.state})
  • when a "page" component is mounted or refreshed (willMount and willReceiveProps) I check for state in props.params.location.state: if there is one, I do restore it; if there is none I create a fresh new state.
  • when navigating on the same page, I do not use routes, I just use this.setState and window.history.pushState
  • when navigating outside of the page, I just use routes and avoid to pass anything in the state

This solution seems to work nicely, the only minor cons are:

  • state must be serializable
  • this.setState is a pitfall because it's asynchronous, you cannot use this.state after it, unless you do trickery.
  • initial empty state must be provided by a function to be used during the restore or init phase, it can't just stay in the constructor() of the Component
  • in Firefox, automatic scroll restoration after the back button works randomly, don't know why, but Chrome and Edge are ok.

Overall I have written a PageComponent that extends Component that does all the init/restoration work; it also overrides this.setState to make it syncs automatically with history and avoids the asynchronous annoyances.

Demantoid answered 9/12, 2017 at 11:16 Comment(2)
This suggestion helps a lot. To resolve the setState syncing issue, you can pass a callback which than replaces the state. this.setState({ a: "b" }, function() { console.log("replace state here") });Vaclava
I like this approach. However, as far as I understood history.state has size limitations. What I did: After each setState I cache the result in sessionStorage, 2. Each time the component mounts I check if history.action==="POP" and if true I load the cached state version instead of fetching from server. Works so far but current drawback I see is that if the users reloads the page with F5 or browser button the history.action is still POPWed
P
1

You can also use the session storage instead of state. Just replace the useState() function and use the useSessionStorage().

For example:


const [myState, setMyState] = useSessionStorage("state_key", "initial_value")


const handleSomeChangeOnState = (event: any) => {
  setMyState("new_value")
}
Phelgon answered 27/8, 2021 at 13:4 Comment(0)
H
0

Use getDerivedStateFromProps and check if there has been a change in value of props.location.key to restore states only when the user navigates with browser buttons.

 static getDerivedStateFromProps(props,states){
         if ((typeof props.location !== 'undefined') &&
             (typeof props.location.state !== 'undefined') &&
              (states.location_key !== props.location.key)) {
             let newState = props.location.state;
             newState.location_key = props.location.key;
             return newState;
         }
    }
Holton answered 13/2, 2020 at 14:40 Comment(0)
D
0

When a React component un-mounts, the state is also destroyed. https://beta.reactjs.org/learn/preserving-and-resetting-state

You can use Redux https://redux.js.org/, the state management library to extract your state from the component. This way, even when your component unmounts, the state will persist as long as the Redux Provider remains mounted (which should be the case if you set your provider at the top level of your application).

Redux slice example

const mySlice = createSlice({
  initialState: { textFieldA: '' },
  name: "textFields",
  reducers: {
    setTextFieldA(state, action) {
      state.textFieldA = action.payload;
    }
  }
});

You'll want to create a Redux store and add your reducer to it.

In your React Component, use the redux slice.

import { setTextFieldA } from './slice'

function MyComponent() {
  const { textFieldA } = useSelector((state) => state.myReducer);

  function onTextFieldAChange(text) {
    dispatch(setTextFieldA(text));
  }

  // render component
}

Now if MyComponent unmounts, the state will persist in the store if I navigate away to a different part of my application. When I rerender MyComponent, textFieldA will remain the same as I left it.

Dealfish answered 3/1, 2023 at 21:47 Comment(0)
O
-1

i thought about this problem too, For example you have multi-step form and you want to navigate between steps by browser controls too. As result you have to use router for storing activeStep, but you don't want to share it in browser URL. The solution is store step in router state:

const location = useLocation();
const history = useHistory();

const onSubmit = useCallback(() => {
  history.push({
    ..location,
    state: {
      activeStep: 1
    }
  });
}, [...]);

As result your step will restore event after page refreshing, but you can't share step to other user. It's perfect.

The next problem is store form data between steps. We can't store it in router state, too. I've chosen for it sessionStorage. It has similar properties like router state. As result you have to update session storage fro example on submit and retrieve data on init form:

const location = useLocation();
const history = useHistory();
const [initData] = useState(getFormDataFromStorage());

const onSubmit = useCallback(() => {
  saveFormDataToStorage(...);
  history.push({
    ..location,
    state: {
      activeStep: 1
    }
  });
}, [...]);

i prepared the demo and GitHub where you can test it and details you can find here (RU)

Ornithosis answered 7/5, 2020 at 7:35 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.