├── .babelrc ├── .eslintrc.js ├── .gitignore ├── .prettierignore ├── .travis.yml ├── README.md ├── index.d.ts ├── jest.config.js ├── package-lock.json ├── package.json ├── prettier.config.js └── src ├── __tests__ ├── index.test.js └── redux.js └── index.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | ["env", { 4 | "targets": { 5 | "browsers": [ 6 | ">0.25%", 7 | "not ie 11", 8 | "not op_mini all" 9 | ] 10 | } 11 | }] 12 | ] 13 | } -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: ['prettier'], 3 | extends: ['airbnb-base', 'prettier'], 4 | env: { 5 | jest: true, 6 | }, 7 | rules: { 8 | 'prettier/prettier': 'error', 9 | 'no-prototype-builtins': 'off', 10 | }, 11 | }; 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | node_modules 3 | lib 4 | package 5 | coverage 6 | *.log 7 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | lib 2 | coverage 3 | .babelrc 4 | package-lock.json 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "10" 4 | script: 5 | - npm run check 6 | cache: 7 | directories: 8 | - "node_modules" 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # redux-subscriber 2 | 3 | [![Build Status](https://travis-ci.org/ivantsov/redux-subscriber.svg?branch=master)](https://travis-ci.org/ivantsov/redux-subscriber) 4 | [![codecov](https://codecov.io/gh/ivantsov/redux-subscriber/branch/master/graph/badge.svg)](https://codecov.io/gh/ivantsov/redux-subscriber) 5 | [![npm version](https://badge.fury.io/js/redux-subscriber.svg)](https://badge.fury.io/js/redux-subscriber) 6 | 7 | This package allows you to subscribe to changes in any part of [Redux](https://github.com/reactjs/redux) state. 8 | 9 | ## Installation 10 | 11 | `npm install redux-subscriber --save` 12 | 13 | ## Usage 14 | 15 | _store.js_ 16 | 17 | ```js 18 | import {createStore} from 'redux'; 19 | import initSubscriber from 'redux-subscriber'; 20 | 21 | const store = createStore(...); 22 | 23 | // "initSubscriber" returns "subscribe" function, so you can use it 24 | const subscribe = initSubscriber(store); 25 | ``` 26 | 27 | _somewhere-else.js_ 28 | 29 | ```js 30 | // or you can just import "subscribe" function from the package 31 | import {subscribe} from 'redux-subscriber'; 32 | 33 | const unsubscribe = subscribe('user.messages.count', state => { 34 | // do something 35 | }); 36 | 37 | // if you want to stop listening to changes 38 | unsubscribe(); 39 | ``` 40 | 41 | ## Examples 42 | 43 | - https://github.com/ivantsov/yandex-mail-notifier-chrome - real app that uses `redux-subscriber` 44 | 45 | ## API 46 | 47 | #### `initSubscriber(store)` (_default export_) - initialize `redux-subscriber`, so after that you can use `subscribe` method. 48 | 49 | #### Options 50 | 51 | - `store` - instance of Redux store. 52 | 53 | Returns `subscribe` function. 54 | 55 | #### `subscribe(key, callbackFunction)` - subscribe `callbackFunction` to changes. 56 | 57 | #### Options 58 | 59 | - `key` - string which specified the part of state (e.g. `user.message.count`) to listen to. 60 | - `callbackFunction` - function which will be called when the part of state has changed. New state is passed as a parameter. 61 | 62 | Returns `unsubscribe` function which can be called to unsubscribe from changes. 63 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'redux-subscriber' { 2 | export default function(store: any): () => void; 3 | 4 | export function subscribe(key: string, cb: (state: any) => void): () => void; 5 | } 6 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | clearMocks: true, 3 | resetModules: true, 4 | testMatch: ['**/__tests__/**/*.test.js'], 5 | }; 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "redux-subscriber", 3 | "version": "1.1.0", 4 | "description": "Subscribe to changes in any part of redux state", 5 | "main": "lib/index.js", 6 | "repository": { 7 | "type": "git", 8 | "url": "git+https://github.com/ivantsov/redux-subscriber.git" 9 | }, 10 | "keywords": [ 11 | "redux", 12 | "store", 13 | "state", 14 | "subscribe", 15 | "subscriber", 16 | "watch", 17 | "watcher", 18 | "observe", 19 | "observer", 20 | "react" 21 | ], 22 | "author": "Alexander Ivantsov (https://github.com/ivantsov)", 23 | "license": "MIT", 24 | "bugs": { 25 | "url": "https://github.com/ivantsov/redux-subscriber/issues" 26 | }, 27 | "homepage": "https://github.com/ivantsov/redux-subscriber#readme", 28 | "files": [ 29 | "lib", 30 | "index.d.ts" 31 | ], 32 | "scripts": { 33 | "prepack": "npm-run-all check build", 34 | "check": "npm-run-all -p clean format:check lint test", 35 | "build": "babel src -d lib --ignore \"**/__tests__\"", 36 | "clean": "rimraf lib", 37 | "test": "jest", 38 | "test:watch": "npm test -- --watch", 39 | "test:coverage": "npm test -- --coverage && codecov", 40 | "lint": "eslint --ignore-path .gitignore .", 41 | "lint:fix": "npm run lint -- --fix", 42 | "format": "prettier --write \"**/*.{js,json,ts,md}\"", 43 | "format:check": "prettier --list-different \"**/*.{js,json,ts,md}\"" 44 | }, 45 | "dependencies": { 46 | "object-path": "^0.11.3" 47 | }, 48 | "devDependencies": { 49 | "babel-cli": "^6.26.0", 50 | "babel-preset-env": "^1.7.0", 51 | "codecov": "^3.0.2", 52 | "eslint": "^5.0.0", 53 | "eslint-config-airbnb-base": "^13.0.0", 54 | "eslint-config-prettier": "^2.9.0", 55 | "eslint-plugin-import": "^2.2.0", 56 | "eslint-plugin-prettier": "^2.6.1", 57 | "jest": "^23.1.0", 58 | "npm-run-all": "^4.1.3", 59 | "prettier": "^1.13.5", 60 | "redux": "^4.0.0", 61 | "rimraf": "^2.6.2" 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | singleQuote: true, 3 | trailingComma: 'all', 4 | bracketSpacing: false, 5 | }; 6 | -------------------------------------------------------------------------------- /src/__tests__/index.test.js: -------------------------------------------------------------------------------- 1 | import {createStore} from 'redux'; 2 | import initSubscriber, {subscribe} from '../index'; 3 | import {reducer, actions} from './redux'; 4 | 5 | describe('redux-subscriber', () => { 6 | describe('subscribe', () => { 7 | function testCase(subscribersKeys) { 8 | const store = createStore(reducer); 9 | 10 | initSubscriber(store); 11 | const subscribers = subscribersKeys.map(key => { 12 | const subscriber = jest.fn(); 13 | subscribe(key, subscriber); 14 | return subscriber; 15 | }); 16 | 17 | return {store, subscribers}; 18 | } 19 | 20 | it('state has not changed at all', () => { 21 | const {store, subscribers} = testCase(['key1.key11', 'key1.key11']); 22 | 23 | store.dispatch({type: 'FAKE_ACTION_TYPE'}); 24 | 25 | subscribers.forEach(subscriber => expect(subscriber).not.toBeCalled()); 26 | }); 27 | 28 | it('not subscribed part of state has changed', () => { 29 | const {store, subscribers} = testCase(['key1.key11', 'key1.key11']); 30 | 31 | store.dispatch( 32 | actions.key2({ 33 | key21: 'newValue', 34 | key22: 'newValue', 35 | }), 36 | ); 37 | 38 | subscribers.forEach(subscriber => expect(subscriber).not.toBeCalled()); 39 | }); 40 | 41 | it('part of state has changed & only expected subscribers are called', () => { 42 | const {store, subscribers} = testCase([ 43 | 'key1.key11', 44 | 'key1.key11', 45 | 'key2.key21', 46 | ]); 47 | 48 | store.dispatch(actions.key1({key11: 'newValue'})); 49 | 50 | const newState = store.getState(); 51 | expect(subscribers[0]).toBeCalledWith(newState); 52 | expect(subscribers[1]).toBeCalledWith(newState); 53 | expect(subscribers[2]).not.toBeCalled(); 54 | }); 55 | 56 | it('subscribed key does not exist after 1st dispatch', () => { 57 | const {store, subscribers} = testCase(['key1.key11', 'key1.key11']); 58 | 59 | store.dispatch(actions.key1('newValue1')); 60 | 61 | const newState = store.getState(); 62 | subscribers.forEach(subscriber => 63 | expect(subscriber).toBeCalledWith(newState), 64 | ); 65 | 66 | store.dispatch(actions.key1('newValue2')); 67 | subscribers.forEach(subscriber => expect(subscriber).toBeCalledTimes(1)); 68 | }); 69 | 70 | it('dispatch action inside subscriber callback, which changes another part of state', () => { 71 | const store = createStore(reducer); 72 | let newState1; 73 | let newState2; 74 | 75 | initSubscriber(store); 76 | 77 | let toggle = true; 78 | const subscriber = jest.fn(() => { 79 | if (toggle) { 80 | toggle = !toggle; 81 | 82 | newState1 = store.getState(); 83 | store.dispatch(actions.key2({key21: 'newValue'})); 84 | } else { 85 | newState2 = store.getState(); 86 | } 87 | }); 88 | 89 | subscribe('key1.key11', subscriber); 90 | 91 | store.dispatch(actions.key1({key11: 'newValue'})); 92 | 93 | expect(subscriber).toBeCalledTimes(2); 94 | expect(subscriber).nthCalledWith(1, newState1); 95 | expect(subscriber).nthCalledWith(2, newState2); 96 | }); 97 | }); 98 | 99 | describe('unsubscribe', () => { 100 | function testCase() { 101 | const store = createStore(reducer); 102 | 103 | initSubscriber(store); 104 | const subscribers = [jest.fn(), jest.fn()]; 105 | const unsubscribers = subscribers.map(subscriber => 106 | subscribe('key1.key11', subscriber), 107 | ); 108 | 109 | return { 110 | store, 111 | subscribers, 112 | unsubscribers, 113 | }; 114 | } 115 | 116 | it('2 subscribers & 1 unsubscribe', () => { 117 | const {store, subscribers, unsubscribers} = testCase(); 118 | 119 | unsubscribers[0](); 120 | 121 | store.dispatch(actions.key1({key11: 'newNewValue'})); 122 | 123 | expect(subscribers[0]).not.toBeCalled(); 124 | expect(subscribers[1]).toBeCalledWith(store.getState()); 125 | }); 126 | 127 | it('2 subscribers, 2 unsubscribe', () => { 128 | const {store, subscribers, unsubscribers} = testCase(); 129 | 130 | unsubscribers.forEach(fn => fn()); 131 | 132 | store.dispatch(actions.key1({key11: 'newNewValue'})); 133 | 134 | subscribers.forEach(subscriber => expect(subscriber).not.toBeCalled()); 135 | }); 136 | }); 137 | }); 138 | -------------------------------------------------------------------------------- /src/__tests__/redux.js: -------------------------------------------------------------------------------- 1 | const actionTypes = { 2 | KEY_1: 'KEY_1', 3 | KEY_2: 'KEY_2', 4 | }; 5 | 6 | const defaultState = { 7 | key1: { 8 | key11: 'key11', 9 | key12: 'key12', 10 | }, 11 | key2: { 12 | key21: 'key21', 13 | key22: 'key22', 14 | }, 15 | }; 16 | 17 | export function reducer(state = defaultState, action) { 18 | switch (action.type) { 19 | case actionTypes.KEY_1: 20 | return { 21 | ...state, 22 | key1: action.data, 23 | }; 24 | case actionTypes.KEY_2: 25 | return { 26 | ...state, 27 | key2: action.data, 28 | }; 29 | default: 30 | return state; 31 | } 32 | } 33 | 34 | export const actions = { 35 | key1(data) { 36 | return {type: actionTypes.KEY_1, data}; 37 | }, 38 | key2(data) { 39 | return {type: actionTypes.KEY_2, data}; 40 | }, 41 | }; 42 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import {get} from 'object-path'; 2 | 3 | const subscribers = {}; 4 | 5 | export function subscribe(key, cb) { 6 | if (subscribers.hasOwnProperty(key)) { 7 | subscribers[key].push(cb); 8 | } else { 9 | subscribers[key] = [cb]; 10 | } 11 | 12 | // return "unsubscribe" function 13 | return function() { 14 | subscribers[key] = subscribers[key].filter(s => s !== cb); 15 | }; 16 | } 17 | 18 | export default function(store) { 19 | let prevState = store.getState(); 20 | 21 | store.subscribe(() => { 22 | const newState = store.getState(); 23 | 24 | Object.keys(subscribers).forEach(key => { 25 | if (get(prevState, key) !== get(newState, key)) { 26 | subscribers[key].forEach(cb => cb(newState)); 27 | } 28 | }); 29 | 30 | prevState = newState; 31 | }); 32 | 33 | return subscribe; 34 | } 35 | --------------------------------------------------------------------------------