Skip to content

Instantly share code, notes, and snippets.

@heygrady
Last active April 3, 2023 17:50
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save heygrady/1e2ab33e9bb538d2fd149b764ceb0a2f to your computer and use it in GitHub Desktop.
Save heygrady/1e2ab33e9bb538d2fd149b764ceb0a2f to your computer and use it in GitHub Desktop.
Using react-router as a store

Using react-router as a store

If you are building a react-redux application, you know that redux is the store. The state of your application should be kept in redux... but it's not the only store in your application. If you are using react-router (v4) you also have access to a "history store" — the URL of the current page hold valuable information about your application state.

We want to use react-router (v4) history to manage the state of the application too!

In a server-side react application, the URL is typically the only information available at render time. We populate the redux state using only the URL information we get from express. Treating the URL as a type of store has imortant implications in how you structure your app.

If you are truly dogmatic about only having one store to rule them all, you might enjoy react-router-redux. But it doesn't circumvent a key reality of a browser-based application: our app state is derived from the URL.

There are a handful of ways to store information in a URL. React-router uses the history package to manage the location. It defines location as an object comprised of pathname, search, hash, and location.state.

The location is available as a prop on any component that was rendered by a <Route /> or wrapped in the withRouter higher-order-component. Meaning, you can always get access to the current location in your components.

What is location.state good for?

React-router's history allows each location to have a location.state. It's invisible to the user and exists only in memory. It works like a session in that it is blank when you first arive at a site and will be destroyed if you close the browser. Some applications use location.state to store crucial application state information that they want to restore later if we havigate back to that page.

