├── test └── index.test.js ├── src ├── createStore.js ├── useImmerRenderer.js ├── index.js ├── useImmer.js ├── applySpec.js ├── Observable.js ├── useImmerHook.js ├── store.js ├── Immer.js └── shallowEqual.js ├── .eslintrc ├── .travis.yml ├── .babelrc ├── LICENSE ├── .gitignore ├── package.json └── README.md /test/index.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | 3 | test('foo', t => { 4 | t.pass() 5 | }) 6 | -------------------------------------------------------------------------------- /src/createStore.js: -------------------------------------------------------------------------------- 1 | import state$ from './store' 2 | 3 | export default function createStore (initialState) { 4 | state$.next(initialState) 5 | } 6 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "extends": ["standard-react", "mono"], 4 | "rules": { 5 | "react/prop-types": "off" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/useImmerRenderer.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Immer from './Immer' 3 | 4 | export default function useImmerRenderer (spec, render) { 5 | return {render} 6 | } 7 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - '10.14.2' 4 | 5 | deploy: 6 | provider: npm 7 | email: achimvionut@gmail.com 8 | api_key: $NPM_TOKEN 9 | skip_cleanup: true 10 | on: 11 | tags: true 12 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | export { default as createStore, } from './createStore' 2 | export { default, default as useImmer, } from './useImmer' 3 | export { default as Immer, } from './Immer' 4 | export { default as store, } from './store' 5 | -------------------------------------------------------------------------------- /src/useImmer.js: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, } from 'react' 2 | import useImmerHook from './useImmerHook' 3 | import useImmerRenderer from './useImmerRenderer' 4 | 5 | const useImmer = useState && useEffect ? useImmerHook : useImmerRenderer 6 | 7 | export default useImmer 8 | -------------------------------------------------------------------------------- /src/applySpec.js: -------------------------------------------------------------------------------- 1 | export default function applySpec (spec) { 2 | return object => { 3 | return Object.entries(spec).reduce( 4 | (acc, [ key, project, ]) => ({ 5 | ...acc, 6 | [key]: project(object), 7 | }), 8 | {} 9 | ) 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/Observable.js: -------------------------------------------------------------------------------- 1 | import $$observable from 'symbol-observable' 2 | 3 | export default class Observable { 4 | constructor (subscriber) { 5 | this._subscriber = subscriber 6 | } 7 | 8 | subscribe (observer) { 9 | return this._subscriber(observer) 10 | } 11 | 12 | [$$observable] () { 13 | return this 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [["@babel/preset-env", { "loose": true }], "@babel/preset-react"], 3 | "plugins": ["@babel/plugin-proposal-class-properties"], 4 | "env": { 5 | "esm": { 6 | "presets": [ 7 | ["@babel/preset-env", { "loose": true, "modules": false }], 8 | "@babel/preset-react" 9 | ] 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/useImmerHook.js: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, } from 'react' 2 | 3 | import shallowEqual from './shallowEqual' 4 | import applySpec from './applySpec' 5 | import state$ from './store' 6 | 7 | const produce = state$.update.bind(state$) 8 | 9 | export default function useImmerHook (spec) { 10 | const [ state, setState, ] = useState(applySpec(spec)(state$.value)) 11 | 12 | useEffect(() => { 13 | const sub = state$.subscribe({ 14 | next: (next, prev) => { 15 | const prevState = applySpec(spec)(prev) 16 | const nextState = applySpec(spec)(next) 17 | 18 | if (!shallowEqual(prevState, nextState)) { 19 | setState(nextState) 20 | } 21 | }, 22 | }) 23 | 24 | return () => sub.unsubscribe() 25 | }, []) 26 | 27 | return [ state, produce, ] 28 | } 29 | -------------------------------------------------------------------------------- /src/store.js: -------------------------------------------------------------------------------- 1 | import Observable from './Observable' 2 | import produce from 'immer' 3 | 4 | export class Subscription { 5 | constructor (unsubscribe) { 6 | this.unsubscribe = unsubscribe 7 | } 8 | } 9 | 10 | export class Store extends Observable { 11 | constructor (value) { 12 | super(function subscribe (observer) { 13 | value != null && observer.next(value) 14 | this.observers.push(observer) 15 | return new Subscription(() => { 16 | const index = this.observers.indexOf(observer) 17 | if (index >= 0) this.observers.splice(index, 1) 18 | }) 19 | }) 20 | 21 | this._value = value 22 | this.observers = [] 23 | } 24 | 25 | next (x) { 26 | this.observers.forEach(observer => { 27 | observer.next(x, this._value) 28 | }) 29 | this._value = x 30 | } 31 | 32 | update (cb) { 33 | this.next(produce(this._value, cb)) 34 | } 35 | 36 | get value () { 37 | return this._value 38 | } 39 | } 40 | 41 | export default new Store() 42 | -------------------------------------------------------------------------------- /src/Immer.js: -------------------------------------------------------------------------------- 1 | import { Component, } from 'react' 2 | 3 | import state$ from './store' 4 | import shallowEqual from './shallowEqual' 5 | import applySpec from './applySpec' 6 | 7 | export default class Immer extends Component { 8 | constructor (props) { 9 | super(props) 10 | 11 | this._state = applySpec(props.spec)(state$.value) 12 | } 13 | 14 | componentDidMount () { 15 | this.sub = state$.subscribe({ 16 | next: v => { 17 | const nextState = applySpec(this.props.spec)(v) 18 | if (!shallowEqual(this._state, nextState)) { 19 | this._state = nextState 20 | this.forceUpdate() 21 | } 22 | }, 23 | }) 24 | } 25 | 26 | componentDidUpdate (prevProps) { 27 | if (!shallowEqual(prevProps.spec, this.props.spec)) { 28 | this._state = applySpec(this.props.spec)(state$.value) 29 | this.forceUpdate() 30 | } 31 | } 32 | 33 | componentWillUnmount () { 34 | this.sub && this.sub.unsubscribe() 35 | } 36 | 37 | render () { 38 | return this.props.children(this._state, state$.update.bind(state$)) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Ionut Achim 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 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | 3 | # Logs 4 | logs 5 | *.log 6 | npm-debug.log* 7 | yarn-debug.log* 8 | yarn-error.log* 9 | 10 | # Runtime data 11 | pids 12 | *.pid 13 | *.seed 14 | *.pid.lock 15 | 16 | # Directory for instrumented libs generated by jscoverage/JSCover 17 | lib-cov 18 | 19 | # Coverage directory used by tools like istanbul 20 | coverage 21 | 22 | # nyc test coverage 23 | .nyc_output 24 | 25 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 26 | .grunt 27 | 28 | # Bower dependency directory (https://bower.io/) 29 | bower_components 30 | 31 | # node-waf configuration 32 | .lock-wscript 33 | 34 | # Compiled binary addons (https://nodejs.org/api/addons.html) 35 | build/Release 36 | 37 | # Dependency directories 38 | node_modules/ 39 | jspm_packages/ 40 | 41 | # TypeScript v1 declaration files 42 | typings/ 43 | 44 | # Optional npm cache directory 45 | .npm 46 | 47 | # Optional eslint cache 48 | .eslintcache 49 | 50 | # Optional REPL history 51 | .node_repl_history 52 | 53 | # Output of 'npm pack' 54 | *.tgz 55 | 56 | # Yarn Integrity file 57 | .yarn-integrity 58 | 59 | # dotenv environment variables file 60 | .env 61 | 62 | # next.js build output 63 | .next 64 | 65 | # build 66 | esm 67 | lib 68 | -------------------------------------------------------------------------------- /src/shallowEqual.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2013-present, Facebook, Inc. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | * 7 | * @providesModule shallowEqual 8 | * @typechecks 9 | */ 10 | 11 | /* eslint-disable no-self-compare */ 12 | 13 | const hasOwnProperty = Object.prototype.hasOwnProperty 14 | 15 | function is (x, y) { 16 | // SameValue algorithm 17 | if (x === y) { 18 | // Steps 1-5, 7-10 19 | // Steps 6.b-6.e: +0 != -0 20 | // Added the nonzero y check to make Flow happy, but it is redundant 21 | return x !== 0 || y !== 0 || 1 / x === 1 / y 22 | } 23 | // Step 6.a: NaN == NaN 24 | return x !== x && y !== y 25 | } 26 | 27 | export default function shallowEqual (objA, objB) { 28 | if (is(objA, objB)) { 29 | return true 30 | } 31 | 32 | if ( 33 | typeof objA !== 'object' || 34 | objA === null || 35 | typeof objB !== 'object' || 36 | objB === null 37 | ) { 38 | return false 39 | } 40 | 41 | const keysA = Object.keys(objA) 42 | const keysB = Object.keys(objB) 43 | 44 | if (keysA.length !== keysB.length) { 45 | return false 46 | } 47 | 48 | // Test for A's keys different from B. 49 | for (let i = 0; i < keysA.length; i++) { 50 | if ( 51 | !hasOwnProperty.call(objB, keysA[i]) || 52 | !is(objA[keysA[i]], objB[keysA[i]]) 53 | ) { 54 | return false 55 | } 56 | } 57 | 58 | return true 59 | } 60 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-immer", 3 | "version": "1.5.3", 4 | "description": "No nonsense state management with Immer and React hooks", 5 | "repository": { 6 | "type": "git", 7 | "url": "git+https://github.com/monojack/react-immer.git" 8 | }, 9 | "keywords": [ 10 | "javascript", 11 | "react", 12 | "hooks", 13 | "immer", 14 | "state" 15 | ], 16 | "main": "lib/index.js", 17 | "module": "esm/index.js", 18 | "scripts": { 19 | "dev": "npm run build:esm -- -w", 20 | "pretest": "npm run build:cjs", 21 | "test": "ava", 22 | "build:esm": "BABEL_ENV=esm babel src --out-dir esm", 23 | "build:cjs": "BABEL_ENV=cjs babel src --out-dir lib", 24 | "build": "npm run build:cjs && npm run build:esm", 25 | "prepare": "npm run clean && npm run build", 26 | "clean": "rimraf lib esm" 27 | }, 28 | "ava": { 29 | "files": [ 30 | "test/*.js", 31 | "!test/mocks/**" 32 | ] 33 | }, 34 | "bugs": { 35 | "url": "https://github.com/monojack/react-immer/issues" 36 | }, 37 | "npmName": "react-immer", 38 | "files": [ 39 | "esm", 40 | "lib" 41 | ], 42 | "author": "Ionut Achim ", 43 | "license": "MIT", 44 | "sideEffects": false, 45 | "dependencies": { 46 | "symbol-observable": "^1.2.0" 47 | }, 48 | "devDependencies": { 49 | "@babel/cli": "^7.5.0", 50 | "@babel/core": "^7.5.4", 51 | "@babel/plugin-proposal-class-properties": "^7.5.0", 52 | "@babel/preset-env": "^7.5.4", 53 | "@babel/preset-react": "^7.0.0", 54 | "ava": "^2.2.0", 55 | "babel-eslint": "^10.0.2", 56 | "eslint": "^5.16.0", 57 | "eslint-config-mono": "^2.0.0", 58 | "eslint-config-standard-react": "^7.0.2", 59 | "eslint-plugin-import": "^2.18.0", 60 | "eslint-plugin-node": "^8.0.1", 61 | "eslint-plugin-promise": "^4.2.1", 62 | "eslint-plugin-react": "^7.14.2", 63 | "immer": "^3.1.3", 64 | "react": "^16.8.6", 65 | "rimraf": "^2.6.3" 66 | }, 67 | "peerDependencies": { 68 | "react": "^16.7.* || ^16.8.*", 69 | "immer": "^1.8.* || ^2.* || ^3.*" 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## react-immer 2 | 3 | [![Build Status](https://travis-ci.org/monojack/react-immer.svg?branch=master)](https://travis-ci.org/monojack/react-immer) 4 | [![npm version](https://img.shields.io/npm/v/react-immer.svg)](https://www.npmjs.com/package/react-immer) 5 | [![npm downloads](https://img.shields.io/npm/dm/react-immer.svg)](https://www.npmjs.com/package/react-immer) 6 | [![minified size](https://badgen.net/bundlephobia/min/react-immer)](https://bundlephobia.com/result?p=react-immer@latest) 7 | 8 | No nonsense state management with [Immer](https://github.com/mweststrate/immer) and [React hooks](https://reactjs.org/docs/hooks-intro.html) 9 | 10 | **TL;DR** 11 | 12 | `index.js` 13 | 14 | ```js 15 | import React from 'react' 16 | import ReactDOM from 'react' 17 | 18 | import { createStore } from 'react-immer' 19 | import Counter from './Counter' 20 | 21 | createStore({ count: 1 }) 22 | 23 | function App() { 24 | return 25 | } 26 | 27 | const rootElement = document.getElementById('root') 28 | ReactDOM.render(, rootElement) 29 | ``` 30 | 31 |   32 | 33 | `Counter.js` 34 | 35 | ```js 36 | /* Counter.js */ 37 | 38 | import React from 'react' 39 | import { useImmer } from 'react-immer' 40 | 41 | export default function Counter() { 42 | const [{ count }, produce] = useImmer({ count: state => state.count }) 43 | 44 | const decrement = draft => { 45 | draft.count -= 1 46 | } 47 | 48 | const increment = draft => { 49 | draft.count += 1 50 | } 51 | 52 | return ( 53 |
54 | 55 | {count} 56 | 57 |
58 | ) 59 | } 60 | ``` 61 | 62 | [![Edit react-immer-tldr](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/s/yq328o9rvx) 63 | 64 | What's cool about **react-immer** is that if you don't support [Hooks](https://reactjs.org/docs/hooks-intro.html) yet, you can use it inline and it will work like a [render prop](https://reactjs.org/docs/render-props.html). In this case, it takes two arguments, the _spec object_ and the _render function_. 65 | 66 | ```js 67 | import { useImmer } from 'react-immer' 68 | 69 | // ... 70 | // useImmer(specObj, renderFn) 71 |
72 | {useImmer({ count: state => state.count }, ({ count }, produce) => ( 73 | 74 | 75 | {count} 76 | 77 | 78 | ))} 79 |
80 | ``` 81 | 82 | Or, if you don't like the syntax, you can always use the **Immer** component 83 | 84 | ```js 85 | import { Immer } from 'react-immer' 86 | // ... 87 | 88 | state.count }}> 89 | {({ count }, produce) => ( 90 | 91 | 92 | {count} 93 | 94 | 95 | )} 96 | 97 | ``` 98 | --------------------------------------------------------------------------------