├── .gitignore ├── logo ├── logo.png ├── logo-black.png ├── logo-white.png ├── logo-black.svg ├── logo-white.svg └── logo.svg ├── src ├── Consumer.js ├── Context.js ├── index.js ├── utils.js ├── mount.js ├── Container.js ├── ContextContainer.js └── Provider.js ├── test ├── testSetup.js ├── testUtils.js ├── mount.test.js ├── utils.test.js ├── Container.test.js ├── ReactSixteenAdapter.js ├── createCommonTests.js └── Provider.test.js ├── .travis.yml ├── examples ├── index.html ├── index.js └── counter.js ├── .babelrc ├── .editorconfig ├── .eslintrc ├── CHANGELOG.md ├── LICENSE ├── rollup.config.js ├── package.json └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | coverage 4 | dist 5 | *.log 6 | .cache -------------------------------------------------------------------------------- /logo/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stereobooster/constate/master/logo/logo.png -------------------------------------------------------------------------------- /logo/logo-black.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stereobooster/constate/master/logo/logo-black.png -------------------------------------------------------------------------------- /logo/logo-white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stereobooster/constate/master/logo/logo-white.png -------------------------------------------------------------------------------- /src/Consumer.js: -------------------------------------------------------------------------------- 1 | import Context from "./Context"; 2 | 3 | export default Context.Consumer; 4 | -------------------------------------------------------------------------------- /src/Context.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | const Context = React.createContext(); 4 | 5 | export default Context; 6 | -------------------------------------------------------------------------------- /test/testSetup.js: -------------------------------------------------------------------------------- 1 | import "raf/polyfill"; 2 | import { configure } from "enzyme"; 3 | import Adapter from "./ReactSixteenAdapter"; 4 | 5 | configure({ adapter: new Adapter() }); 6 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | export Consumer from "./Consumer"; 2 | export Container from "./Container"; 3 | export ContextContainer from "./ContextContainer"; 4 | export mount from "./mount"; 5 | export Provider from "./Provider"; 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - v7 4 | script: 5 | - npm run lint && npm test -- --coverage && npm run build 6 | cache: 7 | - yarn 8 | after_success: 9 | - bash <(curl -s https://codecov.io/bash) 10 | -------------------------------------------------------------------------------- /examples/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Constate examples 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "env", 5 | { 6 | "modules": false, 7 | "loose": true 8 | } 9 | ], 10 | "stage-1", 11 | "react" 12 | ], 13 | "plugins": [ 14 | "transform-class-properties" 15 | ], 16 | "env": { 17 | "test": { 18 | "plugins": [ 19 | "transform-es2015-modules-commonjs" 20 | ] 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig helps developers define and maintain consistent 2 | # coding styles between different editors and IDEs 3 | # editorconfig.org 4 | 5 | root = true 6 | 7 | [*] 8 | 9 | # Change these settings to your own preference 10 | indent_style = space 11 | indent_size = 2 12 | 13 | # We recommend you to keep these unchanged 14 | end_of_line = lf 15 | charset = utf-8 16 | trim_trailing_whitespace = true 17 | insert_final_newline = true 18 | 19 | [*.md] 20 | trim_trailing_whitespace = false 21 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "extends": [ 4 | "airbnb", 5 | "plugin:prettier/recommended", 6 | "prettier/react" 7 | ], 8 | "env": { 9 | "jest": true, 10 | "browser": true 11 | }, 12 | "rules": { 13 | "react/prop-types": "off", 14 | "import/no-extraneous-dependencies": "off", 15 | "react/forbid-prop-types": "off", 16 | "react/jsx-filename-extension": "off", 17 | "react/require-default-props": "off", 18 | "jsx-a11y/anchor-is-valid": "off" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2 | ## [0.7.2](https://github.com/diegohaz/constate/compare/v0.7.1...v0.7.2) (2018-07-20) 3 | 4 | 5 | ### Bug Fixes 6 | 7 | * onUpdate should be called if shouldUpdate returns true ([446c871](https://github.com/diegohaz/constate/commit/446c871)) 8 | 9 | 10 | 11 | 12 | ## [0.7.1](https://github.com/diegohaz/constate/compare/v0.7.0...v0.7.1) (2018-07-10) 13 | 14 | 15 | ### Features 16 | 17 | * Add shouldUpdate ([#32](https://github.com/diegohaz/constate/issues/32)) ([88d49f8](https://github.com/diegohaz/constate/commit/88d49f8)) 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /test/testUtils.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { mount } from "enzyme"; 3 | import { Container, Provider } from "../src"; 4 | 5 | export const increment = (amount = 1) => state => ({ 6 | count: state.count + amount 7 | }); 8 | 9 | export const getParity = () => state => 10 | state.count % 2 === 0 ? "even" : "odd"; 11 | 12 | export const wrap = (props, providerProps) => 13 | mount( 14 | 15 | {state =>
} 16 | 17 | ); 18 | 19 | export const getState = (wrapper, selector = "div") => 20 | wrapper 21 | .update() 22 | .find(selector) 23 | .prop("state"); 24 | -------------------------------------------------------------------------------- /examples/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { render } from "react-dom"; 3 | import { Block, Link, List, Divider } from "reas"; 4 | import { 5 | BrowserRouter as Router, 6 | Route, 7 | Link as RouterLink 8 | } from "react-router-dom"; 9 | import Counter from "./counter"; 10 | 11 | const App = () => ( 12 | 13 | 14 | 15 | 16 | 17 | Counter 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | ); 26 | 27 | render(, document.getElementById("root")); 28 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | const mapWith = (map, transform) => 2 | Object.keys(map).reduce( 3 | (final, key) => ({ 4 | ...final, 5 | [key]: transform(map[key], key) 6 | }), 7 | {} 8 | ); 9 | 10 | export const mapSetStateToActions = (setState, actionsMap) => 11 | mapWith(actionsMap, (action, key) => (...args) => 12 | setState(action(...args), undefined, key) 13 | ); 14 | 15 | export const mapArgumentToFunctions = (argument, fnMap) => 16 | mapWith(fnMap, (fn, key) => (...args) => { 17 | if (typeof argument === "function") { 18 | return fn(...args)(argument(fn, key)); 19 | } 20 | return fn(...args)(argument); 21 | }); 22 | 23 | export const parseUpdater = (updater, state) => 24 | typeof updater === "function" ? updater(state) : updater; 25 | -------------------------------------------------------------------------------- /examples/counter.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Block, Button } from "reas"; 3 | import { Provider, Container } from "../src"; 4 | 5 | const CounterContainer = props => ( 6 | state => ({ count: state.count + amount }) 10 | }} 11 | {...props} 12 | /> 13 | ); 14 | 15 | const CounterValue = () => ( 16 | 17 | {({ count }) => {count}} 18 | 19 | ); 20 | 21 | const CounterButton = () => ( 22 | 23 | {({ increment }) => } 24 | 25 | ); 26 | 27 | const Counter = () => ( 28 | 29 | 30 | 31 | 32 | ); 33 | 34 | export default Counter; 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Diego Haz 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 | -------------------------------------------------------------------------------- /test/mount.test.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { mount, Container } from "../src"; 3 | import { increment } from "./testUtils"; 4 | import createCommonTests from "./createCommonTests"; 5 | 6 | const wrap = props => mount(p => ); 7 | 8 | const getState = wrapper => wrapper; 9 | 10 | test("nested container", () => { 11 | const CounterContainer = props => ( 12 | 13 | ); 14 | const IncrementableCounterContainer = props => ( 15 | 16 | ); 17 | const DecrementableIncrementableCounterContainer = () => ( 18 | state => ({ count: state.count - 1 }) }} 21 | /> 22 | ); 23 | const wrapper = mount(DecrementableIncrementableCounterContainer); 24 | expect(wrapper).toEqual({ 25 | count: 10, 26 | foo: "bar", 27 | decrement: expect.any(Function) 28 | }); 29 | }); 30 | 31 | test("React element", () => { 32 | const wrapper = mount( 33 | {() => null} 34 | ); 35 | expect(wrapper).toEqual({ count: 0 }); 36 | }); 37 | 38 | createCommonTests({}, getState, wrap)(); 39 | -------------------------------------------------------------------------------- /test/utils.test.js: -------------------------------------------------------------------------------- 1 | import { 2 | mapSetStateToActions, 3 | mapArgumentToFunctions, 4 | parseUpdater 5 | } from "../src/utils"; 6 | 7 | test("mapSetStateToActions", () => { 8 | const setState = jest.fn(fn => fn(2)); 9 | const actionsMap = { 10 | foo: n => state => ({ n: state + n }) 11 | }; 12 | const result = mapSetStateToActions(setState, actionsMap); 13 | expect(result.foo(2)).toEqual({ n: 4 }); 14 | expect(setState).toHaveBeenCalledWith(expect.any(Function), undefined, "foo"); 15 | }); 16 | 17 | test("mapArgumentToFunctions", () => { 18 | const state = { foo: 1 }; 19 | const selectorsMap = { 20 | foo: n => s => s.foo + n 21 | }; 22 | const result = mapArgumentToFunctions(state, selectorsMap); 23 | expect(result.foo(1)).toBe(2); 24 | }); 25 | 26 | test("mapArgumentToFunctions with argument as function", () => { 27 | const argument = jest.fn(); 28 | const fn = () => () => {}; 29 | const fnMap = { fn }; 30 | const result = mapArgumentToFunctions(argument, fnMap); 31 | result.fn(); 32 | expect(argument).toHaveBeenCalledWith(fn, "fn"); 33 | }); 34 | 35 | test("parseUpdater", () => { 36 | expect(parseUpdater({ foo: "bar" })).toEqual({ foo: "bar" }); 37 | expect( 38 | parseUpdater(state => ({ count: state.count + 1 }), { count: 10 }) 39 | ).toEqual({ count: 11 }); 40 | }); 41 | -------------------------------------------------------------------------------- /test/Container.test.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { mount } from "enzyme"; 3 | import createCommonTests from "./createCommonTests"; 4 | import { increment, wrap, getState } from "./testUtils"; 5 | import { Container } from "../src"; 6 | 7 | createCommonTests({}, getState, wrap)(); 8 | 9 | test("onUnmount", () => { 10 | const initialState = { count: 0 }; 11 | const onUnmount = jest.fn(({ state, setState }) => { 12 | expect(state).toEqual(initialState); 13 | expect(setState).toEqual(expect.any(Function)); 14 | }); 15 | const Component = ({ hide }) => 16 | hide ? null : ( 17 | 18 | {state =>
} 19 | 20 | ); 21 | 22 | const wrapper = mount(); 23 | expect(onUnmount).not.toHaveBeenCalled(); 24 | wrapper.setProps({ hide: true }); 25 | expect(onUnmount).toHaveBeenCalledTimes(1); 26 | }); 27 | 28 | test("onUnmount setState is noop", () => { 29 | const initialState = { count: 0 }; 30 | const onUnmount = jest.fn(({ setState }) => { 31 | setState(increment(10)); 32 | }); 33 | const Component = ({ hide }) => 34 | hide ? null : ( 35 | 36 | {state =>
} 37 | 38 | ); 39 | 40 | const wrapper = mount(); 41 | wrapper.setProps({ hide: true }); 42 | expect(onUnmount).toHaveBeenCalledTimes(1); 43 | }); 44 | -------------------------------------------------------------------------------- /logo/logo-black.svg: -------------------------------------------------------------------------------- 1 | Artboard 1 -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import babel from "rollup-plugin-babel"; 2 | import resolve from "rollup-plugin-node-resolve"; 3 | import replace from "rollup-plugin-replace"; 4 | import commonjs from "rollup-plugin-commonjs"; 5 | import filesize from "rollup-plugin-filesize"; 6 | import { uglify } from "rollup-plugin-uglify"; 7 | import ignore from "rollup-plugin-ignore"; 8 | import pkg from "./package.json"; 9 | 10 | const { name } = pkg; 11 | const external = Object.keys(pkg.peerDependencies || {}); 12 | const allExternal = external.concat(Object.keys(pkg.dependencies || {})); 13 | 14 | const makeExternalPredicate = externalArr => { 15 | if (externalArr.length === 0) { 16 | return () => false; 17 | } 18 | const pattern = new RegExp(`^(${externalArr.join("|")})($|/)`); 19 | return id => pattern.test(id); 20 | }; 21 | 22 | const common = { 23 | input: "src/index.js" 24 | }; 25 | 26 | const createCommonPlugins = () => [ 27 | babel({ 28 | exclude: "node_modules/**", 29 | plugins: ["external-helpers"] 30 | }), 31 | commonjs({ 32 | include: /node_modules/, 33 | ignoreGlobal: true 34 | }), 35 | filesize() 36 | ]; 37 | 38 | const main = Object.assign({}, common, { 39 | output: { 40 | name, 41 | file: pkg.main, 42 | format: "cjs", 43 | exports: "named" 44 | }, 45 | external: makeExternalPredicate(allExternal), 46 | plugins: createCommonPlugins().concat([resolve()]) 47 | }); 48 | 49 | const module = Object.assign({}, common, { 50 | output: { 51 | file: pkg.module, 52 | format: "es" 53 | }, 54 | external: makeExternalPredicate(allExternal), 55 | plugins: createCommonPlugins().concat([resolve()]) 56 | }); 57 | 58 | const unpkg = Object.assign({}, common, { 59 | output: { 60 | name, 61 | file: pkg.unpkg, 62 | format: "umd", 63 | exports: "named", 64 | globals: { 65 | react: "React", 66 | "react-dom": "ReactDOM" 67 | } 68 | }, 69 | external: makeExternalPredicate(external), 70 | plugins: createCommonPlugins().concat([ 71 | ignore(["stream"]), 72 | uglify(), 73 | replace({ 74 | "process.env.NODE_ENV": JSON.stringify("production") 75 | }), 76 | resolve({ 77 | preferBuiltins: false 78 | }) 79 | ]) 80 | }); 81 | 82 | export default [main, module, unpkg]; 83 | -------------------------------------------------------------------------------- /src/mount.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-param-reassign, no-use-before-define, no-unused-expressions */ 2 | import React from "react"; 3 | import { 4 | mapSetStateToActions, 5 | mapArgumentToFunctions, 6 | parseUpdater 7 | } from "./utils"; 8 | 9 | const getProps = (Container, props) => { 10 | const getNextProps = rendered => { 11 | const nextProps = { ...rendered.props, ...props }; 12 | return getProps(rendered.type, nextProps); 13 | }; 14 | 15 | if (React.isValidElement(Container)) { 16 | return getNextProps(Container); 17 | } 18 | 19 | const container = new Container({ children: () => null }); 20 | const rendered = container.render ? container.render() : container; 21 | 22 | if (!React.isValidElement(rendered)) { 23 | return props; 24 | } 25 | 26 | return getNextProps(rendered); 27 | }; 28 | 29 | const mapToDraft = (object, draft) => 30 | Object.keys(object).forEach(key => { 31 | draft[key] = object[key]; 32 | }); 33 | 34 | const mount = Container => { 35 | const { 36 | initialState, 37 | actions, 38 | selectors, 39 | effects, 40 | onMount, 41 | onUpdate, 42 | shouldUpdate 43 | } = getProps(Container); 44 | 45 | const draft = { ...initialState }; 46 | const state = { ...initialState }; 47 | 48 | const setState = (updater, callback, type) => { 49 | const prevState = { ...state }; 50 | 51 | mapToDraft(parseUpdater(updater, draft), draft); 52 | 53 | const couldUpdate = shouldUpdate 54 | ? shouldUpdate({ state, nextState: draft }) 55 | : true; 56 | 57 | if (couldUpdate) { 58 | mapToDraft(draft, state); 59 | } 60 | 61 | if (onUpdate && couldUpdate) { 62 | onUpdate(getArgs({ prevState, type }, "onUpdate")); 63 | } 64 | 65 | if (callback) callback(); 66 | }; 67 | 68 | const getArgs = (additionalArgs, type) => ({ 69 | state, 70 | setState: (u, c) => setState(u, c, type), 71 | ...additionalArgs 72 | }); 73 | 74 | typeof onMount === "function" && onMount(getArgs({}, "onMount")); 75 | 76 | actions && mapToDraft(mapSetStateToActions(setState, actions), state); 77 | selectors && mapToDraft(mapArgumentToFunctions(state, selectors), state); 78 | effects && mapToDraft(mapArgumentToFunctions(getArgs, effects), state); 79 | 80 | return state; 81 | }; 82 | 83 | export default mount; 84 | -------------------------------------------------------------------------------- /logo/logo-white.svg: -------------------------------------------------------------------------------- 1 | Artboard 1 -------------------------------------------------------------------------------- /logo/logo.svg: -------------------------------------------------------------------------------- 1 | Artboard 1 -------------------------------------------------------------------------------- /src/Container.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Consumer from "./Consumer"; 3 | import ContextContainer from "./ContextContainer"; 4 | import { 5 | mapSetStateToActions, 6 | mapArgumentToFunctions, 7 | parseUpdater 8 | } from "./utils"; 9 | 10 | class Container extends React.Component { 11 | static defaultProps = { 12 | initialState: {} 13 | }; 14 | 15 | state = this.props.initialState; 16 | 17 | componentDidMount() { 18 | const { context, onMount } = this.props; 19 | if (!context && onMount) { 20 | onMount(this.getArgs({}, "onMount")); 21 | } 22 | } 23 | 24 | shouldComponentUpdate(nextProps, nextState) { 25 | const { context, shouldUpdate } = this.props; 26 | if (!context && shouldUpdate) { 27 | const couldUpdate = shouldUpdate({ state: this.state, nextState }); 28 | this.ignoreState = !couldUpdate && nextState; 29 | return couldUpdate; 30 | } 31 | return true; 32 | } 33 | 34 | componentWillUnmount() { 35 | const { context, onUnmount } = this.props; 36 | if (!context && onUnmount) { 37 | onUnmount(this.getArgs({ setState: () => {} })); 38 | } 39 | } 40 | 41 | getArgs = (additionalArgs, type) => ({ 42 | state: this.state, 43 | setState: (u, c) => this.handleSetState(u, c, type), 44 | ...additionalArgs 45 | }); 46 | 47 | ignoreState = null; 48 | 49 | handleSetState = (updater, callback, type) => { 50 | let prevState; 51 | 52 | this.setState( 53 | state => { 54 | prevState = state; 55 | return parseUpdater(updater, state); 56 | }, 57 | () => { 58 | if (this.props.onUpdate && this.ignoreState !== this.state) { 59 | this.props.onUpdate(this.getArgs({ prevState, type }, "onUpdate")); 60 | } 61 | if (callback) callback(); 62 | } 63 | ); 64 | }; 65 | 66 | render() { 67 | if (this.props.context) { 68 | return ( 69 | 70 | {props => } 71 | 72 | ); 73 | } 74 | 75 | const { children, actions, selectors, effects } = this.props; 76 | 77 | return children({ 78 | ...this.state, 79 | ...(actions && mapSetStateToActions(this.handleSetState, actions)), 80 | ...(selectors && mapArgumentToFunctions(this.state, selectors)), 81 | ...(effects && mapArgumentToFunctions(this.getArgs, effects)) 82 | }); 83 | } 84 | } 85 | 86 | export default Container; 87 | -------------------------------------------------------------------------------- /src/ContextContainer.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { 3 | mapSetStateToActions, 4 | mapArgumentToFunctions, 5 | parseUpdater 6 | } from "./utils"; 7 | 8 | class ContextContainer extends React.Component { 9 | constructor(props) { 10 | super(props); 11 | const { state, setContextState, context, initialState } = props; 12 | if (!state[context]) { 13 | setContextState( 14 | context, 15 | currentState => ({ ...initialState, ...currentState }), 16 | undefined, 17 | "initialState" 18 | ); 19 | } 20 | } 21 | 22 | componentDidMount() { 23 | const { mountContainer, context, onMount } = this.props; 24 | this.unmount = mountContainer( 25 | context, 26 | onMount && (() => onMount(this.getArgs({}, "onMount"))) 27 | ); 28 | } 29 | 30 | shouldComponentUpdate(nextProps) { 31 | const { state } = this.props; 32 | const { context, shouldUpdate, state: nextState } = nextProps; 33 | if (shouldUpdate) { 34 | const couldUpdate = shouldUpdate({ 35 | state: state[context] || {}, 36 | nextState: nextState[context] 37 | }); 38 | this.ignoreState = !couldUpdate && nextState[context]; 39 | return couldUpdate; 40 | } 41 | return true; 42 | } 43 | 44 | componentWillUnmount() { 45 | const { onUnmount } = this.props; 46 | this.unmount(onUnmount && (() => onUnmount(this.getArgs({}, "onUnmount")))); 47 | } 48 | 49 | getArgs = (additionalArgs, type) => { 50 | const { state, context } = this.props; 51 | return { 52 | state: state[context], 53 | setState: (u, c) => this.handleSetState(u, c, type), 54 | ...additionalArgs 55 | }; 56 | }; 57 | 58 | ignoreState = null; 59 | 60 | handleSetState = (updater, callback, type) => { 61 | const { setContextState, context, onUpdate } = this.props; 62 | const setState = (...args) => setContextState(context, ...args); 63 | let prevState; 64 | 65 | setState( 66 | state => { 67 | prevState = state; 68 | return parseUpdater(updater, state); 69 | }, 70 | () => { 71 | if (onUpdate && this.ignoreState !== this.props.state[context]) { 72 | onUpdate(this.getArgs({ prevState, type }, "onUpdate")); 73 | } 74 | if (callback) callback(); 75 | }, 76 | type 77 | ); 78 | }; 79 | 80 | render() { 81 | const { children, actions, selectors, effects } = this.props; 82 | const { state } = this.getArgs(); 83 | return children({ 84 | ...state, 85 | ...(actions && mapSetStateToActions(this.handleSetState, actions)), 86 | ...(selectors && mapArgumentToFunctions(state, selectors)), 87 | ...(effects && mapArgumentToFunctions(this.getArgs, effects)) 88 | }); 89 | } 90 | } 91 | 92 | export default ContextContainer; 93 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "constate", 3 | "version": "0.7.2", 4 | "description": "Yet another React state management library that lets you work with local state and scale up to global state with ease", 5 | "license": "MIT", 6 | "repository": "diegohaz/constate", 7 | "main": "dist/cjs/constate.js", 8 | "module": "dist/es/constate.js", 9 | "jsnext:main": "dist/es/constate.js", 10 | "unpkg": "dist/umd/constate.min.js", 11 | "author": { 12 | "name": "Diego Haz", 13 | "email": "hazdiego@gmail.com", 14 | "url": "https://github.com/diegohaz" 15 | }, 16 | "engines": { 17 | "node": ">=6" 18 | }, 19 | "files": [ 20 | "dist" 21 | ], 22 | "scripts": { 23 | "start": "parcel examples/index.html", 24 | "test": "jest", 25 | "coverage": "npm test -- --coverage", 26 | "postcoverage": "opn coverage/lcov-report/index.html", 27 | "lint": "eslint src test examples", 28 | "clean": "rimraf dist", 29 | "prebuild": "npm run clean", 30 | "build": "rollup -c", 31 | "preversion": "npm run lint && npm test && npm run build", 32 | "version": "standard-changelog && git add CHANGELOG.md", 33 | "postpublish": "git push origin master --follow-tags" 34 | }, 35 | "jest": { 36 | "setupFiles": [ 37 | "/test/testSetup.js" 38 | ], 39 | "snapshotSerializers": [ 40 | "enzyme-to-json/serializer", 41 | "jest-serializer-html" 42 | ], 43 | "coveragePathIgnorePatterns": [ 44 | "/node_modules/", 45 | "/test/" 46 | ] 47 | }, 48 | "keywords": [ 49 | "constate" 50 | ], 51 | "dependencies": {}, 52 | "devDependencies": { 53 | "babel-cli": "^6.26.0", 54 | "babel-core": "^6.26.0", 55 | "babel-eslint": "^8.2.2", 56 | "babel-jest": "^22.4.3", 57 | "babel-plugin-external-helpers": "^6.22.0", 58 | "babel-plugin-transform-es2015-modules-commonjs": "^6.26.0", 59 | "babel-preset-env": "^1.6.1", 60 | "babel-preset-react": "^6.24.1", 61 | "babel-preset-stage-1": "^6.24.1", 62 | "enzyme": "^3.3.0", 63 | "enzyme-adapter-react-16": "^1.1.1", 64 | "enzyme-to-json": "^3.3.3", 65 | "eslint": "^4.19.1", 66 | "eslint-config-airbnb": "^16.1.0", 67 | "eslint-config-prettier": "^2.9.0", 68 | "eslint-plugin-import": "^2.10.0", 69 | "eslint-plugin-jsx-a11y": "^6.0.3", 70 | "eslint-plugin-prettier": "^2.6.0", 71 | "eslint-plugin-react": "^7.7.0", 72 | "jest-cli": "^22.4.3", 73 | "jest-serializer-html": "^5.0.0", 74 | "opn-cli": "^3.1.0", 75 | "parcel-bundler": "^1.8.1", 76 | "prettier": "^1.11.1", 77 | "raf": "^3.4.0", 78 | "react": "^16.3.0", 79 | "react-dom": "^16.3.0", 80 | "react-router-dom": "^4.2.2", 81 | "react-test-renderer": "^16.3.0", 82 | "reas": "^0.10.1", 83 | "rimraf": "^2.6.2", 84 | "rollup": "^0.59.2", 85 | "rollup-plugin-babel": "^3.0.3", 86 | "rollup-plugin-commonjs": "^9.1.0", 87 | "rollup-plugin-filesize": "^1.5.0", 88 | "rollup-plugin-ignore": "^1.0.3", 89 | "rollup-plugin-node-resolve": "^3.3.0", 90 | "rollup-plugin-replace": "^2.0.0", 91 | "rollup-plugin-uglify": "^4.0.0", 92 | "standard-changelog": "^2.0.0" 93 | }, 94 | "peerDependencies": { 95 | "react": "^16.3.0" 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/Provider.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/sort-comp, react/no-unused-state, no-underscore-dangle */ 2 | import React from "react"; 3 | import Context from "./Context"; 4 | import { parseUpdater } from "./utils"; 5 | 6 | const reduxDevtoolsExtension = 7 | typeof window !== "undefined" && window.__REDUX_DEVTOOLS_EXTENSION__; 8 | 9 | class Provider extends React.Component { 10 | static defaultProps = { 11 | initialState: {} 12 | }; 13 | 14 | constructor(props) { 15 | super(props); 16 | this.containers = {}; 17 | const { devtools, initialState } = props; 18 | // istanbul ignore next 19 | if (devtools && reduxDevtoolsExtension) { 20 | this.devtools = reduxDevtoolsExtension.connect({ name: "Context" }); 21 | this.devtools.init(initialState); 22 | this.devtools.subscribe(message => { 23 | if (message.type === "DISPATCH" && message.state) { 24 | this.setState(state => ({ 25 | state: { ...state.state, ...JSON.parse(message.state) } 26 | })); 27 | } 28 | }); 29 | } 30 | } 31 | 32 | componentDidMount() { 33 | if (this.props.onMount) { 34 | this.props.onMount(this.getArgs({}, "Provider/onMount")); 35 | } 36 | } 37 | 38 | componentWillUnmount() { 39 | if (this.props.onUnmount) { 40 | const { setContextState, ...args } = this.getArgs(); 41 | this.props.onUnmount(args); 42 | } 43 | // istanbul ignore next 44 | if (this.devtools) { 45 | this.devtools.unsubscribe(); 46 | reduxDevtoolsExtension.disconnect(); 47 | } 48 | } 49 | 50 | mountContainer = (context, onMount) => { 51 | if (!this.containers[context]) { 52 | this.containers[context] = 0; 53 | if (onMount) this.setState(null, onMount); 54 | } 55 | this.containers[context] += 1; 56 | 57 | return onUnmount => { 58 | if (this.containers[context] === 1 && onUnmount) onUnmount(); 59 | this.containers[context] -= 1; 60 | }; 61 | }; 62 | 63 | setContextState = (context, updater, callback, type) => { 64 | let prevState; 65 | 66 | const updaterFn = state => { 67 | prevState = state.state; 68 | return { 69 | state: { 70 | ...state.state, 71 | [context]: { 72 | ...state.state[context], 73 | ...parseUpdater(updater, state.state[context] || {}) 74 | } 75 | } 76 | }; 77 | }; 78 | 79 | const callbackFn = () => { 80 | if (this.props.onUpdate) { 81 | const additionalArgs = { prevState, context, type }; 82 | const args = this.getArgs(additionalArgs, "Provider/onUpdate"); 83 | this.props.onUpdate(args); 84 | } 85 | if (callback) callback(); 86 | // istanbul ignore next 87 | if (this.devtools && type) { 88 | const devtoolsType = context ? `${context}/${type}` : type; 89 | this.devtools.send(devtoolsType, this.state.state); 90 | } 91 | }; 92 | 93 | this.setState(updaterFn, callbackFn); 94 | }; 95 | 96 | state = { 97 | state: this.props.initialState, 98 | mountContainer: this.mountContainer, 99 | setContextState: this.setContextState 100 | }; 101 | 102 | getArgs = (additionalArgs, type) => { 103 | const { state, setContextState } = this.state; 104 | return { 105 | state, 106 | setContextState: (ctx, u, c) => setContextState(ctx, u, c, type), 107 | ...additionalArgs 108 | }; 109 | }; 110 | 111 | render() { 112 | return ( 113 | 114 | {this.props.children} 115 | 116 | ); 117 | } 118 | } 119 | 120 | export default Provider; 121 | -------------------------------------------------------------------------------- /test/ReactSixteenAdapter.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | import React from 'react' 3 | import ReactDOM from 'react-dom' 4 | import ReactDOMServer from 'react-dom/server' 5 | import ShallowRenderer from 'react-test-renderer/shallow' 6 | import TestUtils from 'react-dom/test-utils' 7 | import { EnzymeAdapter } from 'enzyme' 8 | import { 9 | elementToTree, 10 | nodeTypeFromType, 11 | mapNativeEventNames, 12 | propFromEvent, 13 | assertDomAvailable, 14 | withSetStateAllowed, 15 | createRenderWrapper, 16 | createMountWrapper, 17 | propsWithKeysAndRef, 18 | } from 'enzyme-adapter-utils' 19 | import { findCurrentFiberUsingSlowPath } from 'react-reconciler/reflection' 20 | 21 | function ensureKeyOrUndefined(key) { 22 | return key || (key === '' ? '' : undefined); 23 | } 24 | 25 | const HostRoot = 3 26 | const ClassComponent = 2 27 | const Fragment = 10 28 | const FunctionalComponent = 1 29 | const HostPortal = 4 30 | const HostComponent = 5 31 | const HostText = 6 32 | const Mode = 11 33 | const ContextConsumer = 12 34 | const ContextProvider = 13 35 | 36 | function nodeAndSiblingsArray(nodeWithSibling) { 37 | const array = [] 38 | let node = nodeWithSibling 39 | while (node != null) { 40 | array.push(node) 41 | node = node.sibling 42 | } 43 | return array 44 | } 45 | 46 | function flatten(arr) { 47 | const result = [] 48 | const stack = [{ i: 0, array: arr }] 49 | while (stack.length) { 50 | const n = stack.pop() 51 | while (n.i < n.array.length) { 52 | const el = n.array[n.i] 53 | n.i += 1 54 | if (Array.isArray(el)) { 55 | stack.push(n) 56 | stack.push({ i: 0, array: el }) 57 | break 58 | } 59 | result.push(el) 60 | } 61 | } 62 | return result 63 | } 64 | 65 | function toTree(vnode) { 66 | if (vnode == null) { 67 | return null 68 | } 69 | // TODO(lmr): I'm not really sure I understand whether or not this is what 70 | // i should be doing, or if this is a hack for something i'm doing wrong 71 | // somewhere else. Should talk to sebastian about this perhaps 72 | const node = findCurrentFiberUsingSlowPath(vnode) 73 | switch (node.tag) { 74 | case HostRoot: // 3 75 | return toTree(node.child) 76 | case HostPortal: // 4 77 | return toTree(node.child) 78 | case ClassComponent: 79 | return { 80 | nodeType: 'class', 81 | type: node.type, 82 | props: { ...node.memoizedProps }, 83 | key: ensureKeyOrUndefined(node.key), 84 | ref: node.ref, 85 | instance: node.stateNode, 86 | rendered: childrenToTree(node.child), 87 | } 88 | case FunctionalComponent: // 1 89 | return { 90 | nodeType: 'function', 91 | type: node.type, 92 | props: { ...node.memoizedProps }, 93 | key: ensureKeyOrUndefined(node.key), 94 | ref: node.ref, 95 | instance: null, 96 | rendered: childrenToTree(node.child), 97 | } 98 | case HostComponent: { 99 | // 5 100 | let renderedNodes = flatten(nodeAndSiblingsArray(node.child).map(toTree)) 101 | if (renderedNodes.length === 0) { 102 | renderedNodes = [node.memoizedProps.children] 103 | } 104 | return { 105 | nodeType: 'host', 106 | type: node.type, 107 | props: { ...node.memoizedProps }, 108 | key: ensureKeyOrUndefined(node.key), 109 | ref: node.ref, 110 | instance: node.stateNode, 111 | rendered: renderedNodes, 112 | } 113 | } 114 | case HostText: // 6 115 | return node.memoizedProps 116 | case Fragment: // 10 117 | case Mode: // 11 118 | case ContextProvider: // 13 119 | case ContextConsumer: // 12 120 | return childrenToTree(node.child) 121 | default: 122 | throw new Error( 123 | `Enzyme Internal Error: unknown node with tag ${node.tag}`, 124 | ) 125 | } 126 | } 127 | 128 | function childrenToTree(node) { 129 | if (!node) { 130 | return null 131 | } 132 | const children = nodeAndSiblingsArray(node) 133 | if (children.length === 0) { 134 | return null 135 | } else if (children.length === 1) { 136 | return toTree(children[0]) 137 | } 138 | return flatten(children.map(toTree)) 139 | } 140 | 141 | function nodeToHostNode(_node) { 142 | // NOTE(lmr): node could be a function component 143 | // which wont have an instance prop, but we can get the 144 | // host node associated with its return value at that point. 145 | // Although this breaks down if the return value is an array, 146 | // as is possible with React 16. 147 | let node = _node 148 | while (node && !Array.isArray(node) && node.instance === null) { 149 | node = node.rendered 150 | } 151 | if (Array.isArray(node)) { 152 | // TODO(lmr): throw warning regarding not being able to get a host node here 153 | throw new Error('Trying to get host node of an array') 154 | } 155 | // if the SFC returned null effectively, there is no host node. 156 | if (!node) { 157 | return null 158 | } 159 | return ReactDOM.findDOMNode(node.instance) 160 | } 161 | 162 | class ReactSixteenAdapter extends EnzymeAdapter { 163 | constructor() { 164 | super() 165 | this.options = { 166 | ...this.options, 167 | enableComponentDidUpdateOnSetState: true, 168 | } 169 | } 170 | createMountRenderer(options) { 171 | assertDomAvailable('mount') 172 | const domNode = options.attachTo || global.document.createElement('div') 173 | let instance = null 174 | return { 175 | render(el, context, callback) { 176 | if (instance === null) { 177 | const ReactWrapperComponent = createMountWrapper(el, options) 178 | const wrappedEl = React.createElement(ReactWrapperComponent, { 179 | Component: el.type, 180 | props: el.props, 181 | context, 182 | }) 183 | instance = ReactDOM.render(wrappedEl, domNode) 184 | if (typeof callback === 'function') { 185 | callback() 186 | } 187 | } else { 188 | instance.setChildProps(el.props, context, callback) 189 | } 190 | }, 191 | unmount() { 192 | ReactDOM.unmountComponentAtNode(domNode) 193 | instance = null 194 | }, 195 | getNode() { 196 | return instance ? toTree(instance._reactInternalFiber).rendered : null 197 | }, 198 | simulateEvent(node, event, mock) { 199 | const mappedEvent = mapNativeEventNames(event) 200 | const eventFn = TestUtils.Simulate[mappedEvent] 201 | if (!eventFn) { 202 | throw new TypeError( 203 | `ReactWrapper::simulate() event '${event}' does not exist`, 204 | ) 205 | } 206 | // eslint-disable-next-line react/no-find-dom-node 207 | eventFn(nodeToHostNode(node), mock) 208 | }, 209 | batchedUpdates(fn) { 210 | return fn() 211 | // return ReactDOM.unstable_batchedUpdates(fn); 212 | }, 213 | } 214 | } 215 | 216 | createShallowRenderer(/* options */) { 217 | const renderer = new ShallowRenderer() 218 | let isDOM = false 219 | let cachedNode = null 220 | return { 221 | render(el, context) { 222 | cachedNode = el 223 | /* eslint consistent-return: 0 */ 224 | if (typeof el.type === 'string') { 225 | isDOM = true 226 | } else { 227 | isDOM = false 228 | return withSetStateAllowed(() => renderer.render(el, context)) 229 | } 230 | }, 231 | unmount() { 232 | renderer.unmount() 233 | }, 234 | getNode() { 235 | if (isDOM) { 236 | return elementToTree(cachedNode) 237 | } 238 | const output = renderer.getRenderOutput() 239 | return { 240 | nodeType: nodeTypeFromType(cachedNode.type), 241 | type: cachedNode.type, 242 | props: cachedNode.props, 243 | key: ensureKeyOrUndefined(cachedNode.key), 244 | ref: cachedNode.ref, 245 | instance: renderer._instance, 246 | rendered: Array.isArray(output) 247 | ? flatten(output).map(elementToTree) 248 | : elementToTree(output), 249 | } 250 | }, 251 | simulateEvent(node, event, ...args) { 252 | const handler = node.props[propFromEvent(event)] 253 | if (handler) { 254 | withSetStateAllowed(() => { 255 | // TODO(lmr): create/use synthetic events 256 | // TODO(lmr): emulate React's event propagation 257 | // ReactDOM.unstable_batchedUpdates(() => { 258 | handler(...args) 259 | // }); 260 | }) 261 | } 262 | }, 263 | batchedUpdates(fn) { 264 | return fn() 265 | // return ReactDOM.unstable_batchedUpdates(fn); 266 | }, 267 | } 268 | } 269 | 270 | createStringRenderer(options) { 271 | return { 272 | render(el, context) { 273 | if ( 274 | options.context && 275 | (el.type.contextTypes || options.childContextTypes) 276 | ) { 277 | const childContextTypes = { 278 | ...(el.type.contextTypes || {}), 279 | ...options.childContextTypes, 280 | } 281 | const ContextWrapper = createRenderWrapper( 282 | el, 283 | context, 284 | childContextTypes, 285 | ) 286 | return ReactDOMServer.renderToStaticMarkup( 287 | React.createElement(ContextWrapper), 288 | ) 289 | } 290 | return ReactDOMServer.renderToStaticMarkup(el) 291 | }, 292 | } 293 | } 294 | 295 | // Provided a bag of options, return an `EnzymeRenderer`. Some options can be implementation 296 | // specific, like `attach` etc. for React, but not part of this interface explicitly. 297 | // eslint-disable-next-line class-methods-use-this, no-unused-vars 298 | createRenderer(options) { 299 | switch (options.mode) { 300 | case EnzymeAdapter.MODES.MOUNT: 301 | return this.createMountRenderer(options) 302 | case EnzymeAdapter.MODES.SHALLOW: 303 | return this.createShallowRenderer(options) 304 | case EnzymeAdapter.MODES.STRING: 305 | return this.createStringRenderer(options) 306 | default: 307 | throw new Error( 308 | `Enzyme Internal Error: Unrecognized mode: ${options.mode}`, 309 | ) 310 | } 311 | } 312 | 313 | // converts an RSTNode to the corresponding JSX Pragma Element. This will be needed 314 | // in order to implement the `Wrapper.mount()` and `Wrapper.shallow()` methods, but should 315 | // be pretty straightforward for people to implement. 316 | // eslint-disable-next-line class-methods-use-this, no-unused-vars 317 | nodeToElement(node) { 318 | if (!node || typeof node !== 'object') return null 319 | return React.createElement(node.type, propsWithKeysAndRef(node)) 320 | } 321 | 322 | elementToNode(element) { 323 | return elementToTree(element) 324 | } 325 | 326 | nodeToHostNode(node) { 327 | return nodeToHostNode(node) 328 | } 329 | 330 | isValidElement(element) { 331 | return React.isValidElement(element) 332 | } 333 | 334 | createElement(...args) { 335 | return React.createElement(...args) 336 | } 337 | } 338 | 339 | module.exports = ReactSixteenAdapter 340 | -------------------------------------------------------------------------------- /test/createCommonTests.js: -------------------------------------------------------------------------------- 1 | import { increment, getParity } from "./testUtils"; 2 | 3 | const createCommonTests = (props, getState, wrap) => () => { 4 | test("initialState", () => { 5 | const initialState = { foo: "bar" }; 6 | const wrapper = wrap({ ...props, initialState }); 7 | expect(getState(wrapper)).toEqual(initialState); 8 | }); 9 | 10 | test("multiple initialState", () => { 11 | const initialState = { foo: "bar", baz: "qux" }; 12 | const wrapper = wrap({ ...props, initialState }); 13 | expect(getState(wrapper)).toEqual(initialState); 14 | }); 15 | 16 | test("actions", () => { 17 | const initialState = { count: 0 }; 18 | const actions = { increment }; 19 | const wrapper = wrap({ ...props, initialState, actions }); 20 | expect(getState(wrapper)).toEqual({ 21 | count: 0, 22 | increment: expect.any(Function) 23 | }); 24 | getState(wrapper).increment(2); 25 | expect(getState(wrapper).count).toBe(2); 26 | getState(wrapper).increment(2); 27 | expect(getState(wrapper).count).toBe(4); 28 | }); 29 | 30 | test("actions with mutiple initialState", () => { 31 | const initialState = { count: 0, foo: "bar" }; 32 | const actions = { increment }; 33 | const wrapper = wrap({ ...props, initialState, actions }); 34 | expect(getState(wrapper)).toEqual({ 35 | count: 0, 36 | foo: "bar", 37 | increment: expect.any(Function) 38 | }); 39 | getState(wrapper).increment(2); 40 | expect(getState(wrapper).count).toBe(2); 41 | expect(getState(wrapper).foo).toBe("bar"); 42 | }); 43 | 44 | test("actions without function", () => { 45 | const initialState = { count: 10 }; 46 | const actions = { reset: () => ({ count: 0 }) }; 47 | const wrapper = wrap({ ...props, initialState, actions }); 48 | getState(wrapper).reset(); 49 | expect(getState(wrapper).count).toBe(0); 50 | }); 51 | 52 | test("selectors", () => { 53 | const initialState = { count: 0 }; 54 | const selectors = { getParity }; 55 | const wrapper = wrap({ ...props, initialState, selectors }); 56 | expect(getState(wrapper).count).toBe(0); 57 | expect(getState(wrapper).getParity()).toBe("even"); 58 | }); 59 | 60 | test("selectors with mutiple initialState", () => { 61 | const initialState = { count: 0, foo: "bar" }; 62 | const selectors = { getParity }; 63 | const wrapper = wrap({ ...props, initialState, selectors }); 64 | expect(getState(wrapper).count).toBe(0); 65 | expect(getState(wrapper).foo).toBe("bar"); 66 | expect(getState(wrapper).getParity()).toBe("even"); 67 | }); 68 | 69 | test("effects", () => { 70 | jest.useFakeTimers(); 71 | const initialState = { count: 0 }; 72 | const effects = { 73 | tick: () => ({ setState }) => { 74 | setState(increment(1)); 75 | setTimeout(() => effects.tick()({ setState }), 1000); 76 | } 77 | }; 78 | const wrapper = wrap({ ...props, initialState, effects }); 79 | expect(getState(wrapper)).toEqual({ count: 0, tick: expect.any(Function) }); 80 | getState(wrapper).tick(); 81 | expect(getState(wrapper).count).toBe(1); 82 | jest.advanceTimersByTime(1000); 83 | expect(getState(wrapper).count).toBe(2); 84 | jest.advanceTimersByTime(1000); 85 | expect(getState(wrapper).count).toBe(3); 86 | }); 87 | 88 | test("effects with setState without function", () => { 89 | const initialState = { count: 10 }; 90 | const effects = { 91 | reset: () => ({ setState }) => setState({ count: 0 }) 92 | }; 93 | const wrapper = wrap({ ...props, initialState, effects }); 94 | getState(wrapper).reset(); 95 | expect(getState(wrapper).count).toBe(0); 96 | }); 97 | 98 | test("effects with setState callback", () => { 99 | const initialState = { count: 0 }; 100 | const effects = { 101 | tick: () => ({ setState }) => { 102 | setState(increment(1), () => { 103 | setState(increment(10), () => { 104 | setState(increment(100)); 105 | }); 106 | }); 107 | } 108 | }; 109 | const wrapper = wrap({ ...props, initialState, effects }); 110 | getState(wrapper).tick(); 111 | expect(getState(wrapper).count).toBe(111); 112 | }); 113 | 114 | test("onMount", () => { 115 | const initialState = { count: 0 }; 116 | const onMount = jest.fn(({ state, setState }) => { 117 | if (state.count === 0) { 118 | setState(increment(10)); 119 | } 120 | }); 121 | const wrapper = wrap({ ...props, initialState, onMount }); 122 | expect(onMount).toHaveBeenCalledTimes(1); 123 | expect(getState(wrapper)).toEqual({ count: 10 }); 124 | }); 125 | 126 | test("onMount with setState callback", () => { 127 | const initialState = { count: 0 }; 128 | const onMount = ({ setState }) => { 129 | setState(increment(1), () => { 130 | setState(increment(10), () => { 131 | setState(increment(100)); 132 | }); 133 | }); 134 | }; 135 | const wrapper = wrap({ ...props, initialState, onMount }); 136 | expect(getState(wrapper)).toEqual({ count: 111 }); 137 | }); 138 | 139 | test("onMount delayed", () => { 140 | jest.useFakeTimers(); 141 | const initialState = { count: 0 }; 142 | const onMount = ({ setState }) => { 143 | setTimeout(() => setState(increment(10)), 1000); 144 | }; 145 | const wrapper = wrap({ ...props, initialState, onMount }); 146 | expect(getState(wrapper)).toEqual({ count: 0 }); 147 | jest.advanceTimersByTime(1000); 148 | expect(getState(wrapper)).toEqual({ count: 10 }); 149 | }); 150 | 151 | test("onUpdate", () => { 152 | const initialState = { count: 0 }; 153 | const actions = { increment }; 154 | const onUpdate = jest.fn(({ prevState, setState }) => { 155 | if (prevState.count === 0) { 156 | setState(increment(10)); 157 | } 158 | }); 159 | const wrapper = wrap({ ...props, initialState, onUpdate, actions }); 160 | getState(wrapper).increment(10); 161 | expect(onUpdate).toHaveBeenCalledTimes(2); 162 | expect(getState(wrapper).count).toBe(20); 163 | }); 164 | 165 | test("onUpdate with setState callback", () => { 166 | const initialState = { count: 0 }; 167 | const actions = { increment }; 168 | const onUpdate = ({ prevState, setState }) => { 169 | if (prevState.count === 0) { 170 | setState(increment(1), () => { 171 | setState(increment(10), () => { 172 | setState(increment(100)); 173 | }); 174 | }); 175 | } 176 | }; 177 | const wrapper = wrap({ ...props, initialState, onUpdate, actions }); 178 | getState(wrapper).increment(1); 179 | expect(getState(wrapper).count).toBe(112); 180 | }); 181 | 182 | test("onUpdate delayed", () => { 183 | jest.useFakeTimers(); 184 | const initialState = { count: 0 }; 185 | const actions = { increment }; 186 | const onUpdate = ({ state, setState }) => { 187 | if (state.count === 10) { 188 | setTimeout(() => setState(increment(10)), 1000); 189 | } 190 | }; 191 | const wrapper = wrap({ ...props, initialState, onUpdate, actions }); 192 | getState(wrapper).increment(10); 193 | expect(getState(wrapper).count).toBe(10); 194 | jest.advanceTimersByTime(1000); 195 | expect(getState(wrapper).count).toBe(20); 196 | }); 197 | 198 | test("onUpdate should be triggered on onMount", () => { 199 | const initialState = { count: 0 }; 200 | const onMount = ({ setState }) => { 201 | setState(increment(10)); 202 | }; 203 | const onUpdate = jest.fn(); 204 | wrap({ ...props, initialState, onUpdate, onMount }); 205 | expect(onUpdate).toHaveBeenCalledTimes(1); 206 | }); 207 | 208 | test("onUpdate should be triggered on onUpdate", () => { 209 | const initialState = { count: 0 }; 210 | const actions = { increment }; 211 | const onUpdate = jest.fn(({ state, setState }) => { 212 | if (state.count <= 20) { 213 | setState(increment(10)); 214 | } 215 | }); 216 | const wrapper = wrap({ ...props, initialState, onUpdate, actions }); 217 | expect(onUpdate).toHaveBeenCalledTimes(0); 218 | getState(wrapper).increment(10); 219 | expect(onUpdate).toHaveBeenCalledTimes(3); 220 | expect(getState(wrapper).count).toBe(30); 221 | }); 222 | 223 | test("onUpdate action type", () => { 224 | const initialState = { count: 0 }; 225 | const actions = { increment }; 226 | const onUpdate = jest.fn(); 227 | const wrapper = wrap({ ...props, initialState, onUpdate, actions }); 228 | getState(wrapper).increment(10); 229 | expect(onUpdate).toHaveBeenCalledWith( 230 | expect.objectContaining({ 231 | type: "increment" 232 | }) 233 | ); 234 | }); 235 | 236 | test("onUpdate effect type", () => { 237 | const initialState = { count: 0 }; 238 | const effects = { 239 | increment: amount => ({ setState }) => 240 | setState(state => ({ count: state.count + amount })) 241 | }; 242 | const onUpdate = jest.fn(); 243 | const wrapper = wrap({ ...props, initialState, onUpdate, effects }); 244 | getState(wrapper).increment(10); 245 | expect(onUpdate).toHaveBeenCalledWith( 246 | expect.objectContaining({ 247 | type: "increment" 248 | }) 249 | ); 250 | }); 251 | 252 | test("onUpdate onMount type", () => { 253 | const initialState = { count: 0 }; 254 | const onMount = ({ setState }) => setState(increment(10)); 255 | const onUpdate = jest.fn(); 256 | wrap({ ...props, initialState, onUpdate, onMount }); 257 | expect(onUpdate).toHaveBeenCalledWith( 258 | expect.objectContaining({ type: "onMount" }) 259 | ); 260 | }); 261 | 262 | test("onUpdate onUpdate type", () => { 263 | const initialState = { count: 0 }; 264 | const actions = { increment }; 265 | const onUpdate = jest.fn(({ state, setState }) => { 266 | if (state.count <= 20) { 267 | setState(increment(10)); 268 | } 269 | }); 270 | const wrapper = wrap({ ...props, initialState, onUpdate, actions }); 271 | getState(wrapper).increment(10); 272 | expect(onUpdate).toHaveBeenCalledWith( 273 | expect.objectContaining({ 274 | type: "onUpdate" 275 | }) 276 | ); 277 | }); 278 | 279 | test("shouldUpdate", () => { 280 | const initialState = { count: 0 }; 281 | const actions = { increment }; 282 | const shouldUpdate = jest.fn(({ state, nextState }) => { 283 | if (state.count === 0 && nextState.count === 1) { 284 | return false; 285 | } 286 | return true; 287 | }); 288 | const onUpdate = jest.fn(); 289 | const wrapper = wrap({ 290 | ...props, 291 | initialState, 292 | actions, 293 | shouldUpdate, 294 | onUpdate 295 | }); 296 | expect(getState(wrapper).count).toBe(0); 297 | getState(wrapper).increment(); 298 | expect(shouldUpdate).toHaveBeenCalledWith({ 299 | state: expect.objectContaining({ count: 0 }), 300 | nextState: { count: 1 } 301 | }); 302 | expect(onUpdate).not.toHaveBeenCalled(); 303 | expect(getState(wrapper).count).toBe(0); 304 | getState(wrapper).increment(); 305 | expect(onUpdate).toHaveBeenCalled(); 306 | expect(getState(wrapper).count).toBe(2); 307 | }); 308 | }; 309 | 310 | export default createCommonTests; 311 | -------------------------------------------------------------------------------- /test/Provider.test.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { mount } from "enzyme"; 3 | import { increment, wrap, getState } from "./testUtils"; 4 | import createCommonTests from "./createCommonTests"; 5 | import { Provider, Container, Consumer } from "../src"; 6 | 7 | createCommonTests({ context: "foo" }, getState, wrap)(); 8 | 9 | test("Provider initialState", () => { 10 | const initialState = { foo: { count: 0 } }; 11 | const wrapper = wrap({ context: "foo" }, { initialState }); 12 | expect(getState(wrapper)).toEqual({ count: 0 }); 13 | }); 14 | 15 | test("Provider multiple initialState", () => { 16 | const initialState = { foo: { count: 0, foo: "bar" }, bar: {} }; 17 | const wrapper = wrap({ context: "foo" }, { initialState }); 18 | expect(getState(wrapper)).toEqual({ count: 0, foo: "bar" }); 19 | }); 20 | 21 | test("multiple contexts", () => { 22 | const initialState = { foo: { count: 0 }, bar: { count: 1 } }; 23 | const actions = { increment }; 24 | const wrapper = mount( 25 | 26 | 27 | {state =>
} 28 | 29 | 30 | {state => } 31 | 32 | 33 | ); 34 | expect(getState(wrapper, "div")).toEqual({ 35 | count: 0, 36 | increment: expect.any(Function) 37 | }); 38 | expect(getState(wrapper, "span")).toEqual({ 39 | count: 1, 40 | increment: expect.any(Function) 41 | }); 42 | getState(wrapper, "div").increment(2); 43 | expect(getState(wrapper, "div").count).toBe(2); 44 | getState(wrapper, "span").increment(2); 45 | expect(getState(wrapper, "span").count).toBe(3); 46 | }); 47 | 48 | test("context initialState overrides local initialState", () => { 49 | const initialState = { foo: { count: 0 } }; 50 | const wrapper = wrap( 51 | { context: "foo", initialState: { count: 1 } }, 52 | { initialState } 53 | ); 54 | expect(getState(wrapper)).toEqual({ count: 0 }); 55 | }); 56 | 57 | test("Provider onMount", () => { 58 | const initialState = { counter1: { count: 0 } }; 59 | const onMount = jest.fn(({ state, setContextState }) => { 60 | expect(state).toEqual(initialState); 61 | setContextState("counter1", { count: 10 }); 62 | }); 63 | const wrapper = wrap({ context: "counter1" }, { initialState, onMount }); 64 | expect(onMount).toHaveBeenCalledTimes(1); 65 | expect(getState(wrapper)).toEqual({ count: 10 }); 66 | }); 67 | 68 | test("Provider onUpdate", () => { 69 | expect.assertions(7); 70 | const initialState = { count: 0 }; 71 | const actions = { increment }; 72 | const onUpdate = jest.fn( 73 | ({ state, prevState, setContextState, context, type }) => { 74 | if (context === "counter1" && type === "initialState") { 75 | expect(prevState).toEqual({}); 76 | expect(state).toEqual({ counter1: { count: 0 } }); 77 | } else if (context === "counter1" && type === "increment") { 78 | expect(state).toEqual({ counter1: { count: 1 } }); 79 | expect(state[context]).toEqual({ count: 1 }); 80 | setContextState("foo", { bar: 1 }); 81 | } else if (type === "Provider/onUpdate") { 82 | expect(state).toEqual({ counter1: { count: 1 }, foo: { bar: 1 } }); 83 | } 84 | } 85 | ); 86 | const wrapper = wrap( 87 | { context: "counter1", actions, initialState }, 88 | { onUpdate } 89 | ); 90 | getState(wrapper).increment(1); 91 | expect(onUpdate).toHaveBeenCalledTimes(3); 92 | expect(getState(wrapper).count).toBe(1); 93 | }); 94 | 95 | test("Provider onUnmount", () => { 96 | const initialState = { counter1: { count: 0 } }; 97 | const onUnmount = jest.fn(); 98 | const Component = ({ hide }) => 99 | hide ? null : ( 100 | 101 |
102 | 103 | ); 104 | const wrapper = mount(); 105 | expect(onUnmount).toHaveBeenCalledTimes(0); 106 | wrapper.setProps({ hide: true }); 107 | expect(onUnmount).toHaveBeenCalledTimes(1); 108 | expect(onUnmount).toHaveBeenCalledWith({ 109 | state: { counter1: { count: 0 } } 110 | }); 111 | }); 112 | 113 | test("only the first onMount should be called", () => { 114 | const onMount1 = jest.fn(); 115 | const onMount2 = jest.fn(); 116 | const MyContainer = props => ; 117 | mount( 118 | 119 | 120 | {state =>
} 121 | 122 | 123 | {state => } 124 | 125 | 126 | ); 127 | expect(onMount1).toHaveBeenCalledTimes(1); 128 | expect(onMount2).toHaveBeenCalledTimes(0); 129 | }); 130 | 131 | test("onUpdate should be called only for the caller container", () => { 132 | const onUpdate1 = jest.fn(); 133 | const onUpdate2 = jest.fn(); 134 | const initialState = { count: 0 }; 135 | const actions = { increment }; 136 | const MyContainer = props => ( 137 | 143 | ); 144 | const wrapper = mount( 145 | 146 | 147 | {state =>
} 148 | 149 | 150 | {state => } 151 | 152 | 153 | ); 154 | getState(wrapper, "div").increment(); 155 | expect(onUpdate1).toHaveBeenCalledTimes(1); 156 | expect(onUpdate2).toHaveBeenCalledTimes(0); 157 | getState(wrapper, "span").increment(); 158 | expect(onUpdate1).toHaveBeenCalledTimes(1); 159 | expect(onUpdate2).toHaveBeenCalledTimes(1); 160 | }); 161 | 162 | test("onUpdate should be trigerred on onUnmount", () => { 163 | const initialState = { count: 0 }; 164 | const onUpdate = jest.fn(); 165 | const onUnmount = ({ setState }) => setState(increment(10)); 166 | const Component = ({ hide }) => ( 167 | 168 | {!hide && ( 169 | 175 | {() =>
} 176 | 177 | )} 178 | 179 | ); 180 | const wrapper = mount(); 181 | expect(onUpdate).toHaveBeenCalledTimes(0); 182 | wrapper.setProps({ hide: true }); 183 | expect(onUpdate).toHaveBeenCalledTimes(1); 184 | }); 185 | 186 | test("onUpdate onUnmount type", () => { 187 | const initialState = { count: 0 }; 188 | const onUpdate = jest.fn(); 189 | const onUnmount = ({ setState }) => setState(increment(10)); 190 | const Component = ({ hide }) => ( 191 | 192 | {!hide && ( 193 | 199 | {() =>
} 200 | 201 | )} 202 | 203 | ); 204 | const wrapper = mount(); 205 | wrapper.setProps({ hide: true }); 206 | expect(onUpdate).toHaveBeenCalledWith( 207 | expect.objectContaining({ 208 | type: "onUnmount" 209 | }) 210 | ); 211 | }); 212 | 213 | test("onUnmount should be called only for the last unmounted container", () => { 214 | const onUnmount1 = jest.fn(); 215 | const onUnmount2 = jest.fn(); 216 | const MyContainer = props => ; 217 | const Component = ({ hide1, hide2 }) => ( 218 | 219 | {!hide1 && {() => null}} 220 | {!hide2 && {() => null}} 221 | 222 | ); 223 | const wrapper = mount(); 224 | wrapper.setProps({ hide1: true }); 225 | expect(onUnmount1).toHaveBeenCalledTimes(0); 226 | expect(onUnmount2).toHaveBeenCalledTimes(0); 227 | wrapper.setProps({ hide2: true }); 228 | expect(onUnmount1).toHaveBeenCalledTimes(0); 229 | expect(onUnmount2).toHaveBeenCalledTimes(1); 230 | }); 231 | 232 | test("onUnmount setState", () => { 233 | const initialState = { count: 0 }; 234 | const onUnmount = ({ setState }) => setState(increment(10)); 235 | const Component = ({ hide }) => ( 236 | 237 | {!hide && ( 238 | 243 | {() =>
} 244 | 245 | )} 246 | {ctx => } 247 | 248 | ); 249 | const wrapper = mount(); 250 | expect(getState(wrapper, "span")).toEqual({ count: 0 }); 251 | wrapper.setProps({ hide: true }); 252 | expect(getState(wrapper, "span")).toEqual({ count: 10 }); 253 | }); 254 | 255 | test("onUnmount setState callback", () => { 256 | const initialState = { count: 0 }; 257 | const onUnmount = ({ setState }) => { 258 | setState(increment(1), () => { 259 | setState(increment(10), () => { 260 | setState(increment(100)); 261 | }); 262 | }); 263 | }; 264 | const Component = ({ hide }) => ( 265 | 266 | {!hide && ( 267 | 272 | {() =>
} 273 | 274 | )} 275 | {ctx => } 276 | 277 | ); 278 | const wrapper = mount(); 279 | expect(getState(wrapper, "span")).toEqual({ count: 0 }); 280 | wrapper.setProps({ hide: true }); 281 | expect(getState(wrapper, "span")).toEqual({ count: 111 }); 282 | }); 283 | 284 | test("onUnmount delayed", () => { 285 | jest.useFakeTimers(); 286 | const initialState = { count: 0 }; 287 | const onUnmount = ({ setState }) => { 288 | setTimeout(() => setState(increment(10)), 1000); 289 | }; 290 | const Component = ({ hide }) => ( 291 | 292 | {!hide && ( 293 | 298 | {() =>
} 299 | 300 | )} 301 | {ctx => } 302 | 303 | ); 304 | const wrapper = mount(); 305 | expect(getState(wrapper, "span")).toEqual({ count: 0 }); 306 | wrapper.setProps({ hide: true }); 307 | expect(getState(wrapper, "span")).toEqual({ count: 0 }); 308 | jest.advanceTimersByTime(1000); 309 | expect(getState(wrapper, "span")).toEqual({ count: 10 }); 310 | }); 311 | 312 | test("first initialState should take precedence over others", () => { 313 | const wrapper = mount( 314 | 315 | 316 | {state =>
} 317 | 318 | 319 | {state => } 320 | 321 | 322 | ); 323 | expect(getState(wrapper, "div")).toEqual({ 324 | count: 0, 325 | foo: "bar" 326 | }); 327 | expect(getState(wrapper, "span")).toEqual({ 328 | count: 0, 329 | foo: "bar" 330 | }); 331 | }); 332 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | constate logo 3 |

