Redux-First Router — A Step Beyond Redux-Little-Router
Check out Redux-First Router on Github
The goal of Redux-First Router is to think of your app in states, not routes, not components, while keeping the address bar in sync. Everthing is state, not components. Connect your components and just dispatch flux standard actions.
Redux-First Router is something that should have existed long ago, but because the React Community at the time got caught up with throwing out so much ancient wisdom, was skipped over. Redux-First Router completes the triumverate of the MVC, adding the "C" to the equation (where Redux is the "M" and React the "V"). Basically, it was as if nobody wanted to hear the letters MVC again. It's anathema to me too, but this has needed to exist nevertheless.
RFR also kills the "everything is a component" concept when it comes to routes. It's now correctly: "everything is state" and routes are 100% in sync with actions to trigger that state; your view layer (components) just render from state as they should.
READ THESE ARTICLES TO BE BROUGHT UP TO SPEED
THE THINKING
The thinking behind Redux-First Router has been: "if we were to dream up a 'Redux-first' approach to routing from the ground up, what would it look like?" The result has been what we hope you feel to be one of those "inversion of control" scenarios that makes a challenging problem simple when coming at it from a different angle. We hope Redux-First Router comes off as an obvious solution.
DEMOS
To checkout a demo, right now, you have 2 options
- git clone this: universal-demo
- play with the CodeSandBox right now in your browser: Redux-First Router on CodeSandBox
What Routing in Redux is Meant To Be
The primary motivation of Redux-First-Router is to be able to use Redux as is while keeping the URL in the address bar in sync. In other words, to think solely in terms of "state" and NOT routes, paths, route matching components. And of course for server side rendering to require no more than dispatching on the store like normal. Path params are just action payloads, and action types demarcate a certain kind of path. That is what routing in Redux is meant to be.
In practice, what that means is having the address bar update in response to actions and bi-directionally having actions dispatched in response to address bar changes, such as via the browser back/forward buttons. The "bi-directional" aspect is embodied in the diagram above where the first blue arrows points both ways--i.e. dispatching actions changes the address bar, and changes to the address bar dispatches actions.
In addition, here are some key obstacles Redux-First Router seeks to avoid:
- having to render from any state that doesn't come from redux
- cluttering component code with route-related components
- the added complexity [and bugs] from 2 forms of state: redux state vs. routing state
- large API surface areas of packages/frameworks like
react-router
andnext.js
- workarounds that such large (likely "leaky") abstractions inevitably require to achieve a professional app
- strategies as low level as possible to deal with animations. Animations coinciding with React component updates are a problem, particularly in the browser (React Native is better). "Jank" is common. Techniques like
shouldComponentUpdate
are a must; routing frameworks get in the way of optimizing animations.
The Gist
It's set-and-forget-it, so here's the most work you'll ever do!
import { connectRoutes } from 'redux-first-router'
import { combineReducers, createStore, applyMiddleware, compose } from 'redux'
import createHistory from 'history/createBrowserHistory'
import userIdReducer from './reducers/userIdReducer'
const history = createHistory()
// THE WORK:
const routesMap = {
HOME: '/home', // action <-> url path
USER: '/user/:id', // :id is a dynamic segment
}
const { reducer, middleware, enhancer } = connectRoutes(history, routesMap) // yes, 3 redux aspects
// and you already know how the story ends:
const rootReducer = combineReducers({ location: reducer, userId: userIdReducer })
const middlewares = applyMiddleware(middleware)
const store = createStore(rootReducer, compose(enhancer, middlewares))
import { NOT_FOUND } from 'redux-first-router'
export const userIdReducer = (state = null, action = {}) => {
switch(action.type) {
case 'HOME':
case NOT_FOUND:
return null
case 'USER':
return action.payload.id
default:
return state
}
}
And here's how you'd embed SEO/Redux-friendly links in your app, while making use of the triggered state:
import React from 'react'
import ReactDOM from 'react-dom'
import { Provider, connect } from 'react-redux'
import Link from 'redux-first-router-link'
import store from './configureStore'
const App = ({ userId, onClick }) =>
<div>
{!userId
? <div>
<h1>HOME</h1>
// all 3 "links" dispatch actions:
<Link to="/user/123">User 123</Link> // action updates location state + changes address bar
<Link to={{ type: 'USER', payload: { id: 456 } }}>User 456</Link> // so does this
<span onClick={onClick}>User 5</span> // so does this, but without SEO benefits
</div>
: <h1>USER: {userId}</h1> // press the browser BACK button to go HOME :)
}
</div>
const mapStateToProps = ({ userId }) => ({ userId })
const mapDispatchToProps = (dispatch) => ({
onClick: () => dispatch({ type: 'USER', payload: { id: 5 } })
})
const AppContainer = connect(mapStateToProps, mapDispatchToProps)(App)
ReactDOM.render(
<Provider store={store}>
<AppContainer />
</Provider>,
document.getElementById('react-root')
)
Note: ALL THREE clickable elements/links above will change the address bar while dispatching the corresponding USER
action. The only difference is the last one won't get the benefits of SEO--i.e. an <a>
tag with a matching to
path won't be embedded in the page. What this means is you can take an existing Redux app that dispatches similar actions and get the benefit of syncing your address bar without changing your code! The workflow we recommend is to first do that and then, once you're comfortable, to use our <Link />
component to indicate your intentions to Google. Lastly, we recommend using actions
as your to
prop since it doesn't marry you to a given URL structure--you can always change it in one place later (the routesMap
object)!
Based on the above routesMap
the following actions will be dispatched when the
corresponding URL is visited, and conversely those URLs will appear in the address bar when actions with the matching type
and parameters are provided
as keys in the payload object:
URL | <-> | ACTION |
---|---|---|
/home | <-> | { type: 'HOME' } |
/user/123 | <-> | { type: 'USER', payload: { id: 123 } } |
/user/456 | <-> | { type: 'USER', payload: { id: 456 } } |
/user/5 | <-> | { type: 'USER', payload: { id: 6 } } |
note: if you have more keys in your payload that is fine--so long as you have the minimum required keys to populate the path
Lastly, we haven't mentioned redux-first-router-link
yet--Redux-First Router is purposely built in a very modular way, which is why the <Link />
component is in a separate package. It's extremely simple and you're free to make your own. Basically it passes the to
path on to Redux-First Router and calls event.preventDefault()
to stop page reloads. It also can take an action object as a prop, which it will transform into a URL for you! Its props API mirrors React Router's. The package is obvious enough once you get the hang of what's going on here--check it out when you're ready: redux-first-router-link. And if you're wondering, yes there is a NavLink
component with props like activeClass
and activeStyle
just like in React Router.
routesMap
The routesMap
object allows you to match action types to express style dynamic paths, with a few frills. Here's the primary (and very minimal easy to remember) set of configuration options available to you:
const routesMap = {
HOME: '/home', // plain path strings or route objects can be used
CATEGORY: { path: '/category/:cat', capitalizedWords: true },
USER: {
path: '/user/:cat/:name',
fromPath: path => capitalizeWords(path.replace(/-/g, ' ')),
toPath: value => value.toLowerCase().replace(/ /g, '-'),
},
}
Note: the signature of fromPath
and toPath
offers a little more, e.g: (pathSegment, key) => value
. Visit routesMap docs for a bit more info when the time comes.
URL | <-> | ACTION |
---|---|---|
/home | <-> | { type: 'HOME' } |
/category/java-script | <-> | { type: 'CATEGORY', payload: { cat: 'Java Script' } } |
/user/elm/evan-czaplicki | <-> | { type: 'USER', payload: { cat: 'ELM', name: 'Evan Czaplicki' } } |
routesMap (with thunk)
We left out one final configuration key available to you: a thunk. After the dispatch of a matching action, a thunk (if provided) will be called, allowing you to extract path parameters from the location reducer state and make asyncronous requests to get needed data:
const userThunk = async (dispatch, getState) => {
const { slug } = getState().location.payload
const data = await fetch(`/api/user/${slug}`)
const user = await data.json()
const action = { type: 'USER_FOUND', payload: { user } }
dispatch(action)
}
const routesMap = {
USER: { path: '/user/:slug', thunk: userThunk },
}
your
thunk
should return a promise for SSR to be able toawait
for its resolution and forupdateScroll()
to be called if using our scroll restoration package.
note: visit the location reducer docs to see the location
state's shape
URL | <-> | ACTION |
---|---|---|
/user/steve-jobs | <-> | { type: 'USER', payload: { slug: 'steve-jobs' } } |
n/a | n/a | { type: 'USER_FOUND', payload: { user: { name: 'Steve Jobs', slug: 'steve-jobs' } } } |
That's all folks!
Make sure to star the repo here:
github.com/faceyspacey/redux-first-router
Additional Info
- connectRoutes (there is a third
options
parameter you should check out) - action.meta (the
meta
key is how our system communicates & how our action maintains its status as an "FSA") - location reducer shape
- server side rendering
- scroll restoration
- redirects
- client-only API
- React Native
- React Navigation Support 🔮 - my current primary day-to-day focus
- Prefetching! 🔮 -
<Link prefetch />
powered by: react-universal-component + webpack-flush-chunks
React is retarded.