react-router icon indicating copy to clipboard operation
react-router copied to clipboard

[Feature]: Update search params without re-rendering everything

Open cristianoccazinsp opened this issue 3 years ago • 33 comments

What is the new or updated feature that you are suggesting?

After migrating from v5 to v6, I noticed some new unexpected re-renders.

In short, the app updates query-string parameters (e.g., selected object id in a table) so if the user reloads the browser, or copies the link, the state is preserved. In version 5, changing the query string values would not cause a re-render from the very first <BrowserRouter> component. In v6, changing the query string (using navigate, or the new useQueryParams hook), will always cause a full re-render from <BrowserRouter>.

Is there a way, or would it be possible to allow for path/query string manipulations without firing a re-render?

Why should this feature be included?

Use cases where query-string manipulation is done only to preserve state, but not to do any actual react state changes.

cristianoccazinsp avatar May 27 '22 16:05 cristianoccazinsp

Updating search params is considered navigation, so re-rendering seems appropriate. If you need to maintain state, then I suggest using location.hash, as that's considered local state, and should not trigger navigation.

kiliman avatar May 27 '22 17:05 kiliman

Is there any way to perhaps update it without triggering the re render? With a private API or something. Re working everything to use hash routes is a major effort, not to mention hash params look really ugly.

cristianoccazinsp avatar May 27 '22 17:05 cristianoccazinsp

Have you looked into location.state? This allows you to store state per location without affecting the URL.

https://github.com/remix-run/history/blob/main/docs/api-reference.md#locationstate

kiliman avatar May 27 '22 18:05 kiliman

The goal is to actually affect the URL, so let's say if the user refreshes the browser, the component can restore its state from w/e was stored in the URL. Similar idea to allow external links to point to a specific place in the app. I agree this could live in the hash portion of the URL as well, but it is just not friendly to the user or even sharing/bookmarking links.

cristianoccazinsp avatar May 27 '22 18:05 cristianoccazinsp

Actually, updating the hash part of the location will also cause a router re render:

this.props.navigate(
      `${this.props.location.pathname}#${params.toString()}`,
      { replace: true }
    );

cristianoccazinsp avatar May 27 '22 18:05 cristianoccazinsp

Just curious, but would History API's pushState function help here? With that you can freely change your urls (as long as you don't modify the origin), and on page refresh, react-router will reload based on the contents of that url. Here's a Stack Overflow post with more information: https://stackoverflow.com/a/3503206/19108226

orderedspinach avatar May 30 '22 10:05 orderedspinach

I did try using the history API, and it works at fist glance. However, react-router ends up out of sync so any code attempting to read from location, queryParams, etc., would break. Not saying it's not possible, as such code could just be updated to read the query string parameters from the history/location API as well instead of react-router, but it's a really bad side effect.

cristianoccazinsp avatar May 30 '22 12:05 cristianoccazinsp

So, when you update the query params, you also want the relevant hooks (location, query params) to be updated as well? It seems to me like a re-render should then be expected because how else should that data be updated? Also, what is the consequence of these re-renders? For example, is it merely a performance issue?

orderedspinach avatar May 31 '22 03:05 orderedspinach

The re renders are considerably slow (compared to v5), and can't even be memoized / used with PureComponents.

Here's an example of what we have, maybe it helps with some other ideas (wall of text ahead).

  1. A main component that renders all routes, left side navigation, top-side header, and the "inner" component the route will end up picking. Left/top side components are also within a route as they may change based on the current route.

  2. The inner components usually consist of a table, and a detail section. Each time an item is selected in the table, the detail section renders. This is managed by component state/props and there's no global state. Further, each time an item is selected the id is also stored in the query string, so if the user reloads, they are taken right where they left (this is where it re renders). The component then reads the query string parameter on mount in order to set its default state and continue were it left over.

  3. In the above situation, the query string is only used to load / store an id so the table selection survives both page reloads, and can also be navigated right into (e.g., from other pages by setting the query string parameter). Re renders are fine when we change the actual page path, but when only changing the query string id on item selection, the entire page will re render from the very top <BrowserRouter>, will re compute all routes (slow), re render both left and top side navigation menus, re render the entire table, and re render the item details.

