Redux-First Router — A Step Beyond Redux-Little-Router

Published Jul 22, 2017Last updated Nov 23, 2017
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

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 and next.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-linkyet--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 to await for its resolution and for updateScroll() 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

Discover and read more posts from James Gillmore
get started
Enjoy this post?

Leave a like and comment for James

3
1