Anton
Anton

Reputation: 1211

How to sync Redux state and url query params

I have a web page with a search panel. Search panel has several input fields: id, size, ...

What I want is when user set search values (for example: id=123 and size=45) and press a search button:

  1. searchState in Redux reducer should be updated with new search values (id=123 and size=45)
  2. URL should be changed to "http://mydomain/home?id=123&size=45"

And on the other hand if the user changes URL to http://mydomain/home?id=111&size=22 then:

  1. searchState in reducer should be changed with new search values (id=111 and size=22)
  2. UI search panel inputs should be updated with new values (id=111 and size=22)

How to reach with goal? What router should I use (react-router, redux-router, react-router-redux ot other)?

Upvotes: 96

Views: 58869

Answers (7)

Sergey P. aka azure
Sergey P. aka azure

Reputation: 4772

Here's a solution with a good control over what's in the browser's url.

Solution consists of the following parts:

  1. queryStringSelector - a selector which is responsible for building a query search string to be pushed in browser's url. It can be smart and it can omit default values. It's possible to implement custom serialization of your values or just use JSON.stringify.

  2. getPartialStateFromQueryString helper fn. It get's browser's query string and parses it into a Partial<MyState>. It should support all of the fields encoded by queryStringSelector and must be able to parse string values back to needed structures. It should not trust the values in URL, because users might edit these strings.

  3. setStateFromPartialState action in your reducer. It gets a partial state of your store (slice) as payload and updates state to match that partial state. All the values that should be in sync with URL but are not present in partial state object should be reset to initial state first.

  4. useSyncWithUrl custom hook. It's responsible for three main aspects. First, it sets initial value from browser url to store on component mount. That happens just once. Second, it subscribes to state changes . Basically, it consumes output of queryStringSelector and pushes updates to URL. Third, it updates state on URL changes. In a nutshell, it subscribes to navigation changes in browser ('POP' event) and updates store accordingly. Note that it does not react to PUSH events, just to 'POP' event

It's essential to keep in mind that suggested approach only works well if you use your store as a source of truth. I.e. your app should apply changes to store, not to url to get to a certain state. URL is only considered as source of truth in two exceptional cases: 1) on initialization 2) when back-forward browsers' navigation happens. Though, if you navigate to some other part of the app with it's own sync hook instance, it still works because initialization phase occurs no matter how you get to the state.

Examples:

query string selector:

export const queryStringSelector = createSelector([rootSelector], (root) => {
  const params = new URLSearchParams();
  if (root.search !== initialState.search) {
    params.append('search', root.search);
  }

  if (root.result_tab !== initialState.result_tab) {
    params.append('tab', root.result_tab);
  }

  const queryString = params.toString();
  if (!queryString) {
    return '';
  }
  return `?${queryString}`;
}

getPartialStateFromQueryString helper

const getPartialStateFromQueryString = (queryString: string): Partial<MyState> => {
  const searchParams = new URLSearchParams(queryString);
  const partialState: Partial<MyState> = {};

  const search = searchParams.get('search');
  partialState.search = search || '';

  const tabString = searchParams.get('tab');
  if (tabString) {
    partialState.result_tab = tabString as any;
  }

  return partialState;
};

reducer action

const keysSyncedToUrl: (keyof MyState)[] = ['search', 'result_type', /* ... */];
//
setStateFromPartialState: (state, { payload }: PayloadAction<Partial<MyState>>) => {
  // set all keys to default state first
  keysSyncedToUrl.forEach((key: string) => {
    state[key] = initialState[key] as any;
  });

  // apply partial state on top of initial state
  Object.entries(payload).forEach(([key, value]) => {
    state[key] = value;
  });
}

sync to url custom hook:

export const useSyncWithUrl = () => {
  const { search } = useLocation();
  const history = useHistory();
  const dispatch = useDispatch();
  const [isSynced, setSynced] = useState<boolean>(false);
  const queryString = useSelector(queryStringSelector);

  // updates url when state changes
  useEffect(() => {
    if (!isSynced) {
      return;
    }
    if (queryString !== search) {
      history.push({ ...location, search: queryString });
    }
  }, [isSynced, queryString]);

  // apply params from url on initialization
  useEffect(() => {
    if (!search) {
      setSynced(true);
      return; // no search params to restore, exit
    }
    const partialState = getPartialStateFromQueryString(search);

    dispatch(mySlice.actions.setStateFromPartialState(partialState));

    setSynced(true);
  }, []);

  // react on navigation events (back-forward) and restore state from url
  useEffect(() => {
    const unsubscribe = history.listen((location, actionType) => {
      if (actionType === 'POP') {
        const partialState = getPartialStateFromQueryString(location.search);
        dispatch(mySlice.actions.setStateFromPartialState(partialState ));
      }
    });
    return unsubscribe;
  }, [history]);
};

Upvotes: 0

Hoan Phung
Hoan Phung

Reputation: 51

I also recently finished working on this feature for my company's app. We wanted to add different search queries to the URL.

A few things I would like to share here:

1) We used Redux store and react-router. Since we also used Typescript, we ended up using RouteComponentProps from react-router to use this.props.history.push() to update the URL.

