├── .babelrc ├── .codeclimate.yml ├── .editorconfig ├── .eslintignore ├── .eslintrc.js ├── .flowconfig ├── .github └── CODEOWNERS ├── .gitignore ├── .npmignore ├── .snyk ├── .travis.yml ├── LICENSE ├── README.md ├── __test-helpers__ ├── reducerParameters.js ├── setup.js ├── setupJest.js └── setupThunk.js ├── __tests__ ├── __snapshots__ │ ├── action-creators.js.snap │ ├── connectRoutes.js.snap │ ├── createLocationReducer.js.snap │ └── pure-utils.js.snap ├── action-creators.js ├── clientOnlyApi.js ├── connectRoutes.js ├── createLocationReducer.js └── pure-utils.js ├── docs ├── _config.yml ├── action.md ├── addRoutes.md ├── blocking-navigation.md ├── client-only-api.md ├── connectRoutes.md ├── low-level-api.md ├── migration.md ├── prefetching.md ├── prior-art.md ├── query-strings.md ├── react-native.md ├── reducer.md ├── redux-first-router-flow-chart.png ├── redux-persist.md ├── scroll-restoration.md ├── server-rendering.md └── url-parsing.md ├── examples ├── README.md ├── change-title │ ├── README.md │ └── change-title.patch ├── links │ ├── README.md │ └── links.patch ├── minimal │ ├── .env │ ├── .eslintrc.js │ └── src │ │ ├── App.js │ │ ├── components.js │ │ ├── configureStore.js │ │ ├── index.js │ │ └── pageReducer.js ├── redux-devtools │ ├── README.md │ └── redux-devtools.patch └── thunks │ ├── README.md │ └── thunks.patch ├── flow-typed └── npm │ ├── babel-cli_vx.x.x.js │ ├── babel-eslint_vx.x.x.js │ ├── babel-plugin-transform-flow-strip-types_vx.x.x.js │ ├── babel-preset-es2015_vx.x.x.js │ ├── babel-preset-react_vx.x.x.js │ ├── babel-preset-stage-0_vx.x.x.js │ ├── eslint-plugin-babel_vx.x.x.js │ ├── eslint-plugin-flow-vars_vx.x.x.js │ ├── eslint-plugin-flowtype_vx.x.x.js │ ├── eslint-plugin-import_vx.x.x.js │ ├── eslint-plugin-react_vx.x.x.js │ ├── eslint_vx.x.x.js │ ├── flow-bin_v0.x.x.js │ ├── flow-copy-source_vx.x.x.js │ ├── jest_v18.x.x.js │ ├── react-redux_v4.x.x.js │ └── redux_v3.x.x.js ├── package.json ├── renovate.json ├── src ├── action-creators │ ├── addRoutes.js │ ├── historyCreateAction.js │ ├── middlewareCreateAction.js │ ├── middlewareCreateNotFoundAction.js │ └── redirect.js ├── connectRoutes.js ├── flow-types.js ├── index.js ├── pure-utils │ ├── actionToPath.js │ ├── attemptCallRouteThunk.js │ ├── canUseDom.js │ ├── changePageTitle.js │ ├── confirmLeave.js │ ├── createThunk.js │ ├── isLocationAction.js │ ├── isReactNative.js │ ├── isRedirectAction.js │ ├── isServer.js │ ├── nestAction.js │ ├── objectValues.js │ ├── pathToAction.js │ ├── pathnamePlusSearch.js │ └── setKind.js └── reducer │ └── createLocationReducer.js ├── troubleshooting.md ├── wallaby.js ├── webpack.config.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "react", 4 | ["env", { 5 | "targets": { 6 | "browsers": ["last 4 versions", "not ie <= 9"] 7 | }, 8 | "modules": false 9 | }], 10 | "stage-0" 11 | ], 12 | "env": { 13 | "development": { 14 | "presets": [ 15 | "react", 16 | ["env", { 17 | "targets": { 18 | "node": "current" 19 | } 20 | }] 21 | ] 22 | }, 23 | "test": { 24 | "presets": [ 25 | "react", 26 | ["env", { 27 | "targets": { 28 | "node": "current" 29 | } 30 | }] 31 | ] 32 | } 33 | }, 34 | "plugins": ["transform-flow-strip-types"] 35 | } 36 | 37 | -------------------------------------------------------------------------------- /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | languages: 2 | JavaScript: true 3 | 4 | engines: 5 | duplication: 6 | enabled: true 7 | config: 8 | languages: 9 | - javascript: 10 | fixme: 11 | enabled: true 12 | eslint: 13 | enabled: true 14 | config: 15 | config: .eslintrc.js 16 | checks: 17 | import/no-unresolved: 18 | enabled: false 19 | import/extensions: 20 | enabled: false 21 | 22 | ratings: 23 | paths: 24 | - "src/**" 25 | 26 | exclude_paths: 27 | - "docs/" 28 | - "dist/" 29 | - "flow-typed/" 30 | - "node_modules/" 31 | - ".vscode/" 32 | - ".eslintrc.js" 33 | - "**/*.snap" 34 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | [*] 3 | indent_style = space 4 | indent_size = 2 5 | end_of_line = lf 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | [*.md] 10 | trim_trailing_whitespace = false -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | dist 2 | flow-typed 3 | node_modules 4 | docs 5 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: 'babel-eslint', 3 | parserOptions: { 4 | ecmaFeatures: { 5 | generators: true, 6 | experimentalObjectRestSpread: true 7 | }, 8 | sourceType: 'module', 9 | allowImportExportEverywhere: false 10 | }, 11 | plugins: ['flowtype'], 12 | extends: ['airbnb', 'plugin:flowtype/recommended'], 13 | settings: { 14 | flowtype: { 15 | onlyFilesWithFlowAnnotation: true 16 | } 17 | }, 18 | globals: { 19 | window: true, 20 | document: true, 21 | __dirname: true, 22 | __DEV__: true, 23 | CONFIG: true, 24 | process: true, 25 | jest: true, 26 | describe: true, 27 | test: true, 28 | it: true, 29 | expect: true, 30 | beforeEach: true 31 | }, 32 | 'import/resolver': { 33 | node: { 34 | extensions: ['.js', '.css', '.json', '.styl'] 35 | } 36 | }, 37 | 'import/extensions': ['.js'], 38 | 'import/ignore': ['node_modules', 'flow-typed', '\\.(css|styl|svg|json)$'], 39 | rules: { 40 | 'no-shadow': 0, 41 | 'no-use-before-define': 0, 42 | 'no-param-reassign': 0, 43 | 'react/prop-types': 0, 44 | 'react/no-render-return-value': 0, 45 | 'no-confusing-arrow': 0, 46 | 'no-underscore-dangle': 0, 47 | 'no-plusplus': 0, 48 | camelcase: 1, 49 | 'prefer-template': 1, 50 | 'react/no-array-index-key': 1, 51 | 'global-require': 1, 52 | 'react/jsx-indent': 1, 53 | 'dot-notation': 1, 54 | 'import/no-named-default': 1, 55 | 'no-unused-vars': 1, 56 | 'import/no-unresolved': 1, 57 | 'flowtype/no-weak-types': 1, 58 | 'consistent-return': 1, 59 | 'no-nested-ternary': 1, 60 | 'no-return-assign': 1, 61 | 'no-continue': 1, 62 | semi: [2, 'never'], 63 | 'no-console': [2, { allow: ['warn', 'error'] }], 64 | 'flowtype/semi': [2, 'never'], 65 | 'jsx-quotes': [2, 'prefer-single'], 66 | 'react/jsx-filename-extension': [2, { extensions: ['.jsx', '.js'] }], 67 | 'spaced-comment': [2, 'always', { markers: ['?'] }], 68 | 'arrow-parens': [2, 'as-needed', { requireForBlockBody: false }], 69 | 'brace-style': [2, 'stroustrup'], 70 | 'no-unused-expressions': [ 71 | 2, 72 | { 73 | allowShortCircuit: true, 74 | allowTernary: true, 75 | allowTaggedTemplates: true 76 | } 77 | ], 78 | 'import/no-extraneous-dependencies': [ 79 | 'error', 80 | { 81 | devDependencies: true, 82 | optionalDependencies: true, 83 | peerDependencies: true 84 | } 85 | ], 86 | 'comma-dangle': [ 87 | 2, 88 | { 89 | arrays: 'never', 90 | objects: 'never', 91 | imports: 'never', 92 | exports: 'never', 93 | functions: 'never' 94 | } 95 | ], 96 | 'max-len': [ 97 | 'error', 98 | { 99 | code: 80, 100 | tabWidth: 2, 101 | ignoreUrls: true, 102 | ignoreComments: true, 103 | ignoreRegExpLiterals: true, 104 | ignoreStrings: true, 105 | ignoreTemplateLiterals: true 106 | } 107 | ], 108 | 'react/sort-comp': [ 109 | 2, 110 | { 111 | order: [ 112 | 'propTypes', 113 | 'props', 114 | 'state', 115 | 'defaultProps', 116 | 'contextTypes', 117 | 'childContextTypes', 118 | 'getChildContext', 119 | 'static-methods', 120 | 'lifecycle', 121 | 'everything-else', 122 | 'render' 123 | ] 124 | } 125 | ] 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /.flowconfig: -------------------------------------------------------------------------------- 1 | [ignore] 2 | .*/node_modules/config-chain/.* 3 | .*/node_modules/npmconf/.* 4 | 5 | [include] 6 | 7 | [libs] 8 | 9 | [options] 10 | 11 | esproposal.class_static_fields=enable 12 | esproposal.class_instance_fields=enable 13 | 14 | module.file_ext=.js 15 | module.file_ext=.json 16 | module.system=haste 17 | 18 | suppress_comment=\\(.\\|\n\\)*\\$FlowFixMe 19 | suppress_comment=\\(.\\|\n\\)*\\$FlowIssue 20 | suppress_comment=\\(.\\|\n\\)*\\$FlowGlobal 21 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # redux-first-router maintainers 2 | * @ScriptedAlchemy 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | coverage 3 | node_modules 4 | *.log 5 | .idea 6 | .DS_Store 7 | package-lock.json 8 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | coverage 2 | __test-helpers__ 3 | __tests__ 4 | docs 5 | flow-typed 6 | src 7 | examples 8 | .github 9 | .idea 10 | 11 | *.log 12 | 13 | .babelrc 14 | .codeclimate.yml 15 | .editorconfig 16 | .eslintrc.js 17 | .snyk 18 | .travis.yml 19 | wallaby.js 20 | webpack.config.js 21 | .eslintignore 22 | .flowconfig 23 | *.png 24 | troubleshooting.md 25 | yarn.lock 26 | -------------------------------------------------------------------------------- /.snyk: -------------------------------------------------------------------------------- 1 | # Snyk (https://snyk.io) policy file, patches or ignores known vulnerabilities. 2 | version: v1.12.0 3 | # ignores vulnerabilities until expiry date; change duration by modifying expiry date 4 | ignore: 5 | 'npm:chownr:20180731': 6 | - cacache > chownr: 7 | reason: 'Introduced via semantic-release, not relevant in production' 8 | - npm-profile > make-fetch-happen > cacache > chownr: 9 | reason: 'Introduced via semantic-release, not relevant in production' 10 | - libcipm > pacote > cacache > chownr: 11 | reason: 'Introduced via semantic-release, not relevant for production' 12 | - libcipm > pacote > tar > chownr: 13 | reason: 'Introduced via semantic-release, not relevant for production' 14 | - npm-registry-fetch > make-fetch-happen > cacache > chownr: 15 | reason: 'Introduced via semantic-release, not relevant for production' 16 | - libcipm > pacote > make-fetch-happen > cacache > chownr: 17 | reason: 'Introduced via semantic-release, not relevant for production' 18 | - libnpmhook > npm-registry-fetch > make-fetch-happen > cacache > chownr: 19 | reason: 'Introduced via semantic-release, not relevant for production' 20 | 'npm:mem:20180117': 21 | - libnpx > yargs > os-locale > mem: 22 | reason: 'Introduced via semantic-release, not relevant for production' 23 | patch: {} 24 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - stable 4 | cache: yarn 5 | install: yarn --ignore-engines # ignore engines to test node 6, otherwise it fails on engine check 6 | jobs: 7 | include: 8 | - stage: Build 9 | name: Travis Status 10 | script: npx travis-github-status 11 | name: Linting 12 | script: npm run lint 13 | name: Flow 14 | script: npm run flow 15 | name: Snyk 16 | script: snyk 17 | notifications: 18 | email: false 19 | webhooks: 20 | urls: 21 | - https://webhooks.gitter.im/e/5156be73e058008e1ed2 22 | on_success: always # options: [always|never|change] default: always 23 | on_failure: always # options: [always|never|change] default: always 24 | on_start: never # options: [always|never|change] default: always 25 | after_success: 26 | - npm run semantic-release 27 | branches: 28 | except: 29 | - /^v\d+\.\d+\.\d+$/ 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 James Gillmore 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Redux-First Router 2 | Think of your app in terms of _states_, not _routes_ or _components_. Connect your components and just dispatch _Flux Standard Actions_! 3 | 4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 | ## Motivation
23 | To be able to use Redux *as is* while keeping the address bar in sync. To define paths as actions, and handle path params and query strings as action payloads.
24 |
25 | The address bar and Redux actions should be *bi-directionally* mapped, including via the browser's back/forward buttons. Dispatch an action and the address bar updates.
26 | Change the address, and an action is dispatched.
27 |
28 | In addition, here are some obstacles **Redux-First Router** seeks to *avoid*:
29 |
30 | * Rendering from state that doesn't come from Redux
31 | * Dealing with the added complexity from having state outside of Redux
32 | * Cluttering components with route-related code
33 | * Large API surface areas of frameworks like `react-router` and `next.js`
34 | * Routing frameworks getting in the way of optimizing animations (such as when animations coincide with component updates).
35 | * Having to do route changes differently in order to support server-side rendering.
36 |
37 | ## Usage
38 |
39 | ### Install
40 | `yarn add redux-first-router`
41 |
42 | (A minimal `` component exists in the separate package [`redux-first-router-link`](https://github.com/faceyspacey/redux-first-router-link).)
43 |
44 | ### Quickstart
45 |
46 | ```js
47 | // configureStore.js
48 | import { applyMiddleware, combineReducers, compose, createStore } from 'redux'
49 | import { connectRoutes } from 'redux-first-router'
50 |
51 | import page from './pageReducer'
52 |
53 | const routesMap = {
54 | HOME: '/',
55 | USER: '/user/:id'
56 | }
57 |
58 | export default function configureStore(preloadedState) {
59 | const { reducer, middleware, enhancer } = connectRoutes(routesMap)
60 |
61 | const rootReducer = combineReducers({ page, location: reducer })
62 | const middlewares = applyMiddleware(middleware)
63 | const enhancers = compose(enhancer, middlewares)
64 |
65 | const store = createStore(rootReducer, preloadedState, enhancers)
66 |
67 | return { store }
68 | }
69 | ```
70 |
71 | ```js
72 | // pageReducer.js
73 | import { NOT_FOUND } from 'redux-first-router'
74 |
75 | const components = {
76 | HOME: 'Home',
77 | USER: 'User',
78 | [NOT_FOUND]: 'NotFound'
79 | }
80 |
81 | export default (state = 'HOME', action = {}) => components[action.type] || state
82 | ```
83 |
84 | ```js
85 | // App.js
86 | import React from 'react'
87 | import { connect } from 'react-redux'
88 |
89 | // Contains 'Home', 'User' and 'NotFound'
90 | import * as components from './components';
91 |
92 | const App = ({ page }) => {
93 | const Component = components[page]
94 | return