├── .babelrc ├── .gitignore ├── .npmrc ├── .prettierignore ├── .prettierrc ├── .travis.yml ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── jest.config.js ├── package.json ├── rollup.config.js ├── src ├── index.js ├── shallow-equals.js └── warning.js └── test ├── create-test-components.js ├── index.test.js ├── setup.js ├── shallow-equals.test.js └── warning.test.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "build": { 4 | "presets": [["env", { "modules": false }], "stage-3", "react"], 5 | "plugins": ["external-helpers", "transform-class-properties"] 6 | }, 7 | "buildProd": { 8 | "presets": [["env", { "modules": false }], "stage-3", "react"], 9 | "plugins": ["external-helpers", "transform-class-properties"] 10 | }, 11 | "es": { 12 | "presets": [["env", { "modules": false }], "stage-3", "react"], 13 | "plugins": ["transform-class-properties"] 14 | }, 15 | "commonjs": { 16 | "plugins": [ 17 | ["transform-es2015-modules-commonjs", { "loose": true }], 18 | "transform-class-properties" 19 | ], 20 | "presets": ["stage-3", "react"] 21 | }, 22 | "test": { 23 | "presets": ["env", "stage-3", "react"], 24 | "plugins": ["transform-class-properties"] 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # next.js build output 61 | .next 62 | 63 | package-lock.json 64 | dist 65 | es 66 | lib 67 | 68 | npm-debug.log* 69 | yarn-debug.log* 70 | yarn-error.log* -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | package.json -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "jsxBracketSameLine": true, 4 | "trailingComma": "es5" 5 | } -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "8" 4 | sudo: false 5 | notifications: 6 | email: false 7 | after_success: 8 | # Upload to coveralls, but don't _fail_ if coveralls is down. 9 | - cat coverage/lcov.info | node_modules/.bin/coveralls || echo "Coveralls upload failed" 10 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ### v1.0.0 (2019/2/12) 4 | 5 | No changes; this release marks API stability. 6 | 7 | ### v0.3.0 (2018/5/29) 8 | 9 | This update is largely a response to @gaearon's great feedback. 10 | 11 | * The Provider is now more performant. It will no longer render its Consumers 12 | when its parents rerender. 13 | * Another performance improvement: the Provider will only render its Consumers 14 | when the state value has changed (using a shallow equality check). 15 | * The actions API is simpler, drawing inspiration from redux-thunk. Note that 16 | this is a breaking change, although no functionality has been removed. 17 | * State must now be an object or null, like a React component's state. 18 | * New, helpful warnings have been introduced. 19 | 20 | ### v0.2.1 (2018/5/25) 21 | 22 | * Fixes a bug where calling `setState` would not work as intended. 23 | 24 | ### v0.2.0 (2018/5/25) 25 | 26 | * Refactor the API to no longer bind actions to the Component instance. 27 | 28 | ### v0.1.0 (2018/5/25) 29 | 30 | * Expand the React `peerDependency` range to include v0.14.0. 31 | 32 | ### v0.0.2 (2018/5/25) 33 | 34 | This was the first release of the library. 35 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | * Using welcoming and inclusive language 12 | * Being respectful of differing viewpoints and experiences 13 | * Gracefully accepting constructive criticism 14 | * Focusing on what is best for the community 15 | * Showing empathy towards other community members 16 | 17 | Examples of unacceptable behavior by participants include: 18 | 19 | * The use of sexualized language or imagery and unwelcome sexual attention or advances 20 | * Trolling, insulting/derogatory comments, and personal or political attacks 21 | * Public or private harassment 22 | * Publishing others' private information, such as a physical or electronic address, without explicit permission 23 | * Other conduct which could reasonably be considered inappropriate in a professional setting 24 | 25 | ## Our Responsibilities 26 | 27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 28 | 29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | ## Scope 32 | 33 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 34 | 35 | ## Enforcement 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at jamesplease2@gmail.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. 38 | 39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. 40 | 41 | ## Attribution 42 | 43 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] 44 | 45 | [homepage]: http://contributor-covenant.org 46 | [version]: http://contributor-covenant.org/version/1/4/ 47 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | > :wave: Hey there! Thanks for your interest in helping out. If you happen to 4 | > run into any issues following this guide, please 5 | > [open an issue](https://github.com/jamesplease/react-state-context/issues/new?title=Contributing+help), 6 | > and we'll do our best to help out. 7 | 8 | To begin contributing, you'll first need to 9 | [clone this repository](https://help.github.com/articles/cloning-a-repository/), 10 | then navigate into the repository's directory. 11 | 12 | ```sh 13 | git clone git@github.com:jamesplease/react-state-context.git 14 | 15 | cd react-state-context 16 | ``` 17 | 18 | Next, install the dependencies using [npm](https://www.npmjs.com/). 19 | 20 | ```js 21 | npm install 22 | ``` 23 | 24 | ### Contributing to the Code 25 | 26 | The source files can be found in `./src`. As you work, and/or when you're done, run 27 | `npm test` to run the unit tests. 28 | 29 | Once you're done, go ahead and open a Pull Request. 30 | 31 | > :information_desk_person: Don't sweat it if you can't get the tests to pass, 32 | > or if you can't finish the changes you'd like to make. If you still open up a 33 | > Pull Request, we'll make sure it gets figured out. 34 | 35 | ### One More Thing... 36 | 37 | Thanks again! 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 James 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 | # React State Context 2 | 3 | [![Travis build status](http://img.shields.io/travis/jamesplease/react-state-context.svg?style=flat)](https://travis-ci.org/jamesplease/react-state-context) 4 | [![npm version](https://img.shields.io/npm/v/react-state-context.svg)](https://www.npmjs.com/package/react-state-context) 5 | [![Test Coverage](https://coveralls.io/repos/github/jamesplease/react-state-context/badge.svg?branch=master)](https://coveralls.io/github/jamesplease/react-state-context?branch=master) 6 | [![gzip size](http://img.badgesize.io/https://unpkg.com/react-state-context/dist/react-state-context.min.js?compression=gzip)](https://unpkg.com/react-state-context/dist/react-state-context.min.js) 7 | 8 | Lightweight state management using React Context. 9 | 10 | ✔ Built on React primitives 11 | ✔ Provides a familiar API 12 | ✔ Designed with a minimal learning curve in mind 13 | ✔ Reasonable file size (~2kb gzipped) 14 | 15 | ## Motivation 16 | 17 | When you are getting started with React, storing all of your application state within individual Components' 18 | [state](https://reactjs.org/docs/state-and-lifecycle.html#adding-local-state-to-a-class) tends to work well. 19 | Component state is a good solution for storing data. 20 | 21 | A limitation of component state is that it can be tedious to share it between components 22 | that are not near one another within your application's component tree. This problem may become more pronounced 23 | as your application grows, and as some of your data needs to be made available to a larger number of separated 24 | components. 25 | 26 | React provides an API to solve this problem: **[Context](https://reactjs.org/docs/context.html)**. Context is a 27 | mechanism to more easily share data between components, even when they are not close. 28 | 29 | As delightful as the Context API is, it is a low-level tool, so using it directly can be a little verbose sometimes. 30 | It also doesn't provide opinions on _how_ it should be used, so it can take some time to figure out an organized system for 31 | working with it. Lastly, it has [some caveats](https://reactjs.org/docs/context.html#caveats) that can trip you up. For these 32 | reasons I created **React State Context**. 33 | 34 | React State Context is a thin wrapper around Context that provides a small amount of structure. This structure 35 | helps reduce the boilerplate that you must write, and it also helps you to stay organized. Plus, when you use State 36 | Context, you can be confident that you are avoiding the caveats that accompany using Context directly. 37 | 38 | ### Installation 39 | 40 | Install using [npm](https://www.npmjs.com): 41 | 42 | ``` 43 | npm install react-state-context 44 | ``` 45 | 46 | or [yarn](https://yarnpkg.com/): 47 | 48 | ``` 49 | yarn add react-state-context 50 | ``` 51 | 52 | ## Concepts 53 | 54 | React State Context has three concepts. 55 | 56 | ### StateContext 57 | 58 | A StateContext is a wrapper around a normal React Context object. Like a regular Context object, it has two properties: 59 | `Provider` and `Consumer`. 60 | 61 | You use StateContexts in the same way as regular Context. If you have used the new Context API, then it should feel 62 | familiar to use StateContexts. If you haven't, don't worry. If I was able to learn it, then you can, too! 63 | 64 | > :information_desk_person: The [React documentation on Context](https://reactjs.org/docs/context.html#when-to-use-context) 65 | > is a great resource. It can be helpful to familiarize yourself with the content on that page before using State Context. 66 | 67 | What is different about a StateContext is that the value that the Consumer provides you with has the 68 | following structure: 69 | 70 | ``` 71 | { 72 | state, 73 | ...actions 74 | } 75 | ``` 76 | 77 | State and actions are the other two concepts of React State Context. Let's take a look! 78 | 79 | ### State 80 | 81 | Every StateContext object has an internal state object. Behind the scenes, it is a regular Component's state object. When you render 82 | a `StateContext.Consumer`, the value passed to the render prop will include a `state` attribute. 83 | 84 | ```jsx 85 | 86 | {value => { 87 | console.log('The current state is:', value.state); 88 | }} 89 | 90 | ``` 91 | 92 | Like a React Component's state, the StateContext state must be an object or null. 93 | 94 | ### Actions 95 | 96 | Actions are functions that you define, and they are how you modify the state. If you have used Redux, then you can 97 | think of them as serving a similar role to action creators. 98 | 99 | To update state, return a new value from your action. The returned value will be shallowly merged 100 | with the existing state. 101 | 102 | Let's take a look at an example action: 103 | 104 | ```js 105 | export function openModal() { 106 | return { 107 | isOpen: true, 108 | }; 109 | } 110 | ``` 111 | 112 | When you call an action from within your application, you can pass arguments to it. Let's use this to 113 | create an action to toggle a modal state based on what is passed into the action: 114 | 115 | ```js 116 | export function toggleModal(isOpen) { 117 | return { isOpen }; 118 | } 119 | ``` 120 | 121 | Sometimes, you may need the previous state within an action. In these situations, you can return a 122 | function from your action. This function will be called with one argument, `setState`. Use `setState` to update 123 | the state as you would using a React Component's `setState`: 124 | 125 | ```js 126 | export function createTodo(newTodo) { 127 | return function(setState) { 128 | setState(prevState => { 129 | return { 130 | todos: prevState.todos.concat([newTodo]) 131 | ); 132 | }); 133 | }; 134 | } 135 | ``` 136 | 137 | Note that `setState` differs from the Component `setState` in that there is no second argument. 138 | 139 | > :information_desk_person: Heads up! The actions API was inspired by [redux-thunk](https://github.com/reduxjs/redux-thunk). 140 | > If you have used that API, you may notice the similarity. In redux-thunk, the thunks are passed the arguments `(dispatch, getState)`. 141 | > In this library, you are passed `(setState)`. 142 | 143 | Along with `state`, the actions that you define will be included in the `value` that you receive from the Consumer: 144 | 145 | ```jsx 146 | 147 | {value => { 148 | console.log('I can add a todo using:', value.createTodo); 149 | console.log('I can delete todos using:', value.deleteTodo); 150 | }} 151 | 152 | ``` 153 | 154 | Once you feel comfortable with these concepts, you are ready to start using React State Context. 155 | 156 | ## API 157 | 158 | This library has one, default export: `createStateContext`. 159 | 160 | ### `createStateContext( actions, [initialState] )` 161 | 162 | Creates and returns a [StateContext](#state-context). 163 | 164 | * `actions` _[Object]_: The actions that modify the state. 165 | * `[initialState]` _[Object|null]_: Optional initial state for the StateContext. 166 | 167 | ```js 168 | import createStateContext from 'react-state-context'; 169 | import * as todoActions from './actions'; 170 | 171 | const TodoContext = createStateContext(todoActions, { 172 | todos: [], 173 | }); 174 | 175 | export default TodoContext; 176 | ``` 177 | 178 | Use a StateContext as you would any other Context. 179 | 180 | ```jsx 181 | import TodoContext from './contexts/todo'; 182 | 183 | export function App() { 184 | // To begin, you must render the Provider somewhere high up in the Component tree. 185 | return ( 186 | 187 | 188 | 189 | ); 190 | } 191 | ``` 192 | 193 | ```jsx 194 | import TodoContext from './contexts/todo'; 195 | 196 | export function DeeplyNestedChild() { 197 | // From anywhere within the Provider, you can access the value of the StateContext. 198 | return ( 199 | 200 | {value => { 201 | console.log('The current state is', value.state); 202 | console.log('All of the todos are here', value.state.todos); 203 | 204 | console.log('I can add a todo using:', value.createTodo); 205 | console.log('I can delete todos using:', value.deleteTodo); 206 | }} 207 | 208 | ); 209 | } 210 | ``` 211 | 212 | ## FAQ 213 | 214 | #### What version of React is required? 215 | 216 | You need to be using at least React v0.14. 217 | 218 | Although the new Context API was introduced in 16.3.0, this library is built using the excellent 219 | [create-react-context](https://github.com/jamiebuilds/create-react-context) library, which polyfills the 220 | API for older React versions. 221 | 222 | #### Why would someone use this over Redux? 223 | 224 | The reason that I initially started using Redux was to more easily share data between components. Although Redux can seem like a simple system once you become 225 | familiar with it, the number of concepts it has can make it daunting to newcomers. At least, that's how I felt when I was learning it. 226 | 227 | For me, React State Context solves the problems that I originally used Redux for in a more straightforward way, which is why I think a solution like this seems promising. 228 | 229 | #### What does one lose by migrating away from Redux? 230 | 231 | The Redux library supports middleware, and it enables time travel debugging, which are both things that you do not get from React State Context. If you 232 | rely heavily on those features of Redux, then this library may not be suitable for your needs. 233 | 234 | Outside of the Redux source code itself, there is an enormous community around that library. There are considerable benefits to using a library that has such a 235 | large number of users, and you will lose that community by switching to this library, or most other alternative state management libraries, for that matter. 236 | 237 | With that said, React State Context is built on top of React's built-in Context API. Although the new Context API is likely not very familiar to most React 238 | developers today, we believe that that will change as time goes on. 239 | 240 | #### How is this different from Unstated? 241 | 242 | [Unstated](https://github.com/jamiebuilds/unstated) is a fantastic library, and it served as inspiration for this library. The primary difference is that 243 | Unstated introduces new concepts for state management, like `Container` and `Subscribe`. One of the design goals of React State Context was to avoid 244 | introducing additional concepts whenever possible in an effort to reduce the learning curve. 245 | 246 | We believe that we avoided introducing those new concepts while still getting a remarkably similar developer experience. Perhaps you will 247 | feel the same way! 248 | 249 | ## Contributing 250 | 251 | Are you interested in helping out with this project? That's awesome – thank you! Head on over to 252 | [the contributing guide](./CONTRIBUTING.md) to get started. 253 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | collectCoverage: true, 3 | collectCoverageFrom: ['src/**/*.{js,jsx}', '!**/node_modules/**'], 4 | coverageDirectory: 'coverage', 5 | setupTestFrameworkScriptFile: './test/setup.js', 6 | testURL: 'http://localhost' 7 | }; 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-state-context", 3 | "version": "1.0.0", 4 | "description": "Lightweight state management using React Context.", 5 | "main": "lib/index.js", 6 | "module": "es/index.js", 7 | "scripts": { 8 | "clean": "rimraf dist es tmp lib", 9 | "test": "jest", 10 | "prepublish": "in-publish && npm run build || not-in-publish", 11 | "build": "npm run clean && npm run build:umd && npm run build:umd:min && npm run build:es && npm run build:commonjs", 12 | "build:commonjs": "cross-env BABEL_ENV=commonjs babel src --out-dir lib", 13 | "build:es": "cross-env BABEL_ENV=es babel src --out-dir es", 14 | "build:umd": "cross-env NODE_ENV=development BABEL_ENV=build rollup -c -i src/index.js -o dist/react-state-context.js", 15 | "build:umd:min": "cross-env NODE_ENV=production BABEL_ENV=buildProd rollup -c -i src/index.js -o dist/react-state-context.min.js" 16 | }, 17 | "repository": { 18 | "type": "git", 19 | "url": "https://github.com/jamesplease/react-state-context.git" 20 | }, 21 | "keywords": [ 22 | "react", 23 | "state", 24 | "context", 25 | "render", 26 | "prop", 27 | "state", 28 | "children", 29 | "redux", 30 | "flux", 31 | "action", 32 | "data" 33 | ], 34 | "author": "James Smith ", 35 | "license": "MIT", 36 | "bugs": { 37 | "url": "https://github.com/jamesplease/react-state-context/issues" 38 | }, 39 | "files": [ 40 | "dist", 41 | "lib", 42 | "es" 43 | ], 44 | "peerDependencies": { 45 | "react": "^0.14.0 || ^15.0.0 || ^16.0.0" 46 | }, 47 | "devDependencies": { 48 | "babel-cli": "^6.26.0", 49 | "babel-core": "^6.26.0", 50 | "babel-jest": "^22.1.0", 51 | "babel-plugin-external-helpers": "^6.22.0", 52 | "babel-plugin-transform-class-properties": "^6.24.1", 53 | "babel-plugin-transform-react-remove-prop-types": "^0.4.13", 54 | "babel-preset-env": "^1.6.1", 55 | "babel-preset-react": "^6.24.1", 56 | "babel-preset-stage-3": "^6.24.1", 57 | "coveralls": "^3.0.0", 58 | "cross-env": "^5.1.3", 59 | "in-publish": "^2.0.0", 60 | "jest": "^22.1.4", 61 | "jest-dom": "^1.3.0", 62 | "react": "^16.2.0", 63 | "react-dom": "^16.2.0", 64 | "react-test-renderer": "^16.2.0", 65 | "react-testing-library": "^3.1.3", 66 | "rimraf": "^2.6.2", 67 | "rollup": "^0.57.1", 68 | "rollup-plugin-babel": "^3.0.3", 69 | "rollup-plugin-commonjs": "^9.1.0", 70 | "rollup-plugin-node-resolve": "^3.3.0", 71 | "rollup-plugin-replace": "^2.0.0", 72 | "rollup-plugin-uglify": "^3.0.0" 73 | }, 74 | "dependencies": { 75 | "create-react-context": "^0.2.2", 76 | "prop-types": "^15.6.1" 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import nodeResolve from 'rollup-plugin-node-resolve'; 2 | import commonjs from 'rollup-plugin-commonjs'; 3 | import babel from 'rollup-plugin-babel'; 4 | import uglify from 'rollup-plugin-uglify'; 5 | import replace from 'rollup-plugin-replace'; 6 | 7 | var env = process.env.NODE_ENV; 8 | var config = { 9 | output: { 10 | name: 'createStateContext', 11 | globals: { 12 | react: 'React' 13 | }, 14 | format: 'umd' 15 | }, 16 | external: ['react'], 17 | context: 'this', 18 | plugins: [ 19 | nodeResolve({ 20 | jsnext: true 21 | }), 22 | commonjs({ 23 | include: 'node_modules/**' 24 | }), 25 | babel({ 26 | exclude: 'node_modules/**' 27 | }), 28 | replace({ 29 | 'process.env.NODE_ENV': JSON.stringify(env) 30 | }) 31 | ] 32 | }; 33 | 34 | if (env === 'production') { 35 | config.plugins.push( 36 | uglify({ 37 | compress: { 38 | pure_getters: true, 39 | unsafe: true, 40 | unsafe_comps: true, 41 | warnings: false 42 | } 43 | }) 44 | ); 45 | } 46 | 47 | export default config; 48 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import createReactContext from 'create-react-context'; 3 | import shallowEquals from './shallow-equals'; 4 | import { warning } from './warning'; 5 | 6 | function isValidState(state) { 7 | if ((state && typeof state !== 'object') || Array.isArray(state)) { 8 | return false; 9 | } else { 10 | return true; 11 | } 12 | } 13 | 14 | export default function createStateContext(actions = {}, initialState) { 15 | const Context = createReactContext(); 16 | 17 | let initialStateToUse; 18 | if (!isValidState(initialState)) { 19 | if (process.env.NODE_ENV !== 'production') { 20 | warning( 21 | `Warning: StateContext state must be an object or null. You passed an invalid value to createStateContext` + 22 | ` that has been ignored.`, 23 | 'INVALID_INITIAL_STATE' 24 | ); 25 | } 26 | } else { 27 | initialStateToUse = initialState; 28 | } 29 | 30 | if (process.env.NODE_ENV !== 'production') { 31 | if ((actions && typeof actions !== 'object') || Array.isArray(actions)) { 32 | warning( 33 | `You passed invalid actions to createStateContext. actions must be an object.`, 34 | 'INVALID_ACTIONS_ARGUMENT' 35 | ); 36 | } 37 | } 38 | 39 | class ProviderComponent extends Component { 40 | render() { 41 | const { children } = this.props; 42 | 43 | return ; 44 | } 45 | 46 | constructor(...args) { 47 | super(...args); 48 | 49 | let actionsToUse = {}; 50 | for (let key in actions) { 51 | const action = actions[key]; 52 | 53 | if (typeof action !== 'function') { 54 | if (process.env.NODE_ENV !== 'production') { 55 | warning( 56 | `Warning: an action with key ${key} was passed to createStateContext that was not a function. Actions` + 57 | ` must be functions. The ${key} action has been ignored. You should check your call to createStateContext().`, 58 | 'ACTION_MUST_BE_FN' 59 | ); 60 | } 61 | 62 | continue; 63 | } 64 | 65 | if (key === 'state') { 66 | if (process.env.NODE_ENV !== 'production') { 67 | warning( 68 | `Warning: an action was passed to createStateContext with the key "state". This is a reserved key,` + 69 | ` so your action has been ignored.`, 70 | 'INVALID_ACTION_KEY' 71 | ); 72 | } 73 | 74 | continue; 75 | } 76 | 77 | actionsToUse[key] = (...args) => this.onAction(action(...args)); 78 | } 79 | 80 | // The Provider's `value` is this Component's state. 81 | // From the API of ReactStateContext, we know that a Provider's value has the form: 82 | // 83 | // { 84 | // state, 85 | // ...actions 86 | // } 87 | // 88 | // Together, those two facts lead to there being a state attribute on this Component's 89 | // state. 90 | this.state = { 91 | state: initialStateToUse, 92 | ...actionsToUse, 93 | }; 94 | } 95 | 96 | onAction = returnValue => { 97 | const returnValueType = typeof returnValue; 98 | 99 | // If the value is undefined, then we have no update to make. 100 | if (returnValueType === 'undefined') { 101 | return; 102 | } 103 | 104 | // If they pass a function, then the action is a thunk. We pass them 105 | // the setState wrapper. 106 | else if (returnValueType === 'function') { 107 | returnValue(this.setStateWrapper); 108 | } 109 | 110 | // If it not undefined, nor a function, nor valid, then we log a warning and do nothing. 111 | else if (!isValidState(returnValue)) { 112 | if (process.env.NODE_ENV !== 'production') { 113 | warning( 114 | `Warning: StateContext actions must update state to an object or null. You called an action that` + 115 | ` set an invalid value. This value has been ignored, and the state has not been updated.`, 116 | 'INVALID_ACTION_UPDATE' 117 | ); 118 | } 119 | 120 | return; 121 | } 122 | 123 | // The last condition is if they return a plain object. 124 | // In that situation, we set the state after merging it. 125 | else { 126 | this.setState(prevState => { 127 | const merged = this.getUpdatedState(prevState, returnValue); 128 | return merged; 129 | }); 130 | } 131 | }; 132 | 133 | getUpdatedState = (prevState, newState) => { 134 | if (!isValidState(newState)) { 135 | if (process.env.NODE_ENV !== 'production') { 136 | warning( 137 | `Warning: StateContext actions must update state to an object or null. You called an action that` + 138 | ` set an invalid value. This value has been ignored, and the state has not been updated.`, 139 | 'INVALID_ACTION_UPDATE' 140 | ); 141 | } 142 | 143 | return; 144 | } 145 | 146 | // To compute the _potential_ new state, we shallow merge the two. 147 | let mergedState = 148 | newState === null ? null : Object.assign({}, prevState.state, newState); 149 | 150 | // If the previous value and the new value are shallowly equal, then we avoid the update altogether. 151 | // In this way, a StateContext.Provider behaves similarly to a PureComponent. 152 | if (shallowEquals(prevState.state, mergedState)) { 153 | return; 154 | } 155 | 156 | return { 157 | state: mergedState, 158 | }; 159 | }; 160 | 161 | // This function is what is called when you call an action. 162 | // `stateUpdate` is just similar to the first argument of `setState`, in that it can be 163 | // a function that is passed the previous state, or an update object. 164 | setStateWrapper = stateUpdate => { 165 | this.setState(prevState => { 166 | // To compute the new state to merge with the old, we mimic the behavior of a Component's 167 | // `setState`, allowing you to pass either an object or a function. 168 | const newState = 169 | typeof stateUpdate === 'function' 170 | ? stateUpdate(prevState.state) 171 | : stateUpdate; 172 | 173 | return this.getUpdatedState(prevState, newState); 174 | }); 175 | }; 176 | } 177 | 178 | return { 179 | Provider: ProviderComponent, 180 | Consumer: Context.Consumer, 181 | }; 182 | } 183 | -------------------------------------------------------------------------------- /src/shallow-equals.js: -------------------------------------------------------------------------------- 1 | export default function shallowEquals(a, b) { 2 | // Handle exact object matches and primitives 3 | if (a === b) { 4 | return true; 5 | } 6 | 7 | // When either value are null, then a strict equals comparison will return 8 | // the expected value. 9 | if (a === null || b === null) { 10 | return a === b; 11 | } 12 | 13 | const aKeys = Object.keys(a); 14 | const bKeys = Object.keys(b); 15 | 16 | // If they are both objects, then they must have the same 17 | // number of keys. Otherwise, they can't be shallowly equal! 18 | if (aKeys.length !== bKeys.length) { 19 | return false; 20 | } 21 | 22 | for (var prop in b) { 23 | if (a[prop] !== b[prop]) { 24 | return false; 25 | } 26 | } 27 | 28 | return true; 29 | } 30 | -------------------------------------------------------------------------------- /src/warning.js: -------------------------------------------------------------------------------- 1 | let codeCache = {}; 2 | 3 | export function warning(message, code) { 4 | // This ensures that each warning type is only logged out one time 5 | if (code) { 6 | if (codeCache[code]) { 7 | return; 8 | } 9 | 10 | codeCache[code] = true; 11 | } 12 | 13 | if (console && typeof console.error === 'function') { 14 | console.error(message); 15 | } 16 | 17 | try { 18 | // This error was thrown as a convenience so that if you enable 19 | // "break on all exceptions" in your console, 20 | // it would pause the execution at this line. 21 | throw new Error(message); 22 | } catch (e) { 23 | // Intentionally blank 24 | } 25 | } 26 | 27 | export function resetCodeCache() { 28 | codeCache = {}; 29 | } 30 | -------------------------------------------------------------------------------- /test/create-test-components.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import createStateContext from '../src'; 3 | 4 | const defaultActions = { 5 | setValue() { 6 | return { 7 | number: 10, 8 | }; 9 | }, 10 | 11 | increment: () => setState => { 12 | setState(prevState => { 13 | return { 14 | number: prevState.number + 1, 15 | }; 16 | }); 17 | }, 18 | 19 | updateToTheSame: () => setState => { 20 | setState(prevState => { 21 | return { 22 | number: prevState.number, 23 | }; 24 | }); 25 | }, 26 | 27 | badAction() { 28 | return []; 29 | }, 30 | 31 | badThunkAction: () => setState => { 32 | setState([]); 33 | }, 34 | 35 | returnsUndefined() {}, 36 | }; 37 | 38 | export default function createTestComponents( 39 | actions = defaultActions, 40 | initialNumber = 2 41 | ) { 42 | const initialState = { 43 | number: initialNumber, 44 | }; 45 | 46 | const StateContext = createStateContext(actions, initialState); 47 | 48 | const Usage = () => ( 49 | 50 | {value => { 51 | return ( 52 |
53 | The number is: {value.state.number} 54 | 55 | 56 | 57 | 58 | 59 | 60 |
61 | ); 62 | }} 63 |
64 | ); 65 | 66 | return { StateContext, Usage }; 67 | } 68 | -------------------------------------------------------------------------------- /test/index.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | render, 4 | wait, 5 | fireEvent, 6 | renderIntoDocument, 7 | cleanup, 8 | } from 'react-testing-library'; 9 | import 'jest-dom/extend-expect'; 10 | import createStateContext from '../src'; 11 | import createTestComponents from './create-test-components'; 12 | import { warning } from '../src/warning'; 13 | 14 | afterEach(cleanup); 15 | 16 | describe('React State Context', () => { 17 | it('is a function', () => { 18 | expect(typeof createStateContext === 'function').toBe(true); 19 | }); 20 | 21 | describe('calling it with no arguments', () => { 22 | it('returns an object with the right keys, and does not log an error', () => { 23 | const Context = createStateContext(); 24 | expect(warning).toHaveBeenCalledTimes(0); 25 | expect(typeof Context.Provider).toBe('function'); 26 | expect(typeof Context.Consumer).toBe('object'); 27 | }); 28 | }); 29 | 30 | it('does not log an error when called with null actions and null initial state', () => { 31 | const Context = createStateContext(null, null); 32 | expect(warning).toHaveBeenCalledTimes(0); 33 | expect(typeof Context.Provider).toBe('function'); 34 | expect(typeof Context.Consumer).toBe('object'); 35 | }); 36 | 37 | it('does not log an error when called with empty actions and empty initial state', () => { 38 | const Context = createStateContext({}, {}); 39 | expect(warning).toHaveBeenCalledTimes(0); 40 | expect(typeof Context.Provider).toBe('function'); 41 | expect(typeof Context.Consumer).toBe('object'); 42 | }); 43 | 44 | it('logs an error when an invalid actions value is passed', () => { 45 | const Context = createStateContext(true); 46 | expect(warning).toHaveBeenCalledTimes(1); 47 | expect(warning.mock.calls[0][1]).toEqual('INVALID_ACTIONS_ARGUMENT'); 48 | expect(typeof Context.Provider).toBe('function'); 49 | expect(typeof Context.Consumer).toBe('object'); 50 | }); 51 | 52 | it('logs an error when an actions array is passed', () => { 53 | const Context = createStateContext([]); 54 | expect(warning).toHaveBeenCalledTimes(1); 55 | expect(warning.mock.calls[0][1]).toEqual('INVALID_ACTIONS_ARGUMENT'); 56 | expect(typeof Context.Provider).toBe('function'); 57 | expect(typeof Context.Consumer).toBe('object'); 58 | }); 59 | 60 | it('logs an error when an array initial state is passed', () => { 61 | const Context = createStateContext(null, []); 62 | expect(warning).toHaveBeenCalledTimes(1); 63 | expect(warning.mock.calls[0][1]).toEqual('INVALID_INITIAL_STATE'); 64 | expect(typeof Context.Provider).toBe('function'); 65 | expect(typeof Context.Consumer).toBe('object'); 66 | }); 67 | 68 | it('renders the initial value before actions are called', () => { 69 | const { StateContext, Usage } = createTestComponents(); 70 | 71 | const tree = ( 72 | 73 | 74 | 75 | ); 76 | 77 | const { getByText } = render(tree); 78 | expect(getByText(/^The number is:/)).toHaveTextContent('The number is: 2'); 79 | expect(warning).toHaveBeenCalledTimes(0); 80 | }); 81 | 82 | it('updates after a synchronous action is called', async () => { 83 | const { StateContext, Usage } = createTestComponents(); 84 | 85 | const tree = ( 86 | 87 | 88 | 89 | ); 90 | 91 | const { getByText } = renderIntoDocument(tree); 92 | expect(getByText(/^The number is:/)).toHaveTextContent('The number is: 2'); 93 | 94 | fireEvent( 95 | getByText('Set value'), 96 | new MouseEvent('click', { 97 | bubbles: true, 98 | cancelable: true, 99 | }) 100 | ); 101 | 102 | await wait(); 103 | expect(getByText(/^The number is:/)).toHaveTextContent('The number is: 10'); 104 | expect(warning).toHaveBeenCalledTimes(0); 105 | }); 106 | 107 | it('updates after an action is called', async () => { 108 | const { StateContext, Usage } = createTestComponents(); 109 | 110 | const tree = ( 111 | 112 | 113 | 114 | ); 115 | 116 | const { getByText } = renderIntoDocument(tree); 117 | expect(getByText(/^The number is:/)).toHaveTextContent('The number is: 2'); 118 | 119 | fireEvent( 120 | getByText('Increment value'), 121 | new MouseEvent('click', { 122 | bubbles: true, 123 | cancelable: true, 124 | }) 125 | ); 126 | 127 | await wait(); 128 | expect(getByText(/^The number is:/)).toHaveTextContent('The number is: 3'); 129 | expect(warning).toHaveBeenCalledTimes(0); 130 | }); 131 | 132 | it('logs an error when an action returns an invalid state value', async () => { 133 | const { StateContext, Usage } = createTestComponents(); 134 | 135 | const tree = ( 136 | 137 | 138 | 139 | ); 140 | 141 | const { getByText } = renderIntoDocument(tree); 142 | expect(getByText(/^The number is:/)).toHaveTextContent('The number is: 2'); 143 | 144 | fireEvent( 145 | getByText('Bad action'), 146 | new MouseEvent('click', { 147 | bubbles: true, 148 | cancelable: true, 149 | }) 150 | ); 151 | 152 | await wait(); 153 | 154 | expect(warning).toHaveBeenCalledTimes(1); 155 | expect(warning.mock.calls[0][1]).toEqual('INVALID_ACTION_UPDATE'); 156 | }); 157 | 158 | it('logs an error when a thunk action returns an invalid state value', async () => { 159 | const { StateContext, Usage } = createTestComponents(); 160 | 161 | const tree = ( 162 | 163 | 164 | 165 | ); 166 | 167 | const { getByText } = renderIntoDocument(tree); 168 | expect(getByText(/^The number is:/)).toHaveTextContent('The number is: 2'); 169 | 170 | fireEvent( 171 | getByText('Bad thunk action'), 172 | new MouseEvent('click', { 173 | bubbles: true, 174 | cancelable: true, 175 | }) 176 | ); 177 | 178 | await wait(); 179 | 180 | expect(warning).toHaveBeenCalledTimes(1); 181 | expect(warning.mock.calls[0][1]).toEqual('INVALID_ACTION_UPDATE'); 182 | }); 183 | 184 | it('does not log an error when setState is called with undefined', async () => { 185 | const { StateContext, Usage } = createTestComponents(); 186 | 187 | const tree = ( 188 | 189 | 190 | 191 | ); 192 | 193 | const { getByText } = renderIntoDocument(tree); 194 | expect(getByText(/^The number is:/)).toHaveTextContent('The number is: 2'); 195 | 196 | fireEvent( 197 | getByText('Sets undefined'), 198 | new MouseEvent('click', { 199 | bubbles: true, 200 | cancelable: true, 201 | }) 202 | ); 203 | 204 | await wait(); 205 | 206 | expect(warning).toHaveBeenCalledTimes(0); 207 | }); 208 | 209 | it('does not log an error when setState leaves the state shallowly equal', async () => { 210 | const { StateContext, Usage } = createTestComponents(); 211 | 212 | const tree = ( 213 | 214 | 215 | 216 | ); 217 | 218 | const { getByText } = renderIntoDocument(tree); 219 | expect(getByText(/^The number is:/)).toHaveTextContent('The number is: 2'); 220 | 221 | fireEvent( 222 | getByText('Stays the same'), 223 | new MouseEvent('click', { 224 | bubbles: true, 225 | cancelable: true, 226 | }) 227 | ); 228 | 229 | await wait(); 230 | 231 | expect(warning).toHaveBeenCalledTimes(0); 232 | }); 233 | 234 | it('logs an error when non-function actions are passed', () => { 235 | const { StateContext } = createTestComponents({ 236 | stuff: true, 237 | }); 238 | 239 | render(); 240 | 241 | expect(warning).toHaveBeenCalledTimes(1); 242 | expect(warning.mock.calls[0][1]).toEqual('ACTION_MUST_BE_FN'); 243 | }); 244 | 245 | it('logs an error when an action called "state" is passed', () => { 246 | const { StateContext } = createTestComponents({ 247 | state: () => () => {}, 248 | }); 249 | 250 | render(); 251 | 252 | expect(warning).toHaveBeenCalledTimes(1); 253 | expect(warning.mock.calls[0][1]).toEqual('INVALID_ACTION_KEY'); 254 | }); 255 | }); 256 | -------------------------------------------------------------------------------- /test/setup.js: -------------------------------------------------------------------------------- 1 | import * as warning from '../src/warning'; 2 | 3 | beforeEach(() => { 4 | if (console.error.mockRestore) { 5 | console.error.mockRestore(); 6 | } 7 | 8 | if (warning.warning.mockRestore) { 9 | warning.warning.mockRestore(); 10 | } 11 | 12 | jest.spyOn(console, 'error').mockImplementation(() => {}); 13 | jest.spyOn(warning, 'warning').mockImplementation(() => {}); 14 | 15 | warning.resetCodeCache(); 16 | }); 17 | -------------------------------------------------------------------------------- /test/shallow-equals.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import shallowEquals from '../src/shallow-equals'; 3 | 4 | describe('shallowEquals', () => { 5 | it('is a function', () => { 6 | expect(typeof shallowEquals === 'function').toBe(true); 7 | }); 8 | 9 | it('returns true for null values', () => { 10 | expect(shallowEquals(null, null)).toBe(true); 11 | }); 12 | 13 | it('returns false for null and objects', () => { 14 | expect(shallowEquals(null, {})).toBe(false); 15 | expect(shallowEquals({}, null)).toBe(false); 16 | }); 17 | 18 | it('returns true for objects that are the same', () => { 19 | const obj = { name: 'please' }; 20 | expect(shallowEquals(obj, obj)).toBe(true); 21 | }); 22 | 23 | it('returns false for objects that are different, and not shallowly equal', () => { 24 | expect(shallowEquals({ name: 'j' }, { name: 'p' })).toBe(false); 25 | }); 26 | 27 | it('returns false for objects that are shallow-equal subsets of one another', () => { 28 | expect(shallowEquals({ name: 'j', age: 30 }, { name: 'j' })).toBe(false); 29 | expect(shallowEquals({ name: 'j' }, { name: 'j', age: 30 })).toBe(false); 30 | }); 31 | 32 | it('returns true for objects that are shallowly equal', () => { 33 | expect(shallowEquals({ name: 'j' }, { name: 'j' })).toBe(true); 34 | }); 35 | 36 | it('returns false for objects that are deeply equal', () => { 37 | expect( 38 | shallowEquals({ name: { first: 'j' } }, { name: { first: 'j' } }) 39 | ).toBe(false); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /test/warning.test.js: -------------------------------------------------------------------------------- 1 | import { warning } from '../src/warning'; 2 | 3 | describe('warning', () => { 4 | beforeEach(() => { 5 | if (warning.mockRestore) { 6 | warning.mockRestore(); 7 | } 8 | }); 9 | 10 | it('logs an error when called', () => { 11 | warning('uh oh', 'key'); 12 | 13 | expect(console.error).toHaveBeenCalledTimes(1); 14 | expect(console.error.mock.calls[0][0]).toEqual('uh oh'); 15 | }); 16 | 17 | it('should log one time for duplicate calls', () => { 18 | warning('uh oh', 'key'); 19 | warning('uh oh', 'key'); 20 | 21 | expect(console.error).toHaveBeenCalledTimes(1); 22 | expect(console.error.mock.calls[0][0]).toEqual('uh oh'); 23 | }); 24 | }); 25 | --------------------------------------------------------------------------------