├── src ├── index.js ├── utils │ └── index.js ├── types.d.ts ├── state │ └── index.js ├── validators │ └── index.js └── spec.js ├── .gitignore ├── tea.yaml ├── .npmignore ├── .eslintrc.json ├── .github └── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md ├── CHANGELOG.md ├── LICENSE ├── rollup.config.mjs ├── package.json ├── CODE_OF_CONDUCT.md └── README.md /src/index.js: -------------------------------------------------------------------------------- 1 | import { create } from './state'; 2 | 3 | export default { create }; 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | coverage/ 2 | node_modules/ 3 | yarn-error.log 4 | npm-debug.log 5 | lib/ 6 | dist/ 7 | -------------------------------------------------------------------------------- /tea.yaml: -------------------------------------------------------------------------------- 1 | # https://tea.xyz/what-is-this-file 2 | --- 3 | version: 1.0.0 4 | codeOwners: 5 | - '0x8958579fcDE99f81808e6c89aCFeEc3DF93Ad9bb' 6 | quorum: 1 7 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | npm-debug.log 2 | yarn-error.log 3 | webpack.config.js 4 | CODE_OF_CONDUCT.md 5 | .eslintrc.json 6 | .gitignore 7 | .npmignore 8 | .travis.yml 9 | .vscode/ 10 | 11 | /demo/ 12 | /src/ 13 | /coverage/ 14 | /.github/ 15 | /node_modules/ 16 | 17 | __snapshots__ 18 | *.spec.js 19 | *.spec.js.snap 20 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es2020": true, 5 | "node": true 6 | }, 7 | "parserOptions": { 8 | "ecmaVersion": 11, 9 | "sourceType": "module" 10 | }, 11 | "ignorePatterns": ["spec.js", "*.spec.js"], 12 | "extends": "eslint:recommended", 13 | "rules": { 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ### Versions 2 | 3 | ## 1.0.7 4 | ###### *Jan 7, 2021* 5 | 6 | - package: add jsdelivr source path 7 | 8 | ## 1.0.6 9 | ###### *Jan 3, 2021* 10 | 11 | - use default export in the entry file 12 | - fix library import in `spec.js` 13 | 14 | ## 1.0.5 15 | ###### *Jan 3, 2021* 16 | 17 | - replace `webpack` with `rollup` 18 | - add cjs/es bundles 19 | 20 | ## 1.0.4 21 | ###### *Nov 15, 2020* 22 | 23 | - revert "corrected moudles" 24 | 25 | ## 1.0.3 26 | ###### *Nov 15, 2020* 27 | 28 | - remove `src` from `.npmignore` and refer it to `package.module` 29 | 30 | ## 1.0.2 31 | ###### *Nov 14, 2020* 32 | 33 | - fix `module` path in package.json 34 | 35 | ## 1.0.1 36 | ###### *Aug 6, 2020* 37 | 38 | - add default export into the index point 39 | 40 | ## 1.0.0 41 | ###### *Aug 6, 2020* 42 | 43 | 🎉 First stable version of the library 44 | -------------------------------------------------------------------------------- /src/utils/index.js: -------------------------------------------------------------------------------- 1 | function compose(...fns) { 2 | return x => fns.reduceRight((y, f) => f(y), x); 3 | } 4 | 5 | function curry(fn) { 6 | return function curried(...args) { 7 | return args.length >= fn.length 8 | ? fn.apply(this, args) 9 | : (...nextArgs) => curried.apply(this, [...args, ...nextArgs]); 10 | } 11 | } 12 | 13 | function isObject(value) { 14 | return ({}).toString.call(value).includes('Object'); 15 | } 16 | 17 | function isEmpty(obj) { 18 | return !Object.keys(obj).length; 19 | } 20 | 21 | function isFunction(value) { 22 | return typeof value === 'function'; 23 | } 24 | 25 | function hasOwnProperty(object, property) { 26 | return Object.prototype.hasOwnProperty.call(object, property); 27 | } 28 | 29 | function noop() {} 30 | 31 | export { compose, curry, isObject, isEmpty, isFunction, noop, hasOwnProperty }; 32 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | 15 | Steps to reproduce the behavior: 16 | 1. Go to '...' 17 | 2. Click on '....' 18 | 3. Scroll down to '....' 19 | 4. See error 20 | 21 | Or, please add a minimal code snippet to reproduce the issue, for example in [codesandbox](https://codesandbox.io/). 22 | 23 | **Expected behavior** 24 | A clear and concise description of what you expected to happen. 25 | 26 | **Screenshots** 27 | If applicable, add screenshots to help to explain your problem. 28 | 29 | **Desktop (please complete the following information):** 30 | - OS: [e.g. iOS] 31 | - Browser [e.g. chrome, safari] 32 | - Version [e.g. 22] 33 | 34 | **Additional context** 35 | Add any other context about the problem here. 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Suren Atoyan 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 | -------------------------------------------------------------------------------- /src/types.d.ts: -------------------------------------------------------------------------------- 1 | // Type definitions for state-local v0.0.1 2 | // Project: state-local 3 | // Definitions by: Suren Atoyan contact@surenatoyan.com 4 | 5 | export as namespace state; 6 | 7 | export type State = Record; 8 | export type Selector = (state: State) => State; 9 | export type ChangeGetter = (state: State) => State; 10 | export type GetState = (selector?: Selector) => State; 11 | export type SetState = (change: State | ChangeGetter) => void; 12 | export type StateUpdateHandler = (update: State) => unknown; 13 | export type FieldUpdateHandler = (update: any) => unknown; 14 | export type Handlers = Record; 15 | 16 | /** 17 | * `state.create` is a function with two parameters: 18 | * the first one (required) is the initial state and it should be a non-empty object 19 | * the second one (optional) is the handler, which can be function or object 20 | * if the handler is a function than it should be called immediately after every state update 21 | * if the handler is an object than the keys of that object should be a subset of the state 22 | * and the all values of that object should be functions, plus they should be called immediately 23 | * after every update of the corresponding field in the state 24 | */ 25 | export function create(initial: State, handler?: StateUpdateHandler | Handlers): [GetState, SetState]; 26 | -------------------------------------------------------------------------------- /src/state/index.js: -------------------------------------------------------------------------------- 1 | import { compose, curry, isFunction } from '../utils'; 2 | import validators from '../validators'; 3 | 4 | function create(initial, handler = {}) { 5 | validators.initial(initial); 6 | validators.handler(handler); 7 | 8 | const state = { current: initial }; 9 | 10 | const didUpdate = curry(didStateUpdate)(state, handler); 11 | const update = curry(updateState)(state); 12 | const validate = curry(validators.changes)(initial); 13 | const getChanges = curry(extractChanges)(state); 14 | 15 | function getState(selector = state => state) { 16 | validators.selector(selector); 17 | return selector(state.current); 18 | } 19 | 20 | function setState(causedChanges) { 21 | compose( 22 | didUpdate, 23 | update, 24 | validate, 25 | getChanges, 26 | )(causedChanges); 27 | } 28 | 29 | return [getState, setState]; 30 | } 31 | 32 | function extractChanges(state, causedChanges) { 33 | return isFunction(causedChanges) 34 | ? causedChanges(state.current) 35 | : causedChanges; 36 | } 37 | 38 | function updateState(state, changes) { 39 | state.current = { ...state.current, ...changes }; 40 | 41 | return changes; 42 | } 43 | 44 | function didStateUpdate(state, handler, changes) { 45 | isFunction(handler) 46 | ? handler(state.current) 47 | : Object.keys(changes) 48 | .forEach(field => handler[field]?.(state.current[field])); 49 | 50 | return changes; 51 | } 52 | 53 | export { create }; 54 | -------------------------------------------------------------------------------- /rollup.config.mjs: -------------------------------------------------------------------------------- 1 | import nodeResolve from '@rollup/plugin-node-resolve'; 2 | import { terser } from 'rollup-plugin-terser'; 3 | import replace from '@rollup/plugin-replace'; 4 | import commonjs from '@rollup/plugin-commonjs'; 5 | import babel from '@rollup/plugin-babel'; 6 | 7 | const defaultNodeResolveConfig = {}; 8 | const nodeResolvePlugin = nodeResolve(defaultNodeResolveConfig); 9 | 10 | const commonPlugins = [ 11 | nodeResolvePlugin, 12 | babel.default({ 13 | presets: ['@babel/preset-env'], 14 | babelHelpers: 'bundled', 15 | }), 16 | commonjs(), 17 | ]; 18 | 19 | const developmentPlugins = [ 20 | ...commonPlugins, 21 | replace({ 22 | 'process.env.NODE_ENV': JSON.stringify('development'), 23 | }), 24 | ]; 25 | 26 | const productionPlugins = [ 27 | ...commonPlugins, 28 | replace({ 29 | 'process.env.NODE_ENV': JSON.stringify('production'), 30 | }), 31 | terser({ mangle: false }), 32 | ]; 33 | 34 | export default [ 35 | { 36 | input: 'src/index.js', 37 | output: { 38 | file: 'lib/cjs/state-local.js', 39 | format: 'cjs', 40 | exports: 'default', 41 | }, 42 | plugins: commonPlugins, 43 | }, 44 | { 45 | input: 'src/index.js', 46 | output: { 47 | file: 'lib/es/state-local.js', 48 | format: 'es', 49 | }, 50 | plugins: commonPlugins, 51 | }, 52 | { 53 | input: 'src/index.js', 54 | output: { 55 | file: 'lib/umd/state-local.js', 56 | format: 'umd', 57 | name: 'state', 58 | }, 59 | plugins: developmentPlugins, 60 | }, 61 | { 62 | input: 'src/index.js', 63 | output: { 64 | file: 'lib/umd/state-local.min.js', 65 | format: 'umd', 66 | name: 'state', 67 | }, 68 | plugins: productionPlugins, 69 | }, 70 | ]; 71 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "state-local", 3 | "version": "1.0.7", 4 | "description": "Tiny, simple, and robust technique for defining and acting with local states", 5 | "main": "lib/cjs/state-local.js", 6 | "module": "lib/es/state-local.js", 7 | "unpkg": "lib/umd/state-local.min.js", 8 | "jsdelivr": "lib/umd/state-local.min.js", 9 | "types": "lib/types.d.ts", 10 | "author": "Suren Atoyan ", 11 | "license": "MIT", 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/suren-atoyan/state-local" 15 | }, 16 | "bugs": { 17 | "url": "https://github.com/suren-atoyan/state-local/issues" 18 | }, 19 | "homepage": "https://github.com/suren-atoyan/state-local#readme", 20 | "keywords": [ 21 | "state", 22 | "state management", 23 | "local state" 24 | ], 25 | "scripts": { 26 | "test": "jest", 27 | "test-watch": "npm run build && jest --watch", 28 | "coverage": "jest --collect-coverage", 29 | "lint": "npx eslint src", 30 | "prepublish": "npm test && npm run lint && npm run build", 31 | "build": "rollup -c && cp ./src/types.d.ts ./lib/" 32 | }, 33 | "babel": { 34 | "presets": [ 35 | "@babel/preset-env" 36 | ] 37 | }, 38 | "husky": { 39 | "hooks": { 40 | "pre-commit": "npm test && npm run lint" 41 | } 42 | }, 43 | "devDependencies": { 44 | "@babel/core": "^7.11.0", 45 | "@babel/preset-env": "^7.11.0", 46 | "@rollup/plugin-babel": "^5.2.2", 47 | "@rollup/plugin-commonjs": "^17.0.0", 48 | "@rollup/plugin-node-resolve": "^11.0.1", 49 | "@rollup/plugin-replace": "^2.3.4", 50 | "babel-jest": "^26.2.2", 51 | "babel-loader": "^8.1.0", 52 | "eslint": "^7.6.0", 53 | "husky": "^4.2.5", 54 | "jest": "^26.2.2", 55 | "rollup": "^2.35.1", 56 | "rollup-plugin-terser": "^7.0.2" 57 | }, 58 | "dependencies": {} 59 | } 60 | -------------------------------------------------------------------------------- /src/validators/index.js: -------------------------------------------------------------------------------- 1 | import { curry, isFunction, isObject, isEmpty, hasOwnProperty } from '../utils'; 2 | 3 | function validateChanges(initial, changes) { 4 | if (!isObject(changes)) errorHandler('changeType'); 5 | if (Object.keys(changes).some(field => !hasOwnProperty(initial, field))) errorHandler('changeField'); 6 | 7 | return changes; 8 | } 9 | 10 | function validateSelector(selector) { 11 | if (!isFunction(selector)) errorHandler('selectorType'); 12 | } 13 | 14 | function validateHandler(handler) { 15 | if (!(isFunction(handler) || isObject(handler))) errorHandler('handlerType'); 16 | if (isObject(handler) && Object.values(handler).some(_handler => !isFunction(_handler))) errorHandler('handlersType'); 17 | } 18 | 19 | function validateInitial(initial) { 20 | if (!initial) errorHandler('initialIsRequired'); 21 | if (!isObject(initial)) errorHandler('initialType'); 22 | if (isEmpty(initial)) errorHandler('initialContent'); 23 | } 24 | 25 | function throwError(errorMessages, type) { 26 | throw new Error(errorMessages[type] || errorMessages.default); 27 | } 28 | 29 | const errorMessages = { 30 | initialIsRequired: 'initial state is required', 31 | initialType: 'initial state should be an object', 32 | initialContent: 'initial state shouldn\'t be an empty object', 33 | handlerType: 'handler should be an object or a function', 34 | handlersType: 'all handlers should be a functions', 35 | selectorType: 'selector should be a function', 36 | changeType: 'provided value of changes should be an object', 37 | changeField: 'it seams you want to change a field in the state which is not specified in the "initial" state', 38 | default: 'an unknown error accured in `state-local` package', 39 | }; 40 | 41 | const errorHandler = curry(throwError)(errorMessages); 42 | 43 | const validators = { 44 | changes: validateChanges, 45 | selector: validateSelector, 46 | handler: validateHandler, 47 | initial: validateInitial, 48 | }; 49 | 50 | export default validators; 51 | 52 | export { errorMessages, errorHandler }; 53 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at contact@surenatoyan.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # State · [![monthly downloads](https://img.shields.io/npm/dm/state-local)](https://www.npmjs.com/package/state-local) [![gitHub license](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/suren-atoyan/state-local/blob/master/LICENSE) [![Rate on Openbase](https://badges.openbase.io/js/rating/state-local.svg)](https://openbase.io/js/state-local?utm_source=embedded&utm_medium=badge&utm_campaign=rate-badge) [![build size](https://img.shields.io/bundlephobia/minzip/state-local)](https://bundlephobia.com/result?p=state-local) [![npm version](https://img.shields.io/npm/v/state-local.svg?style=flat)](https://www.npmjs.com/package/state-local) [![PRs welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](https://github.com/suren-atoyan/state-local/pulls) 2 | 3 | :zap: Tiny, simple, and robust technique for defining and acting with local states (for all js environments - node, browser, etc.) 4 | 5 | ## Synopsis 6 | 7 | A local state for modules, functions, and other ECs 8 | 9 | ## Motivation 10 | 11 | We all love functional programming and the concepts of it. It gives us many clean patterns, which we use in our code regardless of exactly which paradigm is in the base of our codebase. But sometimes, for some reason, we can't keep our code "clean" and have to interact with items that are outside of the current lexical environment 12 | 13 | For example: 14 | 15 | :x: 16 | ```javascript 17 | let x = 0; 18 | let y = 1; 19 | 20 | // ... 21 | function someFn() { 22 | // ... 23 | x++; 24 | } 25 | 26 | // ... 27 | function anotherFn() { 28 | // ... 29 | y = 6; 30 | console.log(x); 31 | } 32 | 33 | // ... 34 | function yetAnotherFn() { 35 | // ... 36 | y = x + 4; 37 | x = null; // 🚶 38 | } 39 | ``` 40 | 41 | The example above lacks control over the mutations and consumption, which can lead to unpredictable and unwanted results. It is just an example of real-life usage and there are many similar cases that belong to the same class of the problem 42 | 43 | **The purpose of this library is to give an opportunity to work with local states in a clear, predictable, trackable, and strict way** 44 | 45 | :white_check_mark: 46 | 47 | ```javascript 48 | import state from 'state-local'; 49 | 50 | const [getState, setState] = state.create({ x: 0, y: 1 }); 51 | 52 | // ... 53 | function someFn() { 54 | // ... 55 | setState(state => ({ x: state.x + 1 })); 56 | } 57 | 58 | // ... 59 | function anotherFn() { 60 | // ... 61 | setState({ y: 6 }); 62 | const state = getState(); 63 | console.log(state); 64 | } 65 | 66 | // ... 67 | function yetAnotherFn() { 68 | // ... 69 | setState(state => ({ y: state.x + 4, x: null })); 70 | } 71 | ``` 72 | 73 | [codesandbox](https://codesandbox.io/s/motivation-1-xv5el?file=/src/index.js) 74 | 75 | We also can track the changes in items: 76 | 77 | ```javascript 78 | import state from 'state-local'; 79 | 80 | const [getState, setState] = state.create({ x: 0, y: 1 }, { 81 | x: latestX => console.log('(⌐▀ ̯ʖ▀) Houston we have a problem; "x" has been changed. "x" now is:', latestX), 82 | y: latestY => console.log('(⌐▀ ̯ʖ▀) Houston we have a problem; "y" has been changed. "y" now is:', latestY), 83 | }); 84 | 85 | // ... 86 | ``` 87 | 88 | [codesandbox](https://codesandbox.io/s/motivation-2-ivf7d) 89 | 90 | We can use the subset of the state in some execution contexts: 91 | 92 | ```javascript 93 | import state from 'state-local'; 94 | 95 | const [getState, setState] = state.create({ x: 5, y: 7 }); 96 | 97 | // ... 98 | function someFn() { 99 | const state = getState(({ x }) => ({ x })); 100 | 101 | console.log(state.x); // 5 102 | console.log(state.y); // ❌ undefined - there is no y 103 | } 104 | ``` 105 | 106 | [codesandbox](https://codesandbox.io/s/motivation-3-femne) 107 | 108 | And much more... 109 | 110 | ## Documentation 111 | 112 | #### Contents 113 | 114 | * [Installation](#installation) 115 | * Usage 116 | * [create](#create) 117 | * [initial state](#initial-state) 118 | * [handler](#handler) 119 | * [getState](#getstate) 120 | * [selector](#selector) 121 | * [setState](#setstate) 122 | 123 | #### Installation 124 | 125 | You can install this library as an npm package or download it from the CDN and use it in node or browser: 126 | 127 | ```bash 128 | npm install state-local 129 | ``` 130 | or 131 | ```bash 132 | yarn add state-local 133 | ``` 134 | 135 | or 136 | 137 | ```html 138 | 139 | 140 | 145 | ``` 146 | 147 | #### create 148 | 149 | The default export has a method called `create`, which is supposed to be a function to create a state: 150 | 151 | ```javascript 152 | import state from 'state-local'; 153 | 154 | // state.create 155 | 156 | // ... 157 | ``` 158 | 159 | [codesandbox](https://codesandbox.io/s/docs-create-t1cxe) 160 | 161 | `create` is a function with two parameters: 162 | 163 | 1) [`initial state`](#initial-state) (**required**) 164 | 2) [`handler`](#handler) (**optional**) 165 | 166 | #### initial state 167 | 168 | `initial state` is a base structure and a value for the state. It should be a non-empty object 169 | 170 | ```javascript 171 | import state from 'state-local'; 172 | 173 | /* 174 | const [getState, setState] = state.create(); // ❌ error - initial state is required 175 | const [getState, setState] = state.create(5); // ❌ error - initial state should be an object 176 | const [getState, setState] = state.create({}); // ❌ error - initial state shouldn\'t be an empty object 177 | */ 178 | 179 | const [getState, setState] = state.create({ isLoading: false, payload: null }); // ✅ 180 | // ... 181 | ``` 182 | 183 | [codesandbox](https://codesandbox.io/s/docs-initial-state-22i3s) 184 | 185 | #### handler 186 | 187 | `handler` is a second parameter for `create` function and it is optional. It is going to be a handler for state updates. Hence it can be either a function or an object. 188 | 189 | - If the handler is a function than it should be called immediately after every state update (with the latest state) 190 | - If the handler is an object than the keys of that object should be a subset of the state and the values should be called immediately after every update of the corresponding field in the state (with the latest value of the field) 191 | 192 | see example below: 193 | 194 | if `handler` is a function 195 | ```javascript 196 | import state from 'state-local'; 197 | 198 | const [getState, setState] = state.create({ x: 2, y: 3, z: 5 }, handleStateUpdate /* will be called immediately after every state update */); 199 | 200 | function handleStateUpdate(latestState) { 201 | console.log('hey state has been updated; the new state is:', latestState); // { x: 7, y: 11, z: 13 } 202 | } 203 | 204 | setState({ x: 7, y: 11, z: 13 }); 205 | // ... 206 | ``` 207 | 208 | [codesandbox](https://codesandbox.io/s/handler-function-uevxj) 209 | 210 | if `handler` is an object 211 | ```javascript 212 | import state from 'state-local'; 213 | 214 | const [getState, setState] = state.create({ x: 2, y: 3, z: 5 }, { 215 | x: handleXUpdate, // will be called immediately after every "x" update 216 | y: handleYUpdate, // will be called immediately after every "y" update 217 | // and we don't want to listen "z" updates 😔 218 | }); 219 | 220 | function handleXUpdate(latestX) { 221 | console.log('(⌐▀ ̯ʖ▀) Houston we have a problem; "x" has been changed. "x" now is:', latestX); // ... "x" now is 7 222 | } 223 | 224 | function handleYUpdate(latestY) { 225 | console.log('(⌐▀ ̯ʖ▀) Houston we have a problem; "y" has been changed. "y" now is:', latestY); // ... "y" now is 11 226 | } 227 | 228 | setState({ x: 7, y: 11, z: 13 }); 229 | // ... 230 | ``` 231 | 232 | [codesandbox](https://codesandbox.io/s/handler-object-8k0pt) 233 | 234 | #### getState 235 | 236 | `getState` is the first element of the pair returned by `create` function. It will return the current state or the subset of the current state depending on how it was called. It has an optional parameter `selector` 237 | 238 | ```javascript 239 | import state from "state-local"; 240 | 241 | const [getState, setState] = state.create({ p1: 509, p2: 521 }); 242 | 243 | const state = getState(); 244 | console.log(state.p1); // 509 245 | console.log(state.p2); // 521 246 | 247 | // or 248 | 249 | const { p1, p2 } = getState(); 250 | console.log(p1); // 509 251 | console.log(p2); // 521 252 | ``` 253 | 254 | [codesandbox](https://codesandbox.io/s/getstate-zn3hj) 255 | 256 | #### selector 257 | 258 | `selector` is a function that is supposed to be passed (optional) as an argument to `getState`. It receives the current state and returns a subset of the state 259 | 260 | ```javascript 261 | import state from 'state-local'; 262 | 263 | const [getState, setState] = state.create({ p1: 389, p2: 397, p3: 401 }); 264 | 265 | function someFn() { 266 | const state = getState(({ p1, p2 }) => ({ p1, p2 })); 267 | console.log(state.p1); // 389 268 | console.log(state.p2); // 397 269 | console.log(state.p3); // ❌ undefined - there is no p3 270 | } 271 | ``` 272 | 273 | [codesandbox](https://codesandbox.io/s/selector-vjmdu) 274 | 275 | #### setState 276 | 277 | `setState` is the second element of the pair returned by `create` function. It is going to receive an object as a change for the state. The change object will be shallow merged with the current state and the result will be the next state 278 | 279 | **NOTE: the change object can't contain a field that is not specified in the "initial" state** 280 | 281 | ```javascript 282 | import state from 'state-local'; 283 | 284 | const [getState, setState] = state.create({ x:0, y: 0 }); 285 | 286 | setState({ z: 'some value' }); // ❌ error - it seams you want to change a field in the state which is not specified in the "initial" state 287 | 288 | setState({ x: 11 }); // ✅ ok 289 | setState({ y: 1 }); // ✅ ok 290 | setState({ x: -11, y: 11 }); // ✅ ok 291 | ``` 292 | 293 | [codesandbox](https://codesandbox.io/s/setstate-1-u4fq0) 294 | 295 | `setState` also can receive a function which will be called with the current state and it is supposed to return the change object 296 | 297 | ```javascript 298 | import state from 'state-local'; 299 | 300 | const [getState, setState] = state.create({ x:0, y: 0 }); 301 | 302 | setState(state => ({ x: state.x + 2 })); // ✅ ok 303 | setState(state => ({ x: state.x - 11, y: state.y + 11 })); // ✅ ok 304 | 305 | setState(state => ({ z: 'some value' })); // ❌ error - it seams you want to change a field in the state which is not specified in the "initial" state 306 | ``` 307 | 308 | [codesandbox](https://codesandbox.io/s/smoosh-wildflower-nv9dg) 309 | 310 | ## License 311 | 312 | [MIT](./LICENSE) 313 | -------------------------------------------------------------------------------- /src/spec.js: -------------------------------------------------------------------------------- 1 | import state from '.'; 2 | import { errorMessages } from './validators'; 3 | 4 | // there are three major parts that should be tested 5 | 6 | // 1) the `createState` function itself 7 | // `createState` is a function with two parameters: 8 | // the first one (required) is the initial state and it should be a non-empty object 9 | // the second one (optional) is the handler, which can be function or object 10 | // if the handler is a function then it should be called immediately after every state update 11 | // if the handler is an object then the keys of that object should be a subset of the state 12 | // and all the values of that object should be functions, plus they should be called immediately 13 | // after every update of the corresponding field in the state 14 | 15 | describe('createState', () => { 16 | // test 1 - check if `createState` throws an error when we don't pass an argument 17 | // check error message 18 | test('should throw an error when no arguments are passed', () => { 19 | function callCreateStateWithoutArguments() { 20 | state.create(); 21 | } 22 | 23 | expect(callCreateStateWithoutArguments).toThrow(errorMessages.initialIsRequired); 24 | }); 25 | 26 | // test 2 - check if `createState` throws an error when the first argument is not an object 27 | // check the error message 28 | test('should throw an error when the first argument is not an object', () => { 29 | function callCreateStateWithNonObjectFirstArgument(initial) { 30 | return () => state.create(initial); 31 | } 32 | 33 | expect(callCreateStateWithNonObjectFirstArgument('string')).toThrow(errorMessages.initialType); 34 | expect(callCreateStateWithNonObjectFirstArgument([1, 2, 3])).toThrow(errorMessages.initialType); 35 | expect(callCreateStateWithNonObjectFirstArgument(x => x + 1)).toThrow(errorMessages.initialType); 36 | }); 37 | 38 | // test 3 - check if `createState` throws an error when the first argument is an empty object 39 | // check the error message 40 | test('should throw an error when the first argument is an empty object', () => { 41 | function callCreateStateWithEmptyObjectFirstArgument() { 42 | state.create({}); 43 | } 44 | 45 | expect(callCreateStateWithEmptyObjectFirstArgument).toThrow(errorMessages.initialContent); 46 | }); 47 | 48 | // test 4 - check if `createState` returns a pair of functions when it receives a non-empty object 49 | test('should return a pair of functions when receives a non-empty object', () => { 50 | const result = state.create({ x: 1, y: 2 }); 51 | 52 | expect(result.length).toEqual(2); 53 | expect(result[0]).toBeInstanceOf(Function); 54 | expect(result[1]).toBeInstanceOf(Function); 55 | }); 56 | 57 | // test 5 - check if `createState` (with valid first argument) throws an error when the second 58 | // arguemnt is neither function nor object 59 | // check the error message 60 | test('should throw an error when the second argument is neither function nor object', () => { 61 | function callCreateStateWithWrongSecondArgument(handler) { 62 | return () => state.create({ x: 1, y: 2 }, handler); 63 | } 64 | 65 | expect(callCreateStateWithWrongSecondArgument('string')).toThrow(errorMessages.handlerType); 66 | expect(callCreateStateWithWrongSecondArgument([1, 2, 3])).toThrow(errorMessages.handlerType); 67 | }); 68 | 69 | // test 6 - check if `createState` (with valid first argument) throws an error when the second 70 | // argument is an object but its values are not functions 71 | test('should throw an error when the second argument is object, but its values are not functions', () => { 72 | function callCreateStateWithWrongSecondArgument() { 73 | state.create({ x: 1, y: 2 }, { 74 | x: () => {}, 75 | y: 'not a function', 76 | }); 77 | } 78 | 79 | expect(callCreateStateWithWrongSecondArgument).toThrow(errorMessages.handlersType); 80 | }); 81 | }); 82 | 83 | // 2) the `getState` 84 | // `getState` is a function with one optional parameter - selector 85 | // `getState` call without argument will return the current state 86 | // `getState` call with an argument (selector) will return the subset of the current state 87 | // the selector should be a function, which is supposed to receive the current state and return its subset 88 | 89 | describe('getState', () => { 90 | // test 1 - check if `getState` is a function 91 | test('should be a function', () => { 92 | const [setState, getState] = state.create({ isLoading: true, errorMessages: 'something went wrong' }); 93 | 94 | expect(getState).toBeInstanceOf(Function); 95 | }); 96 | 97 | // test 2 - check if `getState` (without arguments) returns the current state 98 | test('should return the current state when the selector is missing', () => { 99 | const initialState = { isRendered: false, data: null }; 100 | const [getState, setState] = state.create(initialState); 101 | 102 | const currentState = getState(); 103 | 104 | // according to the docs of jest - toEqual recursively checks every field of an object or array 105 | expect(currentState).toEqual(initialState); 106 | }); 107 | 108 | // test 3 - check if `getState` throws an error when 109 | // the selector (the first argument) is not a function 110 | // check the error message 111 | test('should throw an error when the selector is not a function', () => { 112 | const [getState, setState] = state.create({ value: 0 }); 113 | 114 | function callGetStateWithNonFunctionSelector(selector) { 115 | return () => getState(selector); 116 | } 117 | 118 | expect(callGetStateWithNonFunctionSelector(null)).toThrow(errorMessages.selectorType); 119 | expect(callGetStateWithNonFunctionSelector('string')).toThrow(errorMessages.selectorType); 120 | expect(callGetStateWithNonFunctionSelector({})).toThrow(errorMessages.selectorType); 121 | expect(callGetStateWithNonFunctionSelector(NaN)).toThrow(errorMessages.selectorType); 122 | expect(callGetStateWithNonFunctionSelector(0)).toThrow(errorMessages.selectorType); 123 | expect(callGetStateWithNonFunctionSelector('')).toThrow(errorMessages.selectorType); 124 | expect(callGetStateWithNonFunctionSelector(47)).toThrow(errorMessages.selectorType); 125 | }); 126 | 127 | // test 4 - check if `getState` with the selector returns a subset of the current state as expected 128 | test('should return a subset of the current state if the selector is provided', () => { 129 | const [getState, setState] = state.create({ x: 0, y: 0, color: '#fff', isActive: false }); 130 | 131 | const currentState = getState(({ x, y }) => ({ x, y })); 132 | 133 | expect(currentState).toEqual({ x: 0, y: 0 }); 134 | expect(currentState.color).toBeUndefined(); 135 | expect(currentState.isActive).toBeUndefined(); 136 | }); 137 | }); 138 | 139 | // 3) `setState` 140 | // `setState` is a function with one required parameter 141 | // which is either an object - presumably the change of the state 142 | // or a function which is supposed to be called with the current state and return a change object 143 | // In both cases the change object should contain only those fields which exist in the initial state 144 | // After `setState` the new state should be accessable from `getState` as expected 145 | // Also, it must be mentioned that if there is a handler or there are handlers, they should be called after each 146 | // or an appropriate change of state correspondingly 147 | 148 | describe('setState', () => { 149 | // test 1 - check if `setState` is a function 150 | test('should be a function', () => { 151 | const [getState, setState] = state.create({ resolve: null, reject: null }); 152 | 153 | expect(setState).toBeInstanceOf(Function); 154 | }); 155 | 156 | // test 2 - check if `setState` throws an error when the argument is neither object nor function 157 | // check the error message 158 | test('should throw an error when the argument is neither object nor function', () => { 159 | const [getState, setState] = state.create({ config: {} }); 160 | 161 | function callSetStateWithWrongArgument(change) { 162 | return () => setState(change); 163 | } 164 | 165 | expect(callSetStateWithWrongArgument(null)).toThrow(errorMessages.changeType); 166 | expect(callSetStateWithWrongArgument('string')).toThrow(errorMessages.changeType); 167 | expect(callSetStateWithWrongArgument(NaN)).toThrow(errorMessages.changeType); 168 | expect(callSetStateWithWrongArgument(0)).toThrow(errorMessages.changeType); 169 | expect(callSetStateWithWrongArgument('')).toThrow(errorMessages.changeType); 170 | expect(callSetStateWithWrongArgument(47)).toThrow(errorMessages.changeType); 171 | }); 172 | 173 | // test 3 - check if `setState` throws an error when the change object contains a key which is not from the initial state 174 | // check the error message 175 | test('should throw an error when the change object is not compatible with initial state', () => { 176 | const [getState, setState] = state.create({ x: 1, y: 2 }); 177 | 178 | function callSetStateWithWrongChangeObject(change) { 179 | return () => setState(change); 180 | } 181 | 182 | expect(callSetStateWithWrongChangeObject({ z: 4 })).toThrow(errorMessages.changeField); 183 | expect(callSetStateWithWrongChangeObject(state => ({ z: 5 }))).toThrow(errorMessages.changeField); 184 | }); 185 | 186 | // test 5 - check if `setState` call updates the current state as expected 187 | test('should update current state', () => { 188 | const [getState, setState] = state.create({ x: 0, y: 1 }); 189 | 190 | setState({ x: 5 }); 191 | 192 | const currentState = getState(); 193 | 194 | expect(currentState).toEqual({ x: 5, y: 1 }); 195 | }); 196 | 197 | // test 6 - check if `setState` call invokes the handler with the latest update 198 | test('should invoke handler with the latest update', () => { 199 | const handler = jest.fn(); 200 | const [getState, setState] = state.create({ x: 0, y: 1 }, handler); 201 | 202 | setState({ x: 2 }); 203 | setState({ x: 3 }); 204 | setState(state => ({ y: 5 })); 205 | 206 | expect(handler).toHaveBeenNthCalledWith(1, { x: 2, y: 1 }); 207 | expect(handler).toHaveBeenNthCalledWith(2, { x: 3, y: 1 }); 208 | expect(handler).toHaveBeenNthCalledWith(3, { x: 3, y: 5 }); 209 | }); 210 | 211 | // test 7 - check if `setState` call invokes handlers with the latest update of the corresponding field 212 | test('should invoke handlers with the latest update of the corresponding field', () => { 213 | const handlers = { 214 | uuid: jest.fn(), 215 | config: jest.fn(), 216 | value: jest.fn(), 217 | }; 218 | 219 | const [getState, setState] = state.create({ 220 | uuid: '%6^f', 221 | config: { theme: 'dark' }, 222 | value: 11, 223 | smth: 'something', 224 | }, handlers); 225 | 226 | setState({ uuid: 'j**.' }); 227 | setState({ config: { theme: 'light' } }); 228 | setState(state => ({ value: 17 })); 229 | 230 | expect(handlers.uuid).toHaveBeenNthCalledWith(1, 'j**.'); 231 | expect(handlers.config).toHaveBeenNthCalledWith(1, { theme: 'light' }); 232 | expect(handlers.value).toHaveBeenNthCalledWith(1, 17); 233 | }); 234 | }); 235 | --------------------------------------------------------------------------------