The history.goBack() and history.goForward() (as well as the browser's back and forward buttons) will restore the location.state if it is available. Of course the location.state is always empty on first load, so it cannot always be relied upon. The easiest way to manage the location.state is using a <Link to={{ pathname, state }} /> or history.push({ pathname, state }).

However, this functionality should not be abused. There likely isn't a good reason to, say, keep the entire redux state in the browser history. While it might seem useful to be able to "time travel" your redux app using the back button, this is rarely what you really want. Instead, you should only use the location.state to store critical information that will help you make small, surgical redux state changes after location updates.

The reasoning is that your redux store should operate independently from the browser location as much as possible. To the extent that the URL contains information that we need to render a page (such as match.params) your app will always depend on the browser location. However, your match.params should be used to select data from the redux store. Meaning, the data in the redux store is ideally independent from the location itself. Instead, the match.params should tell your mapStateToProps which data to select. If the data is missing, the match.params can be used to fetch the data your component needs (data fetching is not covered below).

So, what should be stored in location.state? Short answer: nothing. Long answer: data that records app state that doesn't belong in location.search or location.hash.

Note: if you need to use location to store application state, it might be better stored in location.hash or location.search.

Keeping state in location.hash

While it may not be immediately obvious, you can use location.hash in much the same way you would use location.state. Because location.hash is visible in the URL, you will want to restrict the amount of data you store there. This is a good way to visualize an appropriate way to utilize location.state: you don't want to store everything, only what you need. One thing to keep in mind is that browsers will not send the hash to the server. This means that a server-side render will not be able to take the hash into account.

Pros:

  • user can cut and paste the URL

Cons:

  • not visible on the server-side
  • state is visible to the user
import Query from 'qs'

const mapStateToProps = (state, ownProps) => {
  const { location, match } = ownProps
  const hashState = Query.parse(location.hash) // <-- parse the hash to an object
  const { clicked } = hashState
  const { id } = match.params
  const user = selectUser(id)(state)
  return {
    id,
    user,
    clicked // <-- pass the interesting value to your component
  }
}

const mapDispatchToProps = (dispatch, ownProps) => {
  const { history, location } = ownProps

  return {
    onClick: () => {
      const { pathname, search, hash, state } = location
      const hashState = Query.parse(hash) // <-- parse the hash to an object
      hashState.clicked = true // <-- update the interesting value 

      // update only the hash
      history.push({
        pathname,
        search,
        hash: Query.stringify(hashState),
        state
      })
      dispatch(updateClicked(true)) // <-- tell redux too!
    }
  }
}

Keeping state in location.search

We can also use location.search in the exact same manner as location.hash with many of the same pros and cons. One upside to location.search is that browsers do send this part of the URL to the server, which makes it available during server-side render.

Pros:

  • user can cut and paste URL
  • visible on the server-side

Cons:

  • state is visible to the user
import Query from 'qs'

const mapStateToProps = (state, ownProps) => {
  const { location, match } = ownProps
  const query = Query.parse(location.search)
  const { clicked } = query
  const { id } = match.params
  const user = selectUser(id)(state)
  return {
    id,
    user,
    clicked
  }
}

const mapDispatchToProps = (dispatch, ownProps) => {
  const { history, location } = ownProps

  return {
    onClick: () => {
      const { pathname, search, hash, state } = location
      const query = Query.parse(search)
      query.clicked = true

      // update only the search
      history.push({
        pathname,
        search: Query.stringify(query),
        hash,
        state
      })
      dispatch(updateClicked(true))
    }
  }
}

Keeping state in location.state

Of course, we can also use location.state. This is all managed in your browser memory, so you don't want to go overboard. The big upside is that your user won't see their state in their URL. But this also means they can't cut and paste the URL and see the exact same page.

Pros:

  • state is invisible to the user

Cons:

  • user cannot cut and paste URL
  • not visible on the server-side
const mapStateToProps = (state, ownProps) => {
  const { location, match } = ownProps
  const { clicked } = location.state
  const { id } = match.params
  const user = selectUser(id)(state)
  return {
    id,
    user,
    clicked
  }
}

const mapDispatchToProps = (dispatch, ownProps) => {
  const { history, location } = ownProps

  return {
    onClick: () => {
      const { pathname, search, hash, state } = location
      const newState = { ...state, clicked: true }

      // update only the location.state
      history.push({
        pathname,
        search,
        hash,
        state: newState
      })
      dispatch(updateClicked(true))
    }
  }
}

Failing back to redux state

Because some of the methods above won't be visible in the URL, a user may be able to load the page without the expected state. Rather than failing backwards to undefined, we can check the redux state for the value and use that instead. This is beneficial in cases where the value may have been preloaded into the redux state by the server or otherwise added to the redux state (perhaps using something like redux-persist).

The key here is that the value from the URL should override the value from redux. This makes sure that the component has the best chance of receiving the expected value while still allowing the "back" and "forward" buttons to work as expected.

This would work with any of the above methods (search, hash, or location.state) of maintaining location state.

Note: avoid the temptation to call history.push from within mapStateToProps to push redux state into the location. This is better handled using one of the methods outlined further below.

Pros:

  • state is comes from location when it is available

Cons:

  • state can comes from multiple places
const mapStateToProps = (state, ownProps) => {
  const { location, match } = ownProps
  let { clicked } = location.state // <-- try to read from location
  const { id } = match.params
  const user = selectUser(id)(state)
  if (clicked === undefined) { // <-- fail backwards to redux
    clicked = selectClicked(state)
  }
  return {
    id,
    user,
    clicked
  }
}

const mapDispatchToProps = (dispatch, ownProps) => {
  const { history, location } = ownProps

  return {
    onClick: () => {
      const { pathname, search, hash, state } = location
      const newState = { ...state, clicked: true }

      // update only the location.state
      history.push({
        pathname,
        search,
        hash,
        state: newState
      })
      dispatch(updateClicked(true))
    }
  }
}

Updating redux state on location change

All of the above examples show how to keep the state in both location and redux. However, you may want to enable your app to read "state" from only the redux state. In those cases, you need to wire up history.listen to update redux when the location changes. This will ensure that the state that comes from location is added to redux. Below you can see that our mapStateToProps no longer reads values from location. Instead, we rely on a thunk to keep redux in sync with the current location when location changes.

// -- where you initialize history and store

history.listen((location, action) => {
  store.dispatch(locationChange(location, action))
})

// -- in your locationChange thunk

export const locationChange = (location, action) => (dispatch, getState) => {
  // TODO: how to cleanly manage a bunch of listeners?

  // grab the interesting values from search, hash or state
  const { clicked } = location.state
  
  const state = getState()
  const currentClicked = selectClicked(state)

  // tell the redux state about it
  if (clicked !== currentClicked) {
    dispatch(updateClicked(clicked))
  }
}

// -- in your container

const mapStateToProps = (state, ownProps) => {
  const { match } = ownProps
  const { id } = match.params
  const user = selectUser(id)(state)
  const clicked = selectClicked(state) // <-- read from redux
  return {
    id,
    user,
    clicked
  }
}

const mapDispatchToProps = (dispatch, ownProps) => {
  const { history, location } = ownProps

  return {
    onClick: () => {
      const { pathname, search, hash, state } = location
      const newState = { ...state, clicked: true }

      // update only the location.state
      history.push({
        pathname,
        search,
        hash,
        state: newState
      })
      dispatch(updateClicked(true))
    }
  }
}

Updating history on redux state change

What about the reverse? In this example we would like to dispatch an action to redux and have it update the location automatically. Below you can see that we're adding some custom middleware that will update the history when a specific action is dispatched. This works well with the history.listen example shown above. Now we can cleanly dispatch actions and the side effects will take care of themselves. As well, when the location changes, only the parts of our state that need to update will change.

// -- where you initialize history and store

const historyMiddleware = createHistoryMiddleware(history)
const middleware = [thunk, historyMiddleware]

// -- in your history middleware

const createHistoryMiddleware = (history) => {
  return store => next => action => {
    // TODO: how to cleanly manage a bunch of actions? (hint: sagas)
    if (action.type === UPDATE_CLICKED) {
      const { location } = history
      const { pathname, search, hash, state } = location
      const newState = { ...state, clicked: action.payload }

      history.push({
        pathname,
        search,
        hash,
        state: newState
      })
    }

    // always return next
    return next(action)
  };
}
export default createHistoryMiddleware

// -- in your container

const mapStateToProps = (state, ownProps) => {
  const { match } = ownProps
  const { id } = match.params
  const user = selectUser(id)(state)
  const clicked = selectClicked(state)
  return {
    id,
    user,
    clicked
  }
}

const mapDispatchToProps = (dispatch, ownProps) => {
  const { history, location } = ownProps

  return {
    onClick: () => {
      // only need to tell redux
      dispatch(updateClicked(true))
    }
  }
}

Handling side-effects with sagas

While we're using custom middleware to solve this problem above, a better (more scalable) solution might be to use something like redux-saga-watch-actions, which uses sagas to "subscribe" to actions to manage side-effects. The only sticking point with trying to use sagas or thunks is gaining access to the app's history object (see here and here). A reasonable solution is to pass the history to your saga when it is first run.

import { watchActions } from 'redux-saga-watch-actions'

import { UPDATE_CLICKED } from '../constants'

const rootSaga = watchActions({
  [UPDATE_CLICKED]: function*(history, action) {
    const { location } = history
    const { pathname, search, hash, state } = location
    const newState = { ...state, clicked: action.payload }

    history.push({
      pathname,
      search,
      hash,
      state: newState
    })
  }
})

// -- where you run your saga (hopefully where you initialize store and history)

runSaga(rootSaga(history))
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment