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.
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
.
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!
}
}
}
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))
}
}
}
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))
}
}
}
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))
}
}
}
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))
}
}
}
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))
}
}
}
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))