2) We kept all the search queries in the Redux store. The work flow for updating the Redux store and then the URL is as follows:

Select some filtering options in the app => Dispatch actions to update filtering state in the Redux store => update the URL

3) At the end, we also want users to be able to enter an URL with query params and it will update all the filters in the app. For this to work, the work flow is even simpler:

User enters the URL => dispatch actions to update Redux state with query params from the URL. The update of Redux state will automatically cause a re-rendering in the app and all filters will be updated.

So the most important thing here is always keep the Redux state and URL in sync with each other.

Upvotes: 5

Hearen
Hearen

Reputation: 7838

I would recommend the newly tool react-url-query which will do that syncing quite straight-forward.

Upvotes: 2

Gerben
Gerben

Reputation: 233

In short: You could use redux-query-sync to keep the URL parameters and fields in the store in sync. It works without any Router.


Longer story: Struggling with the same question, I first appreciated Dan Abramov's answer, suggesting to (in my words) consider the URL as a 'second store', a part of the application state outside the Redux store. But then I found that having two kinds of 'stores' makes it difficult to modify code, to e.g. move things from one to the other, because each 'store' has a different interface. As a developer, I would rather like to select any of the fields in my Redux state, and expose them in the URL, as if the location were a small window to (a part of) my state.

Hence I just published redux-query-sync, to let you provide a selector function for selecting a value from the state, which is then exposed in the window location at a parameter name you specify. To also let it sync in the other direction, thus from the URL to Redux state (e.g. when the application initially loads), you can provide an action creator that will be passed the parameter value, so your reducer can update the state accordingly.

Upvotes: 20

xiankai
xiankai

Reputation: 2781

I have recently finished working on a similar issue to this. I used MobX as my store and redux-router as my router. (I did fine with react-router, but I needed to dispatch a push action outside of the component - redux as a global store works great here)

My solution has been similar to what cantera has described - my router state is merely a reflection of the form state. This has the added bonus of not having to ditch my form state and depend entirely on the router state.


In the first scenario,

1) I update my form input as usual, that triggers a re-render in the results component.

2) In componentDidUpdate() of the results component, I use the form props to construct the updated query string.

3) I push the updated query string to the router state. This is purely for the effect of updating the URL.


For the second scenario

1) In the onEnter hook on the root Route component, I get the available query string and parse it into the initial form values.

2) I update my store with the values. This is only for the first load of the page, and the only time the router state dictates the form state.


Edit: This solution does not account for the case when you go back in your browser history, because the url will not update your state.

Upvotes: 2

cantera
cantera

Reputation: 25015

Here's the way I've been handling the first scenario:

  • When the input value changes, its component triggers a callback that was passed as a prop from its container.

  • In the callback, the container dispatches the action responsible for updating Redux state when the event occurs.

  • In the line immediately following the action call, I use this.context.router.push() and pass it the url with the correct query string.

I'm not certain that this is the correct approach. I found it preferable to updating the URL first because, in my opinion, the query string should be a reflection of state rather than the master of it.

Regarding the reverse scenario, I'm really not sure. It seems like manually setting the URL would trigger a full reload, but I might be mistaken.

As for which router to use, I am using React Router by itself. I wasn't really finding value in using the others and this discussion was the clincher for me.

Upvotes: 4

Dan Abramov
Dan Abramov

Reputation: 268333

I would suggest just using React Router directly and not keeping searchState in Redux. React Router will inject URL parameters into your components, and you can use them in mapStateToProps(state, ownProps) to calculate the final props.

If you really want to see route changes as actions, you can use react-router-redux for two-way syncing, but it won’t give you the params in the state—just the current location. The only use case for it if you want to record and replay actions, and have the URL bar update as you replay them.

It is by no means required—in most cases, just using React Router directly is enough.

Upvotes: 86

Related Questions