Recently I ran into an interesting bug in the app I'm working on that seemed like a bug in react-router. I wrote seemed* because it's not really a bug but something left unimplemented in react-router because it can be 'fixed' in multiple ways depending on the needs of the developer. (the issue on github)
Our particular issue was that we wanted to do a page refresh after a server call that was updating an item on a list rendered by current react route. The list is loaded based on some filters stored into a higher Context state. Additionally, our app was loaded into another app meaning that routes are mounted accordingly inside a specific subset.
Initially the code that had to do the page refresh was looking something like this
handleItemUpdateSuccess() {
history.push('app/list');
}
The problem here is that react doesn't refresh the component rendered at app/list
because nothing changed in the state.
Step 1
First solution I was thinking of was to dispatch
the getList
action and let the List component loading itself without doing anything with history. This might have been the right solution but seemed to be very specific to my List component.
Then I found a hack that worked just fine inside our standalone app.
handleItemUpdateSuccess() {
history.push('/');
history.replace(redirectPath);
}
Step 2
But because the app's router was mounted into a nested route, doing history.push('/')
un-mounts the entire react app loaded there. This means the whole context gets wiped.
Next step was to push back to the index route in the microfronted app.
history.push(MicroFrontendRoute.Index);
history.replace(redirectPath);
This one solves one problem, the react Context wrapping the routes remains untouched.
Step #3 -- solution
Now the problem is that this is not working properly all the time. Depending on how fast(or slow) the react router updates the location it manages to trigger a component un-mount or not! So if the second history update would be delayed a bit react would trigger it's un-mount hook..
After struggling to use setTimeout
into a JS method and not beeing able to properly do clearTimeout
in the same method, I've decided to extract everything into a custom hook:
# ../app/routes/useRefresh.ts
import { useEffect } from 'react';
import { MyAppRoute } from './MyAppRoute';
export default function useRefresh(history: any, path: string, resetRoute: string = MyAppRoute.Index) {
let handler: any;
const refresh = () => {
history.push(resetRoute);
handler = setTimeout(() => history.push(path), 10);
};
useEffect(() => {
return () => handler && clearTimeout(handler);
}, [handler]);
return refresh;
}
Now that all the refresh logic is safely encapsulated into its own hook, let me show you how easy it is to use it:
const history = useHistory();
const refresh = useRefresh(history, redirectPath);
const handleSuccess = () => {
if (history.location.pathname === redirectPath) {
refresh();
} else {
history.push(redirectPath);
}
};
const handleErrors = () => {
...
Afterthought
As I pasted the above code snippet, I'm starting to think my useRefresh hook api can be made even more simple. I mean no parameters:
- How about doing
useHistory
inside the hook -- 1st parameter down -
redirectPath
seems to fit more with the returnedrefresh
function -- 2nd parameter down too.
Will let you do the refactoring as an exercise ;)
Bonus
Here are some unit tests I wrote for the useRefresh
hook using jest and testing-library ..
import { renderHook, act } from '@testing-library/react-hooks';
import { createMemoryHistory } from 'history';
import useRefresh from '../../../../components/app/routes/useRefresh';
import { MyAppRoute } from '../../../../components/app/routes/MyAppRoute';
describe('useRefresh', () => {
const history = createMemoryHistory();
const subject = () => renderHook(() => useRefresh(history, MyAppRoute.List));
it('returns a function', () => {
const { result } = subject();
expect(typeof result.current).toBe('function');
});
it('redirect to the Index route and then back to the given path', async () => {
jest.useFakeTimers();
jest.spyOn(history, 'push');
const { result } = subject();
result.current();
expect(history.push).toHaveBeenCalledWith(MyAppRoute.Index);
act(() => {
jest.runAllTimers();
});
expect(history.push).toHaveBeenCalledWith(MyAppRoute.List);
expect(history.push).toHaveBeenCalledTimes(2);
});
it('clears the timeout', () => {
jest.useFakeTimers();
const { result, unmount } = subject();
result.current();
act(() => {
jest.runAllTimers();
});
unmount();
expect(clearTimeout).toHaveBeenCalledTimes(1);
});
});
If you know of a nicer way of refreshing a route with react-router than using setTimeout
:| let me know in the comments
Top comments (4)
What about forcing a component remount on route change
MyPage
will unmount and a new one mount every timelocation.key
changes.location.key
changes every time you push a new location, even if you navigate to the same URL the.key
still changes.I just discovered this and it's working great!
Good catch if it really changes when you push to the same location!!
Looks like it's in the
location
API v5.reactrouter.com/web/api/location.I'd just call the hook inside the routed component like:
thank you! great idea having empty page for refresh
for my case it was enough to use it without hook
This works great! thanks