'In React Router v6, how to check form is dirty before leaving page/route
Below are the package versions I'm using.
React version - 16.13.1
react-router-dom version - 6.0.0-beta.0
react-redux version 7.2.0
Material UI version 4.11.0
How/what is the best way to check that a form isDirty
(has changed) when the user is trying to leave the current page? I would like to prompt "Are you sure want to leave...." if the form isDirty
.
I will fetch the data from within useEffect()
and use a redux reducer to render the UI.
Should I declare a variable to keep the original fetched data for dirty checking?
This is what I am doing, but it is not working correctly.
component.js
useEffect(() => {
props.fetchUserInfo();
})
action.js
export function fetchUserInfo() {
return (dispatch) => {
dispatch({type: USER_INITIALSTATE, {Name: 'abc', Age: 20}}
)
}
}
userReducer.js
const initialState = {
processing: false,
success: false,
fail: false,
Profile: {}
}
let oriState;
let State;
const UserReducer = (state = initialState, action) => {
if (action.type === USER_INITIALSTATE) {
oriState = {Profile: action.data};
State = {...state, Profile: action.data};
return {...state, Profile: action.data};
} else if (action.type === OTHERS_ACTION) {
//update field change
return {...state, xxx}
}
}
export const userIsDirty = state => {
if (oriState && State) {
return JSON.stringify(oriState.Profile) !== JSON.stringify(State.Profile);
}
return false;
};
export default UserReducer;
So in my component I call userIsDirty
to return the isDirty boolean, but I haven't figured out how to catch the leave page event and use this as a trigger to do the dirty form checking.
So how to detect leaving the current page? I tried something on useEffect return(component umount), but the props is not getting the updated INITIALSTATE state (meaning I will get Profile: {}), because it only runs once, but if I add the useEffect optional array argument, I get an infinite loop(maybe I set it wrong?).
useEffect(() => {
props.fetchUserInfo();
return () => {
console.log(props); //not getting initial state object
};
}, []);
Am I doing this the correct way? What have I missed? Is there a better/correct solution to achieve what I want?
Updated
Thanks @gdh, useBlocker
is the one I want. I am using it to popup a confirmation dialog.
I will share my complete codesandbox, I believe this may be helpful for someone in the future.
Solution 1:[1]
Update:
Prompt, usePrompt and useBlocker have been removed from react-router-dom. This answer will not currently work, though this might change. The github issue, opened Oct 2021, is here
The answer...
This answer uses router v6.
- You can use usePrompt.
- usePrompt will show the confirm modal/popup when you go to another route i.e. on mount.
- A generic alert with message when you try to close the browser. It handles beforeunload internally
usePrompt("Hello from usePrompt -- Are you sure you want to leave?", isBlocking);
- You can use useBlocker
- useBlocker will simply block user when attempting to navigating away i.e. on unmount
- A generic alert with message when you try to close the browser. It handles beforeunload internally
useBlocker(
() => "Hello from useBlocker -- are you sure you want to leave?",
isBlocking
);
- You can also use beforeunload. But you have to do your own logic. See an example here
Solution 2:[2]
Just adding an additional answer for React Router v6 users.
As of v6.0.0-beta - useBlocker and usePrompt were removed (to be added back in at a later date).
It was suggsested if we need them in v6.0.2 (current version at the time of writing) that we should use existing code as an example.
Here is the code directly from the the alpha for these hooks.
So to add the hooks back in would be this code (anywhere in your app for usage):
** I only copied the code for react-router-dom - if you're using native, then you'll need to check the above link for the other usePrompt
hook
/**
* These hooks re-implement the now removed useBlocker and usePrompt hooks in 'react-router-dom'.
* Thanks for the idea @piecyk https://github.com/remix-run/react-router/issues/8139#issuecomment-953816315
* Source: https://github.com/remix-run/react-router/commit/256cad70d3fd4500b1abcfea66f3ee622fb90874#diff-b60f1a2d4276b2a605c05e19816634111de2e8a4186fe9dd7de8e344b65ed4d3L344-L381
*/
import { useContext, useEffect, useCallback } from 'react';
import { UNSAFE_NavigationContext as NavigationContext } from 'react-router-dom';
/**
* Blocks all navigation attempts. This is useful for preventing the page from
* changing until some condition is met, like saving form data.
*
* @param blocker
* @param when
* @see https://reactrouter.com/api/useBlocker
*/
export function useBlocker( blocker, when = true ) {
const { navigator } = useContext( NavigationContext );
useEffect( () => {
if ( ! when ) return;
const unblock = navigator.block( ( tx ) => {
const autoUnblockingTx = {
...tx,
retry() {
// Automatically unblock the transition so it can play all the way
// through before retrying it. TODO: Figure out how to re-enable
// this block if the transition is cancelled for some reason.
unblock();
tx.retry();
},
};
blocker( autoUnblockingTx );
} );
return unblock;
}, [ navigator, blocker, when ] );
}
/**
* Prompts the user with an Alert before they leave the current screen.
*
* @param message
* @param when
*/
export function usePrompt( message, when = true ) {
const blocker = useCallback(
( tx ) => {
// eslint-disable-next-line no-alert
if ( window.confirm( message ) ) tx.retry();
},
[ message ]
);
useBlocker( blocker, when );
}
Then the usage would be:
const MyComponent = () => {
const formIsDirty = true; // Condition to trigger the prompt.
usePrompt( 'Leave screen?', formIsDirty );
return (
<div>Hello world</div>
);
};
Solution 3:[3]
@Devb your question and update were super helpful and saved me a lot of time. Thank you! created a HOC based on your code. might be useful to someone. props on Wrapped Component:
setPreventNavigation - sets when to block navigation
provideLeaveHandler - sets the function that will run when you try to change a route and you are blocked for navigation
confirmNavigation - continue navigation
cancelNavigation - stop Navigation
import React, { useEffect, useState, useCallback } from 'react' import { useNavigate, useBlocker, useLocation } from 'react-router-dom' export default function withPreventNavigation(WrappedComponent) { return function preventNavigation(props) { const navigate = useNavigate() const location = useLocation() const [lastLocation, setLastLocation] = useState(null) const [confirmedNavigation, setConfirmedNavigation] = useState(false) const [shouldBlock, setShouldBlock] = useState(false) let handleLeave = null const cancelNavigation = useCallback(() => { setshouldBlock(false) },[]) const handleBlockedNavigation = useCallback( nextLocation => { if ( !confirmedNavigation && nextLocation.location.pathname !== location.pathname ) { handleLeave(nextLocation) setLastLocation(nextLocation) return false } return true }, [confirmedNavigation] ) const confirmNavigation = useCallback(() => { setConfirmedNavigation(true) }, []) useEffect(() => { if (confirmedNavigation && lastLocation) { navigate(lastLocation.location.pathname) } }, [confirmedNavigation, lastLocation]) const provideLeaveHandler = handler => { handleLeave = handler } useBlocker(handleBlockedNavigation, shouldBlock) return ( <WrappedComponent {...props} provideLeaveHandler={provideLeaveHandler} setPreventNavigation={setShouldBlock} confirmNavigation={confirmNavigation} cancelNavigation={cancelNavigation} /> ) } }
Solution 4:[4]
Posting this for someone who wants custom UI pop-up/modal box
instead for browser's default prompt
and they are using react-router (v4)
with history
.
You can make use of custom history
and configure your router
like
import createBrowserHistory from 'history/createBrowserHistory'
export const history = createBrowserHistory()
...
import { history } from 'path/to/history';
<Router history={history}>
<App/>
</Router>
and then in your custom prompt component you can make use of history.block
like
import { history } from 'path/to/history';
class MyCustomPrompt extends React.Component {
componentDidMount() {
this.unblock = history.block(targetLocation => {
// take your action here
return false;
});
}
componentWillUnmount() {
this.unblock();
}
render() {
//component render here
}
}
Add this MyCustomPrompt
in your components where ever you want to block navigation.
Solution 5:[5]
It seems you are looking for the beforeunload event.
Read carefully as not all browsers are compliant with event.preventDefault()
.
In the event handler you can do the checks you want and call prevent the window to close depending on your requirements.
Hope this helps.
Solution 6:[6]
The hooks @gdh mentioned in his answer were removed by the developers team of react-router. Because of that you can't use usePrompt
or useBlocker
with the current version of react-router (v6).
But the team mentioned they are heavily working on the features. reference
If somebody wants to implement the changes the remix team made to offer the functionalities of the hooks you can take a look at this answer from github. here
Solution 7:[7]
I was facing the same situation of attempting to utilize a customized "pleasant" UI confirmation dialog integrating with react router v6 beta's useBlocker
hook for blocking route transitions when the current route's form has unsaved modifications. I started with the code from the codesandbox linked in the UPDATED
section at the bottom of this question. I found this custom hook implementation to not work for all of my needs, so I adapted it to support an optional regular expression parameter to define a set of routes that should not be blocked. Also of note, the codesandbox implementation returns a boolean from the callback passed into useBlocker
, but I found this has no effect or usefulness, so I removed this. Here is my full TypeScript implementation of a revised custom hook:
useNavigationWarning.ts
import { useState, useEffect, useCallback } from 'react';
import { useBlocker, useNavigate, useLocation } from 'react-router-dom';
import { Blocker } from 'history';
export function useNavigationWarning(
when: boolean,
exceptPathsMatching?: RegExp
) {
const navigate = useNavigate();
const location = useLocation();
const [showPrompt, setShowPrompt] = useState<boolean>(false);
const [lastLocation, setLastLocation] = useState<any>(null);
const [confirmedNavigation, setConfirmedNavigation] = useState<boolean>(
false
);
const cancelNavigation = useCallback(() => {
setShowPrompt(false);
}, []);
const handleBlockedNavigation = useCallback<Blocker>(
nextLocation => {
const shouldIgnorePathChange = exceptPathsMatching?.test(
nextLocation.location.pathname
);
if (
!(confirmedNavigation || shouldIgnorePathChange) &&
nextLocation.location.pathname !== location.pathname
) {
setShowPrompt(true);
setLastLocation(nextLocation);
} else if (shouldIgnorePathChange) {
// to cancel blocking based on the route we need to retry the nextLocation
nextLocation.retry();
}
},
[confirmedNavigation, location.pathname, exceptPathsMatching]
);
const confirmNavigation = useCallback(() => {
setShowPrompt(false);
setConfirmedNavigation(true);
}, []);
useEffect(() => {
if (confirmedNavigation && lastLocation?.location) {
navigate(lastLocation.location.pathname);
// Reset hook state
setConfirmedNavigation(false);
setLastLocation(null);
}
}, [confirmedNavigation, lastLocation, navigate]);
useBlocker(handleBlockedNavigation, when);
return [showPrompt, confirmNavigation, cancelNavigation] as const;
}
Sources
This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.
Source: Stack Overflow
Solution | Source |
---|---|
Solution 1 | bbsimonbb |
Solution 2 | rmorse |
Solution 3 | |
Solution 4 | Prathap Reddy |
Solution 5 | Daniele Ricci |
Solution 6 | ChrKahl |
Solution 7 |