├── .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 }) => increment(1)}>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 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
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 | {count}
39 | )}
40 |
41 | );
42 | ```
43 |
44 |
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 }) => {count} }
95 |
96 | );
97 | ```
98 |
99 |
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 | increment(1)}>{count}
124 | )}
125 |
126 | );
127 | ```
128 |
129 |
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 | increment(1)}>{count} {getParity()}
160 | )}
161 |
162 | );
163 | ```
164 |
165 |
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 | {count}
191 | )}
192 |
193 | );
194 | ```
195 |
196 |
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 }) => 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 |
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 }) => {count} }
260 |
261 | );
262 | ```
263 |
264 |
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 }) => {count} }
301 |
302 | );
303 | ```
304 |
305 |
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 }) => {count} }
333 |
334 | );
335 | ```
336 |
337 |
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 |
383 | Count: {count}
384 |
385 | Updates: {updates}
386 |
387 | )}
388 |
389 | );
390 | ```
391 |
392 |
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 |
490 | count: {count}
491 | incrementCalls: {incrementCalls}
492 |
493 | )}
494 |
495 |
496 | );
497 | ```
498 |
499 |
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 |
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 }) => {count} }
603 |
604 | );
605 | ```
606 |
607 |
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 |
--------------------------------------------------------------------------------