├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── config ├── env.js ├── jest │ ├── cssTransform.js │ └── fileTransform.js ├── paths.js ├── polyfills.js ├── webpack.config.dev.js ├── webpack.config.prod.js └── webpackDevServer.config.js ├── diagram.png ├── diagram.xml ├── package.json ├── public ├── favicon.ico ├── index.html └── manifest.json ├── scripts ├── build.js ├── start.js └── test.js ├── src ├── App.css ├── App.js ├── App.test.js ├── UserCreator │ ├── actions.js │ ├── index.js │ └── reducer.js ├── index.css ├── index.js ├── state │ ├── app.js │ └── users.js └── store.js └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | 6 | # testing 7 | /coverage 8 | 9 | # production 10 | /build 11 | 12 | # misc 13 | .DS_Store 14 | .env.local 15 | .env.development.local 16 | .env.test.local 17 | .env.production.local 18 | 19 | npm-debug.log* 20 | yarn-debug.log* 21 | yarn-error.log* 22 | .vscode -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "8.4" -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Riku Rouvila 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 | # Scaling a Redux app - reusable containers 2 | 3 | 4 | 5 | The idea here is to experiment with building Redux applications by reusing Redux applications. So basically a classic Yo Dawg situation. I'm doing this in the name of science and in the hope that future generations would have a more structured way of building user interfaces. 6 | 7 | This repository functions as a sandbox for testing different methods of cluing these Redux apps together. The way I see it, this could potentially lead into more scalable Redux codebases as you would now have a concept even greater than components or containers, that both are quite low-level. Also a really practical thing you could use this for would be a bit more complex reusable components like date pickers that have a state of their own, but have to use `this.setState`, component state & other funny stuff for managing the state. 8 | 9 | ### Structure of this repository 10 | 11 | Currently on the master branch, I have a component called [``](https://github.com/rikukissa/redux-isolated-apps/tree/master/src/UserCreator) that has the purpose of being a component/widget/fragment that the user would use for creating new users. Basically it's just an text input and a submit button. 12 | 13 | From the developer's point of view, I would want to be able to include this component anywhere in my app, without always having to manually connect to a store. So in the best case scenario, the only thing you would need is a `` tag. 14 | 15 | **Few design principles for ``:** 16 | - It should not know anything about being isolated 17 | - Its implementation should look exactly like normal container's 18 | - It communicates to outer world only via props 19 | - It can only access its own state + state given to it as props 20 | - It can be attached anywhere on the app without any additional setup 21 | - Middleware / enhancers used in its reducer / action creator implementation should be completely independent from app's 22 | - e.g. Using redux-saga as a part of UserCreator does not mean it should be also added to `src/store.js` 23 | 24 | I would also want it to be isolated in a way that it wouldn't share any state with other UserCreators in my app. In the future it will include a lot of logic (both async and sync), and for that reason I want to be able to leverage the action/action creator/reducer pattern Redux provides. 25 | 26 | I've written a few [tests](https://github.com/rikukissa/redux-isolated-apps/blob/master/src/App.test.js) to make these requirements a bit clearer, but unfortunately in the master branch some of them are failing. You can run them by running `yarn test` or `npm test`. All the relevant code for this endeavour lives under the `src` directory. 27 | 28 | **Leave an issue / a pull request if you know other ways of making Redux apps scalable or if you have other problems that you would like to solve.** 29 | 30 | ## Methods & libraries I've tried so far 31 | 32 | ### Nested stores 33 | ![](https://travis-ci.org/rikukissa/redux-isolated-apps.svg?branch=substores) 34 | [Pull request](https://github.com/rikukissa/redux-isolated-apps/pull/2) 35 | 36 | I got this idea from [Redux's documentation](http://redux.js.org/docs/recipes/IsolatingSubapps.html). It's fairly simple to see what's going on just by looking at the code and I actually managed to get everything working with this method as well. At first my main concern was whether the tooling will play well with multiple stores, but it turns out that at least Redux DevTools lets you choose which store you want to observe. 37 | 38 | #### Pros 39 | - Minimalistic approach, easy to understand 40 | - No additional dependencies needed 41 | - Minimal code changes required when isolating pieces of UI 42 | - Easy integration with redux-loop, redux-forms etc 43 | - You can reuse the component dynamically anywhere without needing to explicitely define it in reducer 44 | - App store state remains a bit cleaner 45 | 46 | #### Cons/Challenges 47 | ##### Case 1. 48 | **Example case** 49 | > I want to notify the parent component about created user **after** it has been succesfully stored to our API 50 | 51 | ```js 52 | 53 | ``` 54 | 55 | **The problem** 56 | > How do I call **this.props.onUserCreated** as a consequence of a dispatched **MY_NESTED/USER_SAVED_SUCCESS**? 57 | 58 | 59 | **Possible solutions** 60 | Since we are creating a nested store for our component, we might as well apply middlewares to that store. We could, for instance create a "bridge" between internal actions and prop callback calls. The underlying middleware would take care of calling these defined methods when ever a suitable action is dispatched. 61 | 62 | ```js 63 | // like mapStateToProps & mapDispatchToProps 64 | function actionsToProps(props) { 65 | return { 66 | /* dispatched action */ /* callback prop call */ 67 | [CREATE_USER]: (state, action) => props.onUserCreated({ name: state.name }) 68 | }; 69 | } 70 | ``` 71 | 72 | The thing that I don't like about this approach is, that you now have to expose action types to components / containers. It seems like a small thing that I would be willing to live with, but I have a feeling it might be a telltale sign of an architectural problem. 73 | 74 | 75 | 76 | ##### Case 2. 77 | 78 | **Example case** 79 | > I want to create a button for clearing all UserCreator inputs 80 | 81 | 🙃 This is where it gets even more complicated. 82 | 83 | **The problem** 84 | > How do I trigger **MY_NESTED/CLEAR** as a consequence of **TOP_LEVEL/CLEAR_ALL**? 85 | 86 | 87 | The only semi-clean way of doing it that I could come up with, would be to create a `top-level action -> nested store action` bridge. Something similar to what you saw above, but with 88 | 89 | ```js 90 | // like mapStateToProps & mapDispatchToProps & actionsToProps 91 | function actionsToActions() { 92 | return { 93 | /* top-level */ /* nested */ 94 | [CLEAR_ALL]: CLEAR_INPUT 95 | }; 96 | } 97 | ``` 98 | 99 | However, it would still have the same problem with components knowing about actions. On top of that, as far as I know this impossible to achieve with a vanilla Redux store instance. This is because a Redux store doesn't provide a way of listening actions it receives. This is why we used a middleware to solve the previous problem. Now we can't really do that, because we are not no longer in control of the store which exists outside the scope of our UserCreator component 😔 100 | 101 | **Possible solutions** 102 | I wish I had one. 103 | 104 | --- 105 | 106 | ### [redux-subspace](https://github.com/ioof-holdings/redux-subspace) 107 | ![](https://travis-ci.org/rikukissa/redux-isolated-apps.svg?branch=subspaces) 108 | [Pull request](https://github.com/rikukissa/redux-isolated-apps/pull/3) 109 | 110 | >After using this method in production for couple of months now, I already feel like some parts of the codebase become overly complicated. Most of it is because it's quite difficult to see just by looking at the code to which "subspace" the component / actions belong to. This can potentially be remedied by avoiding the usage of `globalAction` and coming up with some way of achieving your goal just by using component props. Other thing I would advice against is the usage of [wormholes](https://github.com/ioof-holdings/redux-subspace/blob/master/docs/advanced/GlobalState.md#wormholes). It's most likely better to pass the required data down as props. 111 | **Would still recommend this library and I'm keen to see how it evolves in the future** 112 | 113 | I bumped into this by accident while googling this subject. At first the documentation was a bit off-putting, but once I got desperate enough, I decided to give it a go. I definitely recommend checking it out, since it has been easily the best solution I've found so far. Once you start using it, you will find the documentation actually quite nicely structured. 114 | 115 | Besides just offering a solution to the problem I described above, I wanted to make sure it also works with other libraries we're often using in our apps. There are currently 2 different branches I made for this: 116 | - ![](https://travis-ci.org/rikukissa/redux-isolated-apps.svg?branch=subspaces-redux-loop) [redux-loop](https://github.com/rikukissa/redux-isolated-apps/pull/4) 117 | - ![](https://travis-ci.org/rikukissa/redux-isolated-apps.svg?branch=subspaces-redux-form) [redux-form](https://github.com/rikukissa/redux-isolated-apps/pull/6) 118 | 119 | **Pros**: 120 | - Easy to use, well tested & documented 121 | - Integrates well with middlewares and store enhancers like redux-saga, redux-observable & redux-loop 122 | 123 | **Cons:** 124 | - Might hurt the readability of your code in the long run 125 | - Subspaced component's state becomes a part of your app's store state, which by default means, that you have to define a location for that explicitly in your reducer. This makes it a bit tricky to dynamically add new subspaced components. However, there is an additional library [redux-dynamic-reducer](https://github.com/ioof-holdings/redux-dynamic-reducer) for addressing this problem. 126 | - The reducer & action creators are dependant on the main app's middlewares / store enhancers. Using redux-thunk / redux-loop in the subspaced component's logic forces the app to also have them installed. This is not a problem with substores. 127 | - It didn't support redux-loop when I first started using it, but I managed to fix this by writing a wrapper function, that now can be found as the [`redux-subspace-loop`](https://github.com/ioof-holdings/redux-subspace/tree/master/packages/redux-subspace-loop) package on npm. 128 | 129 | This project was bootstrapped with [Create React App](https://github.com/facebookincubator/create-react-app). 130 | -------------------------------------------------------------------------------- /config/env.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const fs = require('fs'); 4 | const path = require('path'); 5 | const paths = require('./paths'); 6 | 7 | // Make sure that including paths.js after env.js will read .env variables. 8 | delete require.cache[require.resolve('./paths')]; 9 | 10 | const NODE_ENV = process.env.NODE_ENV; 11 | if (!NODE_ENV) { 12 | throw new Error( 13 | 'The NODE_ENV environment variable is required but was not specified.' 14 | ); 15 | } 16 | 17 | // https://github.com/bkeepers/dotenv#what-other-env-files-can-i-use 18 | var dotenvFiles = [ 19 | `${paths.dotenv}.${NODE_ENV}.local`, 20 | `${paths.dotenv}.${NODE_ENV}`, 21 | // Don't include `.env.local` for `test` environment 22 | // since normally you expect tests to produce the same 23 | // results for everyone 24 | NODE_ENV !== 'test' && `${paths.dotenv}.local`, 25 | paths.dotenv, 26 | ].filter(Boolean); 27 | 28 | // Load environment variables from .env* files. Suppress warnings using silent 29 | // if this file is missing. dotenv will never modify any environment variables 30 | // that have already been set. 31 | // https://github.com/motdotla/dotenv 32 | dotenvFiles.forEach(dotenvFile => { 33 | if (fs.existsSync(dotenvFile)) { 34 | require('dotenv').config({ 35 | path: dotenvFile, 36 | }); 37 | } 38 | }); 39 | 40 | // We support resolving modules according to `NODE_PATH`. 41 | // This lets you use absolute paths in imports inside large monorepos: 42 | // https://github.com/facebookincubator/create-react-app/issues/253. 43 | // It works similar to `NODE_PATH` in Node itself: 44 | // https://nodejs.org/api/modules.html#modules_loading_from_the_global_folders 45 | // Note that unlike in Node, only *relative* paths from `NODE_PATH` are honored. 46 | // Otherwise, we risk importing Node.js core modules into an app instead of Webpack shims. 47 | // https://github.com/facebookincubator/create-react-app/issues/1023#issuecomment-265344421 48 | // We also resolve them to make sure all tools using them work consistently. 49 | const appDirectory = fs.realpathSync(process.cwd()); 50 | process.env.NODE_PATH = (process.env.NODE_PATH || '') 51 | .split(path.delimiter) 52 | .filter(folder => folder && !path.isAbsolute(folder)) 53 | .map(folder => path.resolve(appDirectory, folder)) 54 | .join(path.delimiter); 55 | 56 | // Grab NODE_ENV and REACT_APP_* environment variables and prepare them to be 57 | // injected into the application via DefinePlugin in Webpack configuration. 58 | const REACT_APP = /^REACT_APP_/i; 59 | 60 | function getClientEnvironment(publicUrl) { 61 | const raw = Object.keys(process.env) 62 | .filter(key => REACT_APP.test(key)) 63 | .reduce( 64 | (env, key) => { 65 | env[key] = process.env[key]; 66 | return env; 67 | }, 68 | { 69 | // Useful for determining whether we’re running in production mode. 70 | // Most importantly, it switches React into the correct mode. 71 | NODE_ENV: process.env.NODE_ENV || 'development', 72 | // Useful for resolving the correct path to static assets in `public`. 73 | // For example, . 74 | // This should only be used as an escape hatch. Normally you would put 75 | // images into the `src` and `import` them in code to get their paths. 76 | PUBLIC_URL: publicUrl, 77 | } 78 | ); 79 | // Stringify all values so we can feed into Webpack DefinePlugin 80 | const stringified = { 81 | 'process.env': Object.keys(raw).reduce((env, key) => { 82 | env[key] = JSON.stringify(raw[key]); 83 | return env; 84 | }, {}), 85 | }; 86 | 87 | return { raw, stringified }; 88 | } 89 | 90 | module.exports = getClientEnvironment; 91 | -------------------------------------------------------------------------------- /config/jest/cssTransform.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // This is a custom Jest transformer turning style imports into empty objects. 4 | // http://facebook.github.io/jest/docs/tutorial-webpack.html 5 | 6 | module.exports = { 7 | process() { 8 | return 'module.exports = {};'; 9 | }, 10 | getCacheKey() { 11 | // The output is always the same. 12 | return 'cssTransform'; 13 | }, 14 | }; 15 | -------------------------------------------------------------------------------- /config/jest/fileTransform.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const path = require('path'); 4 | 5 | // This is a custom Jest transformer turning file imports into filenames. 6 | // http://facebook.github.io/jest/docs/tutorial-webpack.html 7 | 8 | module.exports = { 9 | process(src, filename) { 10 | return `module.exports = ${JSON.stringify(path.basename(filename))};`; 11 | }, 12 | }; 13 | -------------------------------------------------------------------------------- /config/paths.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const path = require('path'); 4 | const fs = require('fs'); 5 | const url = require('url'); 6 | 7 | // Make sure any symlinks in the project folder are resolved: 8 | // https://github.com/facebookincubator/create-react-app/issues/637 9 | const appDirectory = fs.realpathSync(process.cwd()); 10 | const resolveApp = relativePath => path.resolve(appDirectory, relativePath); 11 | 12 | const envPublicUrl = process.env.PUBLIC_URL; 13 | 14 | function ensureSlash(path, needsSlash) { 15 | const hasSlash = path.endsWith('/'); 16 | if (hasSlash && !needsSlash) { 17 | return path.substr(path, path.length - 1); 18 | } else if (!hasSlash && needsSlash) { 19 | return `${path}/`; 20 | } else { 21 | return path; 22 | } 23 | } 24 | 25 | const getPublicUrl = appPackageJson => 26 | envPublicUrl || require(appPackageJson).homepage; 27 | 28 | // We use `PUBLIC_URL` environment variable or "homepage" field to infer 29 | // "public path" at which the app is served. 30 | // Webpack needs to know it to put the right