Of course, some components (e.g., tables) are more or less optimized to only re render if items changed, so it is not that bad (still usable without the debug console open). You could say, optimize all your components to only re render if location.pathname changed then, but this wasn't needed in v5, and it would be extremely verbose to basically re write every single component that may listen to any of the navigation props to always check if pathname changed.

cristianoccazinsp avatar May 31 '22 13:05 cristianoccazinsp

I have a similar use-case that I'd like fixed, but with the hashpart.

I have an SPA with react-router. In one of the pages, the user can copy links to certain parts of the page like you'd expect with anchors & links, e.g. example.com/page#subsection, where #subsection points to a part of the page. But currently, there is no way to set the current page location (for feedback purposes) to #subsection without getting a full re-render of the page.

romgrk avatar Jun 10 '22 11:06 romgrk

I used this custom hook from @kentcdodds’s personal site https://github.com/kentcdodds/kentcdodds.com/blob/main/app/routes/blog.tsx#L185 for a similar use case.

theMosaad avatar Jul 21 '22 00:07 theMosaad

@theMosaad that is just like manually calling pushState , which will basically leave react router out of sync if anywhere else you access the query params?

cristianoccazinsp avatar Jul 21 '22 00:07 cristianoccazinsp

@cristianoccazinsp as far as i remember, it only rerenders the component and, therefore, react router gives you the new search params when you use it inside that component.

theMosaad avatar Jul 21 '22 10:07 theMosaad

