├── .gitignore ├── .npmignore ├── .travis.yml ├── LICENSE.md ├── README.md ├── examples ├── basic │ ├── README.md │ ├── client.js │ ├── common │ │ ├── actions │ │ │ ├── index.js │ │ │ └── name.js │ │ ├── components │ │ │ ├── About │ │ │ │ └── index.js │ │ │ ├── Hello.js │ │ │ ├── Hello │ │ │ │ └── index.js │ │ │ ├── Home │ │ │ │ └── index.js │ │ │ ├── Nav │ │ │ │ └── index.js │ │ │ ├── NotFound │ │ │ │ └── index.js │ │ │ └── Page │ │ │ │ └── index.js │ │ ├── configureStore.js │ │ ├── reducers │ │ │ ├── index.js │ │ │ └── name.js │ │ └── routes.js │ ├── package.json │ ├── server.js │ ├── webpack.config.babel.js │ └── yarn.lock └── real-world │ ├── README.md │ ├── client.js │ ├── common │ ├── actions │ │ ├── areDishesLoading.js │ │ ├── country.js │ │ ├── dishes.js │ │ ├── index.js │ │ └── isVegetarian.js │ ├── components │ │ ├── Menu.js │ │ ├── Menu │ │ │ └── index.js │ │ ├── NotFound │ │ │ └── index.js │ │ └── Page │ │ │ └── index.js │ ├── config.json │ ├── configureStore.js │ ├── reducers │ │ ├── areDishesLoading.js │ │ ├── country.js │ │ ├── dishes.js │ │ ├── index.js │ │ └── isVegetarian.js │ └── routes.js │ ├── package.json │ ├── server.js │ ├── webpack.config.babel.js │ └── yarn.lock ├── package.json ├── src ├── actions │ ├── changePageTo.js │ └── updateUrl.js ├── components │ ├── Link.js │ └── Router.js ├── constants.js ├── helpers │ ├── getAction.js │ ├── getLocation.js │ ├── getRoute.js │ ├── getState.js │ ├── popStateListener.js │ └── resolveActionCreator.js ├── index.js ├── middleware │ └── router.js └── reducers │ ├── router.js │ └── url.js ├── test ├── actions │ ├── changePageTo.js │ └── updateUrl.js ├── components │ ├── Link.js │ └── Router.js ├── helpers │ ├── getAction.js │ ├── getLocation.js │ ├── getRoute.js │ ├── getState.js │ └── resolveActionCreator.js ├── middleware │ └── router.js └── reducers │ ├── router.js │ └── url.js ├── webpack.config.babel.js └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | examples/*/client.dist.js 3 | examples/*/client.dist.js.map 4 | examples/*/universal-redux-router/ 5 | lib/ 6 | node_modules/ 7 | npm-debug.log 8 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .gitignore 2 | .travis.yml 3 | examples/ 4 | src/ 5 | test/ 6 | webpack.config.js 7 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: node_js 3 | cache: 4 | directories: 5 | - node_modules 6 | notifications: 7 | email: false 8 | node_js: 9 | - '6' 10 | before_install: 11 | - npm i -g npm@^3.8.0 12 | before_script: 13 | - npm prune 14 | script: 15 | - npm run lint 16 | - npm test 17 | after_success: 18 | - npm run semantic-release 19 | branches: 20 | except: 21 | - "/^v\\d+\\.\\d+\\.\\d+$/" 22 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Internet Systems Consortium license 2 | =================================== 3 | 4 | Copyright (c) `2016`, `Colin Meinke` 5 | 6 | Permission to use, copy, modify, and/or distribute this software for any purpose 7 | with or without fee is hereby granted, provided that the above copyright notice 8 | and this permission notice appear in all copies. 9 | 10 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH 11 | REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND 12 | FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, 13 | INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS 14 | OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER 15 | TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF 16 | THIS SOFTWARE. 17 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Universal Redux Router 2 | 3 | A router that turns URL params into first-class Redux state 4 | and runs action creators on navigation. 5 | 6 | --- 7 | 8 | ![Test status](https://img.shields.io/travis/colinmeinke/universal-redux-router.svg) 9 | ![Dependencies status](https://img.shields.io/david/colinmeinke/universal-js.svg) 10 | 11 | ## Navigation 12 | 13 | - [Motivation](#motivation) 14 | - [Features](#features) 15 | - [Extracts state from URLs](#extracts-state-from-urls) 16 | - [Runs action creators *after* calculating new state](#runs-action-creators-after-calculating-new-state) 17 | - [Handles async action creators](#handles-async-action-creators) 18 | - [Routing on server and client](#routing-on-server-and-client) 19 | - [Examples](#examples) 20 | - [Installation](#installation) 21 | - [Usage](#usage) 22 | - [Router](#router) 23 | - [routerMiddleware](#routermiddleware) 24 | - [routerReducer](#routerreducer) 25 | - [changePageTo](#changepageto) 26 | - [Link](#link) 27 | - [getState](#getstate) 28 | - [Help make this better](#help-make-this-better) 29 | - [Thanks](#thanks) 30 | - [License](#license) 31 | 32 | ## Motivation 33 | 34 | If you're a good web citizen each part of your app can be 35 | linked to with a URL. These URLs often contain state either 36 | as part of the path name or within the query string. 37 | 38 | ``` 39 | /users/782/posts?page=2&tags[]=coding,making 40 | ``` 41 | 42 | In the above example, we have the following state: 43 | 44 | ```js 45 | { 46 | id: 782, 47 | page: 2, 48 | tags: [ 'coding', 'making' ] 49 | } 50 | ``` 51 | 52 | **I want that state in my Redux store!** 53 | 54 | ## Features 55 | 56 | ### Extracts state from URLs 57 | 58 | Universal Redux Router extracts state from a URL and adds it 59 | as first-class state to your Redux store. 60 | 61 | It achieves this by allowing you to attach Redux action 62 | creators to your routes. 63 | 64 | ```js 65 | const routes = [ 66 | [ 67 | 'users/:id/posts', 68 | { 69 | id: updateId, 70 | page: updatePage, 71 | tags: updateTags 72 | }, 73 | 74 | ] 75 | ] 76 | ``` 77 | 78 | In the above example, we have one route defined that will 79 | match `/users//posts`. It has three Redux action 80 | creators attached `updateId`, `updatePage` and `updateTags`. 81 | 82 | When a user navigates to a URL, the action creators associated 83 | with the matching route are called with the appropriate part 84 | of the URL. 85 | 86 | For example, navigating to 87 | `/users/782/posts?page=2&tags[]=coding,making` will result in 88 | the following function calls: 89 | 90 | - `updateId('782')` 91 | - `updatePage('2')` 92 | - `updateTags([ 'coding', 'making' ])` 93 | 94 | The returned actions are then used to calculate the new state 95 | of the Redux store. 96 | 97 | ### Runs action creators *after* calculating new state 98 | 99 | On top of running action creators to extract state from URL 100 | params, Universal Redux Router allows you to define action 101 | creators to run *after* the new state has been calculated. 102 | 103 | ```js 104 | const routes = [ 105 | [ 106 | 'users/:id/posts', 107 | { 108 | id: updateId, 109 | page: updatePage, 110 | tags: updateTags, 111 | after: [ postsUpdating, getPosts, postsUpdated ] 112 | }, 113 | 114 | ] 115 | ] 116 | ``` 117 | 118 | In the above example the action creators `updateId`, `updatePage` 119 | and `updateTags` are run first. The returned actions are used to 120 | calculate the new state. 121 | 122 | The `after` action creators are then run in sequence, each called 123 | in turn with the updated state. 124 | 125 | ### Handles async action creators 126 | 127 | Both URL param action creators and `after` action creators can 128 | return promises. 129 | 130 | The `after` action creators will not be called until all URL 131 | param action creators have resolved and the new state has been 132 | calculated. 133 | 134 | Each one of the `after` action creators will not be called 135 | until the previous `after` action creator has been resolved 136 | and the new state calculated. 137 | 138 | ### Routing on server and client 139 | 140 | As the name implies, Universal Redux Router is designed 141 | specifically to work the same on both server and client. 142 | 143 | No need for environment specific code. Phew! 144 | 145 | ## Examples 146 | 147 | - The [basic example](./examples/basic) in the examples 148 | directory of this repository. 149 | - The [real world example](./examples/real-world) in the 150 | examples directory of this repository. 151 | - [My React/Redux starter kit](https://github.com/colinmeinke/universal-js). 152 | - [My blog](https://github.com/colinmeinke/colinmeinke). 153 | 154 | ## Installation 155 | 156 | ``` 157 | npm install universal-redux-router 158 | ``` 159 | 160 | ## Usage 161 | 162 | ### Router 163 | 164 | The `Router` component handles which component will be 165 | displayed by taking the `url` property of your Redux store and 166 | your routes array. 167 | 168 | It matches a route and returns the component defined within 169 | that route. 170 | 171 | ```js 172 | import { Router } from 'universal-redux-router' 173 | 174 | const Root = () => ( 175 | 176 | 177 | 178 | ) 179 | ``` 180 | 181 | ### routerMiddleware 182 | 183 | You must use `routerMiddleware` in your Redux middleware 184 | stack. This listens for the `CHANGE_PAGE_TO` action, matches a 185 | route and then makes a list of additional actions we need to 186 | dispatch. 187 | 188 | It also includes a few conveniences like updating scroll 189 | position on navigation and handling browser history. 190 | 191 | ```js 192 | import { routerMiddleware } from 'universal-redux-router' 193 | 194 | const middleware = applyMiddleware(routerMiddleware(routes)) 195 | 196 | return createStore(reducer, state, middleware) 197 | ``` 198 | 199 | ### routerReducer 200 | 201 | Instead of using Redux's `combineReducers` to create your 202 | root reducer, you must use `routerReducer`. It has the same 203 | API as `combineReducers`. 204 | 205 | `routerReducer` uses `combineReducers` under the hood for all 206 | incoming actions apart from `CHANGE_PAGE_TO`. When it receives 207 | `CHANGE_PAGE_TO` it iterates over the list of associated 208 | actions to calculate state. 209 | 210 | ```js 211 | import { routerReducer } from 'universal-redux-router' 212 | 213 | const reducer = routerReducer(reducers) 214 | ``` 215 | 216 | ### changePageTo 217 | 218 | The `changePageTo` Redux action creator creates the 219 | `CHANGE_PAGE_TO` action. It is how we navigate using Universal 220 | Redux Router. 221 | 222 | It can either take an array of data, or a URL string. 223 | 224 | ```js 225 | import { changePageTo } from 'universal-redux-router' 226 | 227 | store.dispatch( 228 | changePageTo([ 'users', id, 'posts', { page, tags } ]) 229 | ) 230 | 231 | store.dispatch( 232 | changePageTo(`/users/${id}/string?page=${page}&tags[]=${tags.join(',')}`) 233 | ) 234 | ``` 235 | 236 | ### Link 237 | 238 | The `Link` component is used to create an HTML anchor element 239 | that has `changePageTo` handling built in. This means you 240 | don't have to worry about `onClick` events or having to 241 | directly call `changePageTo`. 242 | 243 | Like `changePageTo` it accepts both an array of data or a URL 244 | string as its `to` prop. 245 | 246 | ```js 247 | import { Link } from 'universal-redux-router' 248 | 249 | 250 | User posts 251 | 252 | 253 | 254 | User posts 255 | 256 | ``` 257 | 258 | ### getState 259 | 260 | The `getState` helper does exactly what the the router 261 | does on a `CHANGE_PAGE_TO` action, but without any `dispatch` 262 | calls, and therefore without the need for a Redux store. 263 | 264 | This means we can use it to calculate the initial state 265 | to create our Redux store. 266 | 267 | We can also use it on both server and client to avoid passing 268 | state between the two. 269 | 270 | ```js 271 | import { getState } from 'universal-redux-router' 272 | 273 | getState(url, routes, reducer).then(state => { 274 | const store = createStore(reducer, state, middleware) 275 | }) 276 | ``` 277 | 278 | ## Help make this better 279 | 280 | [Issues](https://github.com/colinmeinke/universal-redux-router/issues/new) 281 | and pull requests gratefully received! 282 | 283 | I'm also on twitter [@colinmeinke](https://twitter.com/colinmeinke). 284 | 285 | Thanks :star2: 286 | 287 | ## Thanks 288 | 289 | - [Henrik Joreteg](https://twitter.com/HenrikJoreteg)'s [article on minimalist routing](https://gist.github.com/HenrikJoreteg/530c1da6a5e0ff9bd9ad) 290 | - [React Router](https://github.com/rackt/react-router) (specifically for the [Link API](https://github.com/rackt/react-router/blob/master/modules/Link.js)) 291 | - [Luke Morton](https://twitter.com/lukemorton) for discussions around routing and [Republic](https://github.com/lukemorton/republic) 292 | 293 | ## License 294 | 295 | [ISC](./LICENSE.md). 296 | -------------------------------------------------------------------------------- /examples/basic/README.md: -------------------------------------------------------------------------------- 1 | # Basic example 2 | 3 | ## Getting started 4 | 5 | To run this example make sure you have first run `npm install` 6 | in the root directory. Then `cd` into `/examples/basic` and: 7 | 8 | ``` 9 | npm install 10 | npm run build 11 | npm start 12 | ``` 13 | 14 | Then visit [http://localhost:3000](http://localhost:3000). 15 | -------------------------------------------------------------------------------- /examples/basic/client.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { render } from 'react-dom' 3 | import { Provider } from 'react-redux' 4 | import { Router } from './universal-redux-router' 5 | 6 | import routes from './common/routes' 7 | 8 | import configureStore from './common/configureStore' 9 | 10 | const { hash, pathname, search } = window.location 11 | const url = pathname + search + hash 12 | 13 | configureStore({ url }).then(store => { 14 | render( 15 | 16 | 17 | , 18 | document.querySelector('.app') 19 | ) 20 | }).catch(console.error.bind(console)) 21 | -------------------------------------------------------------------------------- /examples/basic/common/actions/index.js: -------------------------------------------------------------------------------- 1 | import { UPDATE_NAME, updateName } from './name' 2 | 3 | export { UPDATE_NAME, updateName } 4 | -------------------------------------------------------------------------------- /examples/basic/common/actions/name.js: -------------------------------------------------------------------------------- 1 | const UPDATE_NAME = 'UPDATE_NAME' 2 | 3 | const updateName = name => ({ type: UPDATE_NAME, name }) 4 | 5 | export { UPDATE_NAME, updateName } 6 | -------------------------------------------------------------------------------- /examples/basic/common/components/About/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Nav from '../Nav' 3 | 4 | const About = () => ( 5 |
6 |

About page

7 |
9 | ) 10 | 11 | export default About 12 | -------------------------------------------------------------------------------- /examples/basic/common/components/Hello.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux' 2 | 3 | import Hello from './Hello/index' 4 | 5 | const mapStateToProps = ({ name }) => ({ name }) 6 | 7 | const HelloContainer = connect(mapStateToProps)(Hello) 8 | 9 | export default HelloContainer 10 | -------------------------------------------------------------------------------- /examples/basic/common/components/Hello/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Nav from '../Nav' 3 | 4 | const Hello = ({ name }) => ( 5 |
6 |

Hello { name }

7 |
9 | ) 10 | 11 | export default Hello 12 | -------------------------------------------------------------------------------- /examples/basic/common/components/Home/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Nav from '../Nav' 3 | 4 | const Home = () => ( 5 |
6 |

Home page

7 |
9 | ) 10 | 11 | export default Home 12 | -------------------------------------------------------------------------------- /examples/basic/common/components/Nav/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Link } from '../../../universal-redux-router' 3 | 4 | const name = 'Colin' 5 | 6 | const Nav = () => ( 7 | 14 | ) 15 | 16 | export default Nav 17 | -------------------------------------------------------------------------------- /examples/basic/common/components/NotFound/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Nav from '../Nav' 3 | 4 | const NotFound = () => ( 5 |
6 |

Not found

7 |
9 | ) 10 | 11 | export default NotFound 12 | -------------------------------------------------------------------------------- /examples/basic/common/components/Page/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | const Page = ({ app }) => { 4 | return ( 5 | 6 | 7 | 8 | Universal Redux Router example 9 | 10 | 11 |
15 |