DEV Community

loading...

React route refresh without page reload

zbmarius profile image Marius Zaharie ・3 min read

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:

  1. How about doing useHistory inside the hook -- 1st parameter down
  2. redirectPath seems to fit more with the returned refresh 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

Discussion

pic
Editor guide