Same use case (keeping a table's search filters as URL parameters so that the URL can be shared/bookmarked).

Except I'm using the provided useSearchParams hook. The setter triggers a re-render. Re-render loses all table viewport/paging positioning. Not a great UX.

Note: Tried using setSearchParams option replace: true and the default (push history). Both trigger a re-render

dcworldwide avatar Jul 31 '22 11:07 dcworldwide

Are maintainers willing to accept a PR that fixes this ?

Edit : to be more specific, only the components that uses the hook and deconstructed a property that changed would re-render.

const [searchParams] = useSearchParams(); // Re-render every time any search param changes
const [{ page }] = useSearchParams(); // Re-render only when `page` changes

JesusTheHun avatar Aug 06 '22 22:08 JesusTheHun

I have a similar use-case, is there a solution in mind?

cgil avatar Aug 18 '22 14:08 cgil

This is a similar issue as #7634 useSearchParams reads from the LocationContext, NavigationContext and RouteContext (through useNavigate). So when a change is made to any of these, useSearchParams is updated as well.

Without going as far as some of the solutions suggested in the other issue, I wonder if it would be possible to improve memoization and dependencies to reduce these re-renders.

zhouzi avatar Aug 25 '22 12:08 zhouzi

This is a similar issue as #7634 useSearchParams reads from the LocationContext, NavigationContext and RouteContext (through useNavigate). So when a change is made to any of these, useSearchParams is updated as well.

Without going as far as some of the solutions suggested in the other issue, I wonder if it would be possible to improve memoization and dependencies to reduce these re-renders.

Yes you can make your component fully controlled and memoized and have a wrapper that inject the router params in it :

const Wrapper = () => {
  const [searchParams] = useSearchParams();
  return <Wrapped id={searchParams.id} />
}

const Wrapped = React.memo(({ id }) => `Item ${id}`); 

This comes at the cost of a memoization, but sometimes it's worth it.

JesusTheHun avatar Aug 25 '22 13:08 JesusTheHun

Any updates on this? I'm also facing a re-render when attempting to modify the URL based on user actions when using useSearchParams

wiznotwiz avatar Sep 07 '22 14:09 wiznotwiz

This is probably unrelated to what most of you are experiencing, but since I was convinced this was my issue for a while, I thought I'd share in case it helps someone else. We were using a lazy route component where we were passing a function to lazy load the component like here https://github.com/facebook/react/issues/14299 which was causing all of the remounts.

NerdCowboy avatar Sep 07 '22 15:09 NerdCowboy

This is probably unrelated to what most of you are experiencing, but since I was convinced this was my issue for a while, I thought I'd share in case it helps someone else. We were using a lazy route component where we were passing a function to lazy load the component like here facebook/react#14299 which was causing all of the remounts.

Thanks a lot! That was the fix also for me. The problem I had was with lazy loading in microfrontends application - I wasn't able to memoize lazy loaded component. Thanks to the fix that you can find here, I was able to memoize React.lazy components and fix my issue :)

llizon avatar Sep 08 '22 13:09 llizon

I'm facing the same problem: I would like to control the opening/closing process of modals by query strings and make them accessible directly. But when I change the query string to the new modal, everything is being re-rendered. From: localhost:3000/?modal=settings to localhost:3000/?modal=settings&tab=account.

Do we have a solution for that?

vendramini avatar Oct 05 '22 19:10 vendramini

@vendramini what is the problem with the rerender?

JesusTheHun avatar Oct 05 '22 19:10 JesusTheHun

@JesusTheHun the problem arrives when occurs both renderings: the react-router render and the render inside my modals.

Warning: Cannot update a component (`RouterProvider`) while rendering a different component (`WithTabs`). To locate the bad setState() call inside `WithTabs`, follow the stack trace as described in https://reactjs.org/link/setstate-in-render

I need access to the useNavigate and useLocation inside my modal's component to handle it properly accordingly to the URL (as I want all my modals to be accessible by link). And what is happening is: I need to re-render a component inside the modal when the URL changes but I'm changing only query params, not routes. As the router is being re-rendered, I've got this error.

I'm thinking about it and the only solution could be a manual implementation of the push method from browser's history.

vendramini avatar Oct 05 '22 21:10 vendramini

@vendramini this is a problem in your code. It has nothing to do with this library. I bet you navigate to the deep link when the modal opens. React works the opposite way : clicking on the "Open" button should navigate to the deep link, and the status of the modal should reflect the query parameter. Same goes for your tab, clicking on the tab only set the deeplink query parameter, and your component decide what tab to display based on that query parameter.

JesusTheHun avatar Oct 05 '22 22:10 JesusTheHun

I'm doing exactly what you said: my application is reacting based on what was set on query parameter. That's why it's been hard to figure out what is going on, and when I don't update my modal based on route the error goes out, which means that the route is also not being re-rendered.

I bet that I'm doing some re-render wrong, it could be possible true... But if the library didn't re-render the route itself without need I wouldn't be facing this problem.

vendramini avatar Oct 06 '22 01:10 vendramini

@vendramini open a stack overflow and send me the link.

JesusTheHun avatar Oct 06 '22 06:10 JesusTheHun

@JesusTheHun You were right, I had a wrong call for state change here.

My case is: I have a tabbed modal and they are being linked like ?modal=settings&tab=account|billing|invoices|etc. Each tab is basically a bunch of forms, and I have a single "SAVE" button at the modal's footer.

The problem: hitting SAVE should validated every tab and send the user to those who have errors. This should be done by changing the URL em reacting to it (I was doing that).

I have this piece of code for checking the state and sending to the right tab:

if (form) {
  const errors = Object.keys(form.formState.errors);
  if (errors.length) {
    const e = form.formState.errors[errors[0]];
    if (e?.type === 'custom') {
      if (searchParams.get('tab') !== e.message) onSelect(e.message as string);
    }
  }
}

And the onSelect is defined by:

const onSelect = (key: string | null) => {
  setSearchParams(new URLSearchParams({ ...Object.fromEntries(searchParams), tab: key || '' }).toString());
};

I was doing that during the render phase of my component. I've just moved it to a useEffect and it's working like a charm.

Thank you for your attention and inclination to help :P

But I'm still wondering if it's really necessary to re-render the route itself...

vendramini avatar Oct 06 '22 17:10 vendramini

@cristianoccazinsp

In version 5, changing the query string values would not cause a re-render from the very first component.

How did you update query string in V5 in such way?

selrond avatar Oct 28 '22 10:10 selrond

Also the loader function rerun after changing query params, im not sure is this should be a case, query params was a good way to 'store' same data like selected tab, then when users shares link between they have same view on initial render, but rerendering whole app after changing query params seems like overkill, i would love to not rerender Routing but only parts of app that using "useQueryParams", this behavior is possible using hash but it's hard to keep few parameters in hash

Mateusz-H avatar Nov 29 '22 14:11 Mateusz-H