4 |

5 | 6 |

7 | Generated with nod 8 | NPM version 9 | Gzip size 10 | Dependencies 11 | Build Status 12 | Coverage Status 13 |

14 |

15 | 16 | > context + state = constate 17 | 18 | React state management library built with scalability in mind. You can start simple with [local state](#actions) and scale up to [global state](#context) with ease when needed. 19 | 20 | 👓 [**Read the introductory article**](https://medium.freecodecamp.org/reacts-new-context-api-how-to-toggle-between-local-and-global-state-c6ace81443d0)
21 | 🎮 [**Play with the demo**](https://codesandbox.io/s/7p2qv6mmq) 22 | 23 |

24 | 25 | ```jsx 26 | import React from "react"; 27 | import { Container } from "constate"; 28 | 29 | const initialState = { count: 0 }; 30 | 31 | const actions = { 32 | increment: () => state => ({ count: state.count + 1 }) 33 | }; 34 | 35 | const Counter = () => ( 36 | 37 | {({ count, increment }) => ( 38 | 39 | )} 40 | 41 | ); 42 | ``` 43 | 44 |

Example

45 | 46 | **Table of Contents** 47 | 48 | - [Installation](#installation) 49 | - [`Container`](#container) 50 | - [`initialState`](#initialstate) 51 | - [`actions`](#actions) 52 | - [`selectors`](#selectors) 53 | - [`effects`](#effects) 54 | - [`context`](#context) 55 | - [`onMount`](#onmount) 56 | - [`onUpdate`](#onupdate) 57 | - [`onUnmount`](#onunmount) 58 | - [`shouldUpdate`](#shouldupdate) 59 | - [`Provider`](#provider) 60 | - [`initialState`](#initialstate-1) 61 | - [`onMount`](#onmount-1) 62 | - [`onUpdate`](#onupdate-1) 63 | - [`onUnmount`](#onunmount-1) 64 | - [`devtools`](#devtools) 65 | - [`mount`](#mount) 66 | - [Composing](#composing) 67 | - [Testing](#testing) 68 | 69 | ## Installation 70 | 71 | ```sh 72 | npm i constate 73 | ``` 74 | 75 | ## `Container` 76 | 77 | > In computer science, a **container** is a class, a data structure, or an abstract data type (ADT) whose instances are collections of other objects. In other words, they store objects in an organized way that follows specific access rules. 78 | > 79 | > — 80 | 81 | ### `initialState` 82 | 83 | ```js 84 | type initialState = Object; 85 | ``` 86 | 87 | Use this prop to define the initial state of the container. 88 | 89 | ```jsx 90 | const initialState = { count: 0 }; 91 | 92 | const Counter = () => ( 93 | 94 | {({ count }) => } 95 | 96 | ); 97 | ``` 98 | 99 |

Example

100 | 101 | ### `actions` 102 | 103 | ```js 104 | type Actions = { 105 | [string]: () => ((state: Object) => Object) | Object 106 | }; 107 | ``` 108 | 109 | An action is a method that returns an `updater` function, which will be, internally, passed as an argument to React `setState`. Actions will be exposed, then, together with state within the child function. 110 | 111 | You can also return the object directly if you don't need `state`. 112 | 113 | ```jsx 114 | const initialState = { count: 0 }; 115 | 116 | const actions = { 117 | increment: amount => state => ({ count: state.count + amount }) 118 | }; 119 | 120 | const Counter = () => ( 121 | 122 | {({ count, increment }) => ( 123 | 124 | )} 125 | 126 | ); 127 | ``` 128 | 129 |

Example

130 | 131 | ### `selectors` 132 | 133 | ```js 134 | type Selectors = { 135 | [string]: () => (state: Object) => any 136 | }; 137 | ``` 138 | 139 | A selector is a method that returns a function, which receives the current state and should return something (the thing being selected). 140 | 141 | ```jsx 142 | const initialState = { count: 0 }; 143 | 144 | const actions = { 145 | increment: amount => state => ({ count: state.count + amount }) 146 | }; 147 | 148 | const selectors = { 149 | getParity: () => state => (state.count % 2 === 0 ? "even" : "odd") 150 | }; 151 | 152 | const Counter = () => ( 153 | 158 | {({ count, increment, getParity }) => ( 159 | 160 | )} 161 | 162 | ); 163 | ``` 164 | 165 |

Example

166 | 167 | ### `effects` 168 | 169 | ```js 170 | type Effects = { 171 | [string]: () => ({ state: Object, setState: Function }) => void 172 | }; 173 | ``` 174 | 175 | An effect is a method that returns a function, which receives both `state` and `setState`. This is useful if you need to perform side effects, like async actions, or just want to use `setState`. 176 | 177 | ```jsx 178 | const initialState = { count: 0 }; 179 | 180 | const effects = { 181 | tick: () => ({ setState }) => { 182 | const fn = () => setState(state => ({ count: state.count + 1 })); 183 | setInterval(fn, 1000); 184 | } 185 | }; 186 | 187 | const Counter = () => ( 188 | 189 | {({ count, tick }) => ( 190 | 191 | )} 192 | 193 | ); 194 | ``` 195 | 196 |

Example

197 | 198 | ### `context` 199 | 200 | ```js 201 | type Context = string; 202 | ``` 203 | 204 | Whenever you need to share state between components and/or feel the need to have a global state, you can pass a `context` prop to `Container` and wrap your app with `Provider`. 205 | 206 | ```jsx 207 | import { Provider, Container } from "constate"; 208 | 209 | const CounterContainer = props => ( 210 | state => ({ count: state.count + 1 }) }} 213 | {...props} 214 | /> 215 | ); 216 | 217 | const CounterButton = () => ( 218 | 219 | {({ increment }) => } 220 | 221 | ); 222 | 223 | const CounterValue = () => ( 224 | 225 | {({ count }) =>
{count}
} 226 |
227 | ); 228 | 229 | const App = () => ( 230 | 231 | 232 | 233 | 234 | ); 235 | ``` 236 | 237 |

Example

238 | 239 | ### `onMount` 240 | 241 | ```js 242 | type OnMount = ({ state: Object, setState: Function }) => void; 243 | ``` 244 | 245 | This is a function called inside `Container`'s `componentDidMount`. 246 | 247 | > Note: when using [`context`](#context), all `Container`s of the same context behave as a single unit, which means that `onMount` will be called only for the first mounted `Container` of each context. 248 | 249 | ```jsx 250 | const initialState = { count: 0 }; 251 | 252 | const onMount = ({ setState }) => { 253 | const fn = () => setState(state => ({ count: state.count + 1 })); 254 | document.body.addEventListener("mousemove", fn); 255 | }; 256 | 257 | const Counter = () => ( 258 | 259 | {({ count }) => } 260 | 261 | ); 262 | ``` 263 | 264 |

Example

265 | 266 | ### `onUpdate` 267 | 268 | ```js 269 | type OnUpdate = ({ 270 | prevState: Object, 271 | state: Object, 272 | setState: Function, 273 | type: string 274 | }) => void; 275 | ``` 276 | 277 | This is a function called every time `setState` is called, either internally with [`actions`](#actions) or directly with [`effects`](#effects) and lifecycle methods, including `onUpdate` itself. 278 | 279 | Besides `prevState`, `state` and `setState`, it receives a `type` property, which can be either the name of the `action`, `effect` or one of the lifecycle methods that triggered it, including `onUpdate` itself. 280 | 281 | > Note: when using [`context`](#context), `onUpdate` will be triggered only once per `setState` call no matter how many `Container`s of the same context you have mounted. 282 | 283 | ```jsx 284 | const initialState = { count: 0 }; 285 | 286 | const onMount = ({ setState }) => { 287 | const fn = () => setState(state => ({ count: state.count + 1 })); 288 | setInterval(fn, 1000); 289 | }; 290 | 291 | const onUpdate = ({ state, setState, type }) => { 292 | if (type === "onMount" && state.count === 5) { 293 | // reset counter 294 | setState({ count: 0 }); 295 | } 296 | }; 297 | 298 | const Counter = () => ( 299 | 300 | {({ count }) => } 301 | 302 | ); 303 | ``` 304 | 305 |

Example

306 | 307 | ### `onUnmount` 308 | 309 | ```js 310 | type OnUnmount = ({ state: Object, setState: Function }) => void; 311 | ``` 312 | 313 | This is a function called inside `Container`'s `componentWillUnmount`. It receives both current `state` and `setState`, but the latter will have effect only if you're using [`context`](#context). Otherwise, it will be noop. This is useful for making cleanups. 314 | 315 | > Note: when using [`context`](#context), all `Container`s of the same context behave as a single unit, which means that `onUnmount` will be called only when the last remaining `Container` of each context gets unmounted. 316 | 317 | ```jsx 318 | const initialState = { count: 0 }; 319 | 320 | const onMount = ({ setState }) => { 321 | const fn = () => setState(state => ({ count: state.count + 1 })); 322 | const interval = setInterval(fn, 1000); 323 | setState({ interval }); 324 | }; 325 | 326 | const onUnmount = ({ state }) => { 327 | clearInterval(state.interval); 328 | }; 329 | 330 | const Counter = () => ( 331 | 332 | {({ count }) => } 333 | 334 | ); 335 | ``` 336 | 337 |

Example

338 | 339 | ### `shouldUpdate` 340 | 341 | ```js 342 | type ShouldUpdate = ({ state: Object, nextState: Object }) => boolean; 343 | ``` 344 | 345 | This is a function called inside `Container`s `shouldComponentUpdate`. It receives the current `state` and `nextState` and should return `true` or `false`. If it returns `false`, `onUpdate` won't be called for that change, and it won't trigger another render. 346 | 347 | In the previous example using [`onUnmount`](#onunmount), we stored the result of `setInterval` in the state. That's ok to do, but the downside is that it would trigger an additional render, even though our UI didn't depend on `state.interval`. We can use `shouldUpdate` to ignore `state.interval`, for example: 348 | 349 | ```jsx 350 | const initialState = { count: 0, updates: 0 }; 351 | 352 | const onMount = ({ setState }) => { 353 | const fn = () => setState(state => ({ count: state.count + 1 })); 354 | const interval = setInterval(fn, 1000); 355 | setState({ interval }); 356 | }; 357 | 358 | const onUnmount = ({ state }) => { 359 | clearInterval(state.interval); 360 | }; 361 | 362 | const onUpdate = ({ type, setState }) => { 363 | // prevent infinite loop 364 | if (type !== "onUpdate") { 365 | setState(state => ({ updates: state.updates + 1 })); 366 | } 367 | }; 368 | 369 | // Don't call onUpdate and render if `interval` has changed 370 | const shouldUpdate = ({ state, nextState }) => 371 | state.interval === nextState.interval; 372 | 373 | const Counter = () => ( 374 | 381 | {({ count, updates }) => ( 382 | 387 | )} 388 | 389 | ); 390 | ``` 391 | 392 |

Example

393 | 394 | ## `Provider` 395 | 396 | You should wrap your app with `Provider` if you want to use [`context`](#context). 397 | 398 | ### `initialState` 399 | 400 | ```js 401 | type InitialState = Object; 402 | ``` 403 | 404 | It's possible to pass initialState to Provider. In the example below, all `Container`s with `context="counter1"` will start with `{ count: 10 }`. 405 | 406 | > Note: when using [`context`](#context), only the `initialState` of the first `Container` in the tree will be considered. `Provider` will always take precedence over `Container`. 407 | 408 | ```jsx 409 | const initialState = { 410 | counter1: { 411 | count: 10 412 | } 413 | }; 414 | 415 | const App = () => ( 416 | 417 | ... 418 | 419 | ); 420 | ``` 421 | 422 | ### `onMount` 423 | 424 | ```js 425 | type OnMount = ({ state: Object, setContextState: Function }) => void; 426 | ``` 427 | 428 | As well as with `Container`, you can pass an `onMount` prop to `Provider`. The function will be called when `Provider`'s `componentDidMount` gets called. 429 | 430 | ```jsx 431 | const onMount = ({ setContextState }) => { 432 | setContextState("counter1", { count: 0 }); 433 | }; 434 | 435 | const MyProvider = props => ( 436 | 437 | ); 438 | 439 | const App = () => ( 440 | 441 | ... 442 | 443 | ); 444 | ``` 445 | 446 | ### `onUpdate` 447 | 448 | ```js 449 | type OnUpdate = ({ 450 | prevState: Object, 451 | state: Object, 452 | setContextState: Function, 453 | context: string, 454 | type: string 455 | }) => void; 456 | ``` 457 | 458 | `onUpdate` will be called every time `Provider`'s `setState` gets called. If `setContextState` was called instead, `onUpdate` will also receive a `context` prop. 459 | 460 | `Container`s, when the `context` prop is defined, use `setContextState` internally, which means that `Provider`'s `onUpdate` will be triggered for every change on the context. 461 | 462 | ```jsx 463 | const initialState = { counter1: { incrementCalls: 0 } }; 464 | 465 | const onUpdate = ({ context, type, setContextState }) => { 466 | if (type === "increment") { 467 | setContextState(context, state => ({ 468 | incrementCalls: state.incrementCalls + 1 469 | })); 470 | } 471 | }; 472 | 473 | const MyProvider = props => ( 474 | 475 | ); 476 | 477 | const CounterContainer = props => ( 478 | state => ({ count: state.count + 2 }) }} 481 | {...props} 482 | /> 483 | ); 484 | 485 | const Counter = () => ( 486 | 487 | 488 | {({ count, incrementCalls, increment }) => ( 489 | 493 | )} 494 | 495 | 496 | ); 497 | ``` 498 | 499 |

Example

500 | 501 | ### `onUnmount` 502 | 503 | ```js 504 | type OnUnmount => ({ state: Object }) => void; 505 | ``` 506 | 507 | `onUnmount` will be triggered in `Provider`'s `componentWillUnmount`. 508 | 509 | ```jsx 510 | const onUnmount = ({ state }) => { 511 | console.log(state); 512 | }; 513 | 514 | const App = () => ( 515 | 516 | ... 517 | 518 | ); 519 | ``` 520 | 521 | ### `devtools` 522 | 523 | ```js 524 | type Devtools = boolean; 525 | ``` 526 | 527 | Passing `devtools` prop to `Provider` will enable [redux-devtools-extension](https://github.com/zalmoxisus/redux-devtools-extension) integration, if that's installed on your browser. With that, you can easily debug the state of your application. 528 | 529 | > Note: It only works for context state. If you want to debug local state, add a `context` prop to `Container` temporarily. 530 | 531 | ```jsx 532 | const App = () => ( 533 | 534 | ... 535 | 536 | ); 537 | ``` 538 | 539 |

Example

540 | 541 | ## `mount` 542 | 543 | ```js 544 | type Mount = (Container: Function | ReactElement) => Object; 545 | ``` 546 | 547 | > Note: this is an experimental feature 548 | 549 | With `mount`, you can have a stateful object representing the `Container`: 550 | 551 | ```jsx 552 | import { Container, mount } from "constate"; 553 | 554 | const CounterContainer = props => ( 555 | state => ({ count: state.count + 1 }) }} 558 | {...props} 559 | /> 560 | ); 561 | 562 | const state = mount(CounterContainer); 563 | 564 | console.log(state.count); // 0 565 | state.increment(); 566 | console.log(state.count); // 1 567 | ``` 568 | 569 | ## Composing 570 | 571 | Since `Container` is just a React component, you can create `Container`s that accepts new properties, making them really composable. 572 | 573 | For example, let's create a composable `CounterContainer`: 574 | 575 | ```jsx 576 | const increment = () => state => ({ count: state.count + 1 }); 577 | 578 | const CounterContainer = ({ initialState, actions, ...props }) => ( 579 | 584 | ); 585 | ``` 586 | 587 | Then, we can use it to create a `DecrementableCounterContainer`: 588 | 589 | ```jsx 590 | const decrement = () => state => ({ count: state.count - 1 }); 591 | 592 | const DecrementableCounterContainer = ({ actions, ...props }) => ( 593 | 594 | ); 595 | ``` 596 | 597 | Finally, we can use it on our other components: 598 | 599 | ```jsx 600 | const CounterButton = () => ( 601 | 602 | {({ count, decrement }) => } 603 | 604 | ); 605 | ``` 606 | 607 |

Example

608 | 609 | ## Testing 610 | 611 | [`actions`](#actions) and [`selectors`](#selectors) are pure functions and you can test them directly: 612 | ```js 613 | test("increment", () => { 614 | expect(increment(1)({ count: 0 })).toEqual({ count: 1 }); 615 | expect(increment(-1)({ count: 1 })).toEqual({ count: 0 }); 616 | }); 617 | 618 | test("getParity", () => { 619 | expect(getParity()({ count: 0 })).toBe("even"); 620 | expect(getParity()({ count: 1 })).toBe("odd"); 621 | }); 622 | ``` 623 | 624 | On the other hand, [`effects`](#effects) and lifecycle methods can be a little tricky to test depending on how you implement them. 625 | 626 | You can also use [`mount`](#mount) to create integration tests. This is how we can test our `CounterContainer` with its [`tick effect`](#effects): 627 | 628 | ```jsx 629 | import { mount } from "constate"; 630 | import CounterContainer from "./CounterContainer"; 631 | 632 | test("initialState", () => { 633 | const state = mount(CounterContainer); 634 | expect(state.count).toBe(0); 635 | }); 636 | 637 | test("increment", () => { 638 | const state = mount(CounterContainer); 639 | expect(state.count).toBe(0); 640 | state.increment(1); 641 | expect(state.count).toBe(1); 642 | state.increment(-1); 643 | expect(state.count).toBe(0); 644 | }); 645 | 646 | test("getParity", () => { 647 | const state = mount(); 648 | expect(state.getParity()).toBe("odd"); 649 | }); 650 | 651 | test("tick", () => { 652 | jest.useFakeTimers(); 653 | const state = mount(CounterContainer); 654 | 655 | state.tick(); 656 | 657 | jest.advanceTimersByTime(1000); 658 | expect(state.count).toBe(1); 659 | 660 | jest.advanceTimersByTime(1000); 661 | expect(state.count).toBe(2); 662 | }); 663 | ``` 664 | 665 | ## Contributing 666 | 667 | If you find a bug, please [create an issue](https://github.com/diegohaz/constate/issues/new) providing instructions to reproduce it. It's always very appreciable if you find the time to fix it. In this case, please [submit a PR](https://github.com/diegohaz/constate/pulls). 668 | 669 | If you're a beginner, it'll be a pleasure to help you contribute. You can start by reading [the beginner's guide to contributing to a GitHub project](https://akrabat.com/the-beginners-guide-to-contributing-to-a-github-project/). 670 | 671 | Run `npm start` to run examples. 672 | 673 | ## License 674 | 675 | MIT © [Diego Haz](https://github.com/diegohaz) 676 | --------------------------------------------------------------------------------