├── .babelrc ├── .circleci └── config.yml ├── .dependabot └── config.yml ├── .dockerignore ├── .github └── ISSUE_TEMPLATE.md ├── .gitignore ├── CHANGELOG.md ├── Dockerfile ├── README.md ├── __tests__ ├── __snapshots__ │ └── reusable.test.js.snap ├── reusable.test.js └── shallow-compare.js ├── docker-compose.yml ├── docs └── basic-usage.md ├── examples ├── api │ ├── .babelrc │ ├── index.html │ ├── package-lock.json │ ├── package.json │ └── src │ │ ├── Starwars.js │ │ ├── components │ │ ├── Loader.js │ │ └── Table.js │ │ ├── index.js │ │ ├── stores │ │ ├── api.store.js │ │ ├── characters.store.js │ │ ├── loadingState.store.js │ │ └── planets.store.js │ │ └── style.css ├── basic-18 │ ├── index.html │ ├── package-lock.json │ ├── package.json │ └── src │ │ ├── Footer.js │ │ ├── Header.js │ │ ├── index.js │ │ └── stores │ │ └── counter.store.js ├── basic │ ├── index.html │ ├── package-lock.json │ ├── package.json │ └── src │ │ ├── Footer.js │ │ ├── Header.js │ │ ├── index.js │ │ └── stores │ │ └── counter.store.js ├── lazy │ ├── .env │ ├── .gitignore │ ├── README.md │ ├── package-lock.json │ ├── package.json │ ├── public │ │ ├── favicon.ico │ │ ├── index.html │ │ ├── logo192.png │ │ ├── logo512.png │ │ ├── manifest.json │ │ └── robots.txt │ ├── src │ │ ├── App.css │ │ ├── App.js │ │ ├── Home.js │ │ ├── Users.js │ │ ├── index.css │ │ ├── index.js │ │ ├── logo.svg │ │ └── serviceWorker.js │ └── yarn.lock ├── todomvc-parcel │ ├── .babelrc │ ├── index.html │ ├── package-lock.json │ ├── package.json │ └── src │ │ ├── Footer.js │ │ ├── Header.js │ │ ├── Todo.js │ │ ├── Todos.js │ │ ├── index.js │ │ ├── stores │ │ ├── filter.store.js │ │ ├── todos-with-reducer.store.js │ │ ├── todos.store.js │ │ ├── todosSelectors.stores.js │ │ └── usePersistedState.js │ │ └── style.css ├── todomvc-redux │ ├── .env │ ├── .gitignore │ ├── README.md │ ├── config-overrides.js │ ├── package-lock.json │ ├── package.json │ ├── public │ │ ├── favicon.ico │ │ ├── index.html │ │ └── manifest.json │ └── src │ │ ├── components │ │ ├── Footer.js │ │ ├── Header.js │ │ ├── Todo.js │ │ └── Todos.js │ │ ├── index.css │ │ ├── index.js │ │ ├── style.css │ │ ├── todosConstants.js │ │ ├── todosHooks.js │ │ ├── todosReducer.js │ │ └── todosSelectors.js └── todomvc │ ├── .env │ ├── .gitignore │ ├── README.md │ ├── config-overrides.js │ ├── package-lock.json │ ├── package.json │ ├── public │ ├── favicon.ico │ ├── index.html │ └── manifest.json │ ├── src │ ├── Footer.js │ ├── Header.js │ ├── Todo.js │ ├── Todos.js │ ├── index.css │ ├── index.js │ ├── serviceWorker.js │ ├── stores │ │ ├── filter.store.js │ │ ├── todos-with-reducer.store.js │ │ ├── todos.store.js │ │ ├── todosSelectors.stores.js │ │ └── usePersistedState.js │ └── style.css │ └── yarn.lock ├── jest.config.js ├── macro.js ├── package-lock.json ├── package.json ├── sandbox ├── .babelrc ├── index.html ├── package-lock.json ├── package.json ├── src │ └── index.tsx └── tsconfig.json ├── src ├── index.ts ├── react-reusable.tsx ├── reusable.ts ├── reuseable.macro.js └── shallow-equal.ts ├── tsconfig.json └── website ├── README.md ├── blog ├── 2019-04-05-docs-website.md └── 2019-22-05-refactor-to-regular-hooks.md ├── core └── Footer.js ├── i18n └── en.json ├── package-lock.json ├── package.json ├── pages └── en │ ├── index.js │ └── users.js ├── sidebars.json ├── siteConfig.js └── static ├── css └── custom.css └── img ├── docusaurus.svg ├── favicon.png ├── favicon └── favicon.ico ├── oss_logo.png ├── reusable.jpg └── reusable.png /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-env", "@babel/preset-react"], 3 | "plugins": ["@babel/plugin-proposal-class-properties"] 4 | } 5 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | # Javascript Node CircleCI 2.0 configuration file 2 | # 3 | # Check https://circleci.com/docs/2.0/language-javascript/ for more details 4 | # 5 | version: 2 6 | jobs: 7 | build: 8 | docker: 9 | # specify the version you desire here 10 | - image: circleci/node:11.11 11 | 12 | # Specify service dependencies here if necessary 13 | # CircleCI maintains a library of pre-built images 14 | # documented at https://circleci.com/docs/2.0/circleci-images/ 15 | # - image: circleci/mongo:3.4.4 16 | 17 | working_directory: ~/repo 18 | 19 | steps: 20 | - checkout 21 | 22 | # Download and cache dependencies 23 | - restore_cache: 24 | keys: 25 | - v1-dependencies-{{ checksum "package.json" }} 26 | # fallback to using the latest cache if no exact match is found 27 | - v1-dependencies- 28 | 29 | - run: npm install 30 | 31 | - save_cache: 32 | paths: 33 | - node_modules 34 | key: v1-dependencies-{{ checksum "package.json" }} 35 | 36 | # run tests! 37 | - run: npm run test 38 | -------------------------------------------------------------------------------- /.dependabot/config.yml: -------------------------------------------------------------------------------- 1 | version: 1 2 | update_configs: 3 | - package_manager: "javascript" 4 | directory: "/" 5 | update_schedule: "live" 6 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | */node_modules 2 | *.log 3 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | PLEASE READ CAREFULLY! 2 | 3 | # Reproduce 4 | If reporting a bug, or requesting help, please reproduce here using the correct version: 5 | https://codesandbox.io/s/github/reusablejs/reusable/tree/master/examples/basic?fontsize=14&module=%2Fsrc%2Findex.js 6 | 7 | # Additional Info 8 | - paste your package.json 9 | - which browser 10 | - paste your code 11 | 12 | # Search Issues First 13 | Please look for an answer in the closed and open issues before submitting a new one. 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | ### macOS template 3 | # General 4 | .token 5 | .DS_Store 6 | .AppleDouble 7 | .LSOverride 8 | 9 | # Icon must end with two \r 10 | Icon 11 | 12 | # Thumbnails 13 | ._* 14 | 15 | # Files that might appear in the root of a volume 16 | .DocumentRevisions-V100 17 | .fseventsd 18 | .Spotlight-V100 19 | .TemporaryItems 20 | .Trashes 21 | .VolumeIcon.icns 22 | .com.apple.timemachine.donotpresent 23 | 24 | # Directories potentially created on remote AFP share 25 | .AppleDB 26 | .AppleDesktop 27 | Network Trash Folder 28 | Temporary Items 29 | .apdisk 30 | ### JetBrains template 31 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm 32 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 33 | 34 | # User-specific stuff 35 | .idea/ 36 | .idea/**/workspace.xml 37 | .idea/**/tasks.xml 38 | .idea/**/dictionaries 39 | .idea/**/shelf 40 | 41 | # Sensitive or high-churn files 42 | .idea/**/dataSources/ 43 | .idea/**/dataSources.ids 44 | .idea/**/dataSources.local.xml 45 | .idea/**/sqlDataSources.xml 46 | .idea/**/dynamic.xml 47 | .idea/**/uiDesigner.xml 48 | .idea/**/dbnavigator.xml 49 | 50 | # Gradle 51 | .idea/**/gradle.xml 52 | .idea/**/libraries 53 | 54 | # CMake 55 | cmake-build-debug/ 56 | cmake-build-release/ 57 | 58 | # Mongo Explorer plugin 59 | .idea/**/mongoSettings.xml 60 | 61 | # File-based project format 62 | *.iws 63 | 64 | # IntelliJ 65 | out/ 66 | 67 | # mpeltonen/sbt-idea plugin 68 | .idea_modules/ 69 | 70 | # JIRA plugin 71 | atlassian-ide-plugin.xml 72 | 73 | # Cursive Clojure plugin 74 | .idea/replstate.xml 75 | 76 | # Crashlytics plugin (for Android Studio and IntelliJ) 77 | com_crashlytics_export_strings.xml 78 | crashlytics.properties 79 | crashlytics-build.properties 80 | fabric.properties 81 | 82 | # Editor-based Rest Client 83 | .idea/httpRequests 84 | ### VisualStudioCode template 85 | .vscode/* 86 | !.vscode/settings.json 87 | !.vscode/tasks.json 88 | !.vscode/launch.json 89 | !.vscode/extensions.json 90 | ### Node template 91 | # Logs 92 | logs 93 | *.log 94 | npm-debug.log* 95 | yarn-debug.log* 96 | yarn-error.log* 97 | 98 | # Runtime data 99 | pids 100 | *.pid 101 | *.seed 102 | *.pid.lock 103 | 104 | # Directory for instrumented libs generated by jscoverage/JSCover 105 | lib-cov 106 | 107 | # Coverage directory used by tools like istanbul 108 | coverage 109 | 110 | # nyc test coverage 111 | .nyc_output 112 | 113 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 114 | .grunt 115 | 116 | # Bower dependency directory (https://bower.io/) 117 | bower_components 118 | 119 | # node-waf configuration 120 | .lock-wscript 121 | 122 | # Compiled binary addons (https://nodejs.org/api/addons.html) 123 | build/Release 124 | 125 | # Dependency directories 126 | node_modules/ 127 | jspm_packages/ 128 | 129 | # TypeScript v1 declaration files 130 | typings/ 131 | 132 | # Optional npm cache directory 133 | .npm 134 | 135 | # Optional eslint cache 136 | .eslintcache 137 | 138 | # Optional REPL history 139 | .node_repl_history 140 | 141 | # Output of 'npm pack' 142 | *.tgz 143 | 144 | # Yarn Integrity file 145 | .yarn-integrity 146 | 147 | # next.js build output 148 | .next 149 | 150 | dist/ 151 | exampleDist/ 152 | .rts2_cache_cjs/ 153 | .rts2_cache_es/ 154 | .rts2_cache_umd/ 155 | .cache/ 156 | 157 | website/build -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2 | # 1.0.1 (March 3rd, 2020) 3 | * Added description, homepage, license, keywords to package json 4 | 5 | 6 | # 1.0.0 (Feb 17th, 2020) 7 | * Caching previously created components to avoid re-rendering them and losing store state 8 | 9 | 10 | # 1.0.0-alpha.13 (June 14th, 2019) 11 | * Fixed examples, renamed from units to stores 12 | * Added support for debugName for Macro 13 | 14 | 15 | # 1.0.0-alpha.12 (June 11th, 2019) 16 | * Version announced at ReactNext 17 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:8.11.4 2 | 3 | WORKDIR /app/website 4 | 5 | EXPOSE 3000 35729 6 | COPY ./docs /app/docs 7 | COPY ./website /app/website 8 | RUN yarn install 9 | 10 | CMD ["yarn", "start"] 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://circleci.com/gh/reusablejs/reusable.svg?style=svg)](https://circleci.com/gh/reusablejs/reusable) 2 | [![npm version](https://badge.fury.io/js/reusable.svg)](https://badge.fury.io/js/reusable) 3 | 4 | # Reusable - state management with hooks 5 | 6 | 7 | - Use hooks to manage the store 8 | - One paradigm for both local and shared state, and an easier transition between the two 9 | - Use a single context provider and avoid nesting dozens of providers 10 | - Allow direct subscriptions with selectors for better re-render control 11 | 12 | 13 | # How to use 14 | Pass a custom hook to `createStore`: 15 | 16 | ```javascript 17 | const useCounter = createStore(() => { 18 | const [counter, setCounter] = useState(0); 19 | useEffect(...) 20 | const isOdd = useMemo(...); 21 | 22 | return { 23 | counter, 24 | isOdd, 25 | increment: () => setCounter(prev => prev + 1) 26 | decrement: () => setCounter(prev => prev - 1) 27 | } 28 | }); 29 | ``` 30 | 31 | and get a singleton store, with a hook that subscribes directly to that store: 32 | ```javascript 33 | const MyComponent = () => { 34 | const {counter, increment, decrement} = useCounter(); 35 | } 36 | 37 | const AnotherComponent = () => { 38 | const {counter, increment, decrement} = useCounter(); // same counter 39 | } 40 | ``` 41 | 42 | then wrap your app with a provider: 43 | ```javascript 44 | const App = () => ( 45 | 46 | ... 47 | 48 | ) 49 | ``` 50 | 51 | Note there is no need to provide the store. Stores automatically plug into the top provider 52 | 53 | ## Selectors 54 | For better control over re-renders, use selectors: 55 | 56 | ```javascript 57 | const Comp1 = () => { 58 | const isOdd = useCounter(state => state.isOdd); 59 | } 60 | ``` 61 | Comp1 will only re-render if counter switches between odd and even 62 | 63 | useCounter can take a second parameter that will override the comparison function (defaults to shallow compare): 64 | ```javascript 65 | const Comp1 = () => { 66 | const counter = useCounter(state => state, (prevValue, newValue) => prevValue === newValue); 67 | } 68 | ``` 69 | 70 | 71 | ## Using stores from other stores 72 | Each store can use any other store similar to how components use them: 73 | ```javascript 74 | const useCurrentUser = createStore(() => ...); 75 | const usePosts = createStore(() => ...); 76 | 77 | const useCurrentUserPosts = createStore(() => { 78 | const currentUser = useCurrentUser(); 79 | const posts = usePosts(); 80 | 81 | return ... 82 | }); 83 | ``` 84 | 85 | # Demos 86 | **basic** 87 | 88 | Edit basic 89 | 90 | 91 | **TodoMVC** 92 | 93 | 94 | Edit basic 95 | 96 | 97 | # How does this compare to other state management solutions? 98 | Current state management solutions don't let you manage state using hooks, which causes you to manage local and global state differently, and have a costly transition between the two. 99 | 100 | Reusable solves this by seemingly transforming your custom hooks into global stores. 101 | 102 | ## What about hooks+Context? 103 | Using plain context has some drawbacks and limitations, that led us to write this library: 104 | - Context doesn't support selectors, render bailout, or debouncing 105 | - When managing global state using Context in a large app, you will probably have many small, single-purpose providers. Soon enough you'll find a Provider wrapper hell. 106 | - When you order the providers vertically, you can’t dynamically choose to depend on each other without changing the order, which might break things. 107 | 108 | # How does it work 109 | React hooks must run inside a component, and our store is based on a custom hook. 110 | So in order to have a store that uses a custom hook, we need to create a "host component" for each of our stores. 111 | The `ReusableProvider` component renders a `Stores` component, under which it will render one "host component" per store, which only runs the store's hook, and renders nothing to the DOM. Then, it uses an effect to update all subscribers with the new value. 112 | We use plain pubsub stores under the hood, and do shallowCompare on selector values to decide if we re-render the subscribing component or not. 113 | 114 | Notice that the `ReusableProvider` uses a Context provider at the top-level, but it provides a stable ref that never changes. This means that changing store values, and even dynamically adding stores won't re-render your app. 115 | 116 | ## Feedback / Contributing: 117 | We would love your feedback / suggestions 118 | Please open an issue for discussion before submitting a PR 119 | Thanks 120 | -------------------------------------------------------------------------------- /__tests__/__snapshots__/reusable.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`reusable should fail without a provider 1`] = `"Error: Are you trying to use Reusable without a ReusableProvider?"`; 4 | -------------------------------------------------------------------------------- /__tests__/reusable.test.js: -------------------------------------------------------------------------------- 1 | import {useState} from 'react' 2 | import {renderHook, act} from 'react-hooks-testing-library' 3 | import {getContainer, createContainer, replaceContainer} from '../src/reusable'; 4 | import {createStore, ReusableProvider} from '../src/react-reusable'; 5 | 6 | let container; 7 | let store; 8 | const value = {title: 'Yo'}; 9 | const expectToBeAContainer = (object) => { 10 | expect(object.stores).toBeDefined(); 11 | } 12 | const expectToBeAStore = (object) => { 13 | expect(object.cachedValue).toBeDefined(); 14 | } 15 | describe('public container API', () => { 16 | beforeEach(() => { 17 | container = getContainer(); 18 | }); 19 | it('should allow to get a container', () => { 20 | expectToBeAContainer(container); 21 | }); 22 | it('should allow to create a container', () => { 23 | expectToBeAContainer(createContainer()); 24 | }); 25 | it('should allow to replace the container', () => { 26 | const newContainer = createContainer(); 27 | replaceContainer(newContainer); 28 | expect(getContainer()).toBe(newContainer); 29 | }); 30 | it('should allow to create a store', () => { 31 | const fn = () => {}; 32 | container.createStore(fn); 33 | expectToBeAStore(container.getStore(fn)); 34 | }); 35 | it('should allow to subscribe to creating a store', () => { 36 | const fn = () => {}; 37 | let result; 38 | container.onStoresChanged(() => { 39 | result = container.getStore(fn); 40 | }); 41 | container.createStore(fn); 42 | expectToBeAStore(result); 43 | }); 44 | it('should not allow to get a store without creating it', () => { 45 | const fn = () => {}; 46 | 47 | expect(() => container.getStore(fn)).toThrow(); 48 | }); 49 | it('should not allow to create a store twice', () => { 50 | const fn = () => {}; 51 | container.createStore(fn); 52 | expect(() => container.createStore(fn)).toThrow(); 53 | }); 54 | it('should allow to unsubscribe from subscribing to creating a store', () => { 55 | const callback = jest.fn(); 56 | const fn = () => {}; 57 | const unsubscribe = container.onStoresChanged(callback); 58 | expect(callback.mock.calls.length).toBe(0); 59 | unsubscribe(); 60 | container.createStore(fn); 61 | }); 62 | 63 | }); 64 | describe('public store API', () => { 65 | beforeEach(() => { 66 | const fn = () => value; 67 | container.createStore(fn); 68 | store = container.getStore(fn); 69 | }); 70 | 71 | it('should allow to get the cached value', () => { 72 | expect(store.cachedValue).toBeDefined(); 73 | }); 74 | 75 | it('should allow to calculate the stores value', () => { 76 | expect(store.useValue()).toBe(value); 77 | }); 78 | 79 | it('should cache the store value', () => { 80 | store.useValue(); 81 | expect(store.cachedValue).toBe(value); 82 | }); 83 | it('should allow to subscribe to value change', () => { 84 | const callback = jest.fn(); 85 | store.subscribe(callback); 86 | store.useValue(); 87 | store.notify(); 88 | expect(callback.mock.calls.length).toBe(1); 89 | expect(callback.mock.calls[0][0]).toBe(value); 90 | }); 91 | it('should allow to unsubscribe to value change', () => { 92 | const callback = jest.fn(); 93 | const unsubscribe = store.subscribe(callback); 94 | expect(callback.mock.calls.length).toBe(0); 95 | store.useValue(); 96 | unsubscribe(); 97 | store.notify(); 98 | }); 99 | }); 100 | 101 | describe('reusable', () => { 102 | beforeEach(() => { 103 | container = createContainer(); 104 | replaceContainer(container); 105 | }) 106 | it('should return a store value', () => { 107 | const useSomething = createStore(() => useState(1)); 108 | const { result } = renderHook(useSomething, { 109 | wrapper: ReusableProvider 110 | }); 111 | const [state] = result.current; 112 | 113 | expect(state).toBe(1); 114 | }); 115 | it('should allow to set values on a store', () => { 116 | const useSomething = createStore(() => useState(1)); 117 | const {result} = renderHook(useSomething, { 118 | wrapper: ReusableProvider 119 | }); 120 | const [_, setState] = result.current; 121 | 122 | act(() => setState(3)); 123 | 124 | const [state] = result.current; 125 | 126 | expect(state).toBe(3); 127 | }); 128 | it('should allow to set values twice (stale state bug)', () => { 129 | const useSomething = createStore(() => useState(false)); 130 | const {result} = renderHook(useSomething, { 131 | wrapper: ReusableProvider 132 | }); 133 | 134 | const [_, setState] = result.current; 135 | act(() => setState(prev => !prev)); 136 | let state = result.current[0]; 137 | expect(state).toBe(true); 138 | act(() => setState(prev => !prev)); 139 | state = result.current[0]; 140 | expect(state).toBe(false); 141 | }); 142 | it('should share state between stores', () => { 143 | const useSomething = createStore(() => useState(1)); 144 | const {result} = renderHook(useSomething, { 145 | wrapper: ReusableProvider 146 | }); 147 | const [_, setState] = result.current; 148 | 149 | const {result: result2} = renderHook(useSomething, { 150 | wrapper: ReusableProvider 151 | }); 152 | 153 | act(() => setState(3)); 154 | const [state] = result.current; 155 | expect(state).toBe(3); 156 | 157 | const [state2] = result2.current; 158 | 159 | expect(state2).toBe(3); 160 | }); 161 | it('should fail without a provider', () => { 162 | const useSomething = createStore(() => useState(1)); 163 | const {result} = renderHook(useSomething); 164 | 165 | expect(result.error.toString()).toMatchSnapshot(); 166 | }); 167 | it('should allow to return a function value', () => { 168 | const callback = jest.fn(); 169 | const useSomething = createStore(() => callback); 170 | const {result} = renderHook(useSomething, { 171 | wrapper: ReusableProvider 172 | }); 173 | 174 | expect(result.current).toBe(callback); 175 | expect(callback.mock.calls.length).toBe(0); 176 | }); 177 | }); 178 | describe('selectors', () => { 179 | 180 | it('should allow to use a selector', () => { 181 | const useSomething = createStore(() => useState(1)); 182 | const useSelector = () => useSomething(state => state[0]); 183 | const { result } = renderHook(useSelector, { 184 | wrapper: ReusableProvider 185 | }); 186 | const state = result.current; 187 | 188 | expect(state).toBe(1); 189 | }); 190 | 191 | it('should allow to use a selector that returns a function', () => { 192 | const callback = jest.fn(); 193 | const useSomething = createStore(() => ({ callback })); 194 | const useSelector = () => useSomething(state => state.callback); 195 | const {result} = renderHook(useSelector, { 196 | wrapper: ReusableProvider 197 | }); 198 | 199 | expect(result.current).toBe(callback); 200 | expect(callback.mock.calls.length).toBe(0); 201 | }); 202 | 203 | }); 204 | -------------------------------------------------------------------------------- /__tests__/shallow-compare.js: -------------------------------------------------------------------------------- 1 | import { shallowEqual } from '../src/shallow-equal'; 2 | 3 | describe('util: shallow compare', () => { 4 | 5 | describe('should match by', () => { 6 | test('value', () => { 7 | const a = 'hello'; 8 | const b = 'hello'; 9 | 10 | expect(shallowEqual(a, b)).toEqual(true); 11 | }); 12 | 13 | test('false value', () => { 14 | const a = false; 15 | const b = false; 16 | 17 | expect(shallowEqual(a, b)).toEqual(true); 18 | }); 19 | 20 | test('null value', () => { 21 | const a = null; 22 | const b = null; 23 | 24 | expect(shallowEqual(a, b)).toEqual(true); 25 | }); 26 | 27 | test('reference', () => { 28 | const a = { a: 1, b: 2 }; 29 | const b = a; 30 | 31 | expect(shallowEqual(a, b)).toEqual(true); 32 | }); 33 | 34 | test('shape', () => { 35 | const a = { a: 1, b: 2 }; 36 | const b = { a: 1, b: 2 }; 37 | 38 | expect(shallowEqual(a, b)).toEqual(true); 39 | }); 40 | 41 | test('spread', () => { 42 | const a = { a: { c: 3 }, b: 2 }; 43 | const b = { ...a }; 44 | 45 | expect(shallowEqual(a, b)).toEqual(true); 46 | }); 47 | }); 48 | 49 | describe('should not match by', () => { 50 | test('different value', () => { 51 | const a = 'hello'; 52 | const b = 'hello2'; 53 | 54 | expect(shallowEqual(a, b)).toEqual(false); 55 | }); 56 | 57 | test('different shape: should not match objects', () => { 58 | const a = { a: 1, b: 2 }; 59 | const b = { a: 1 }; 60 | 61 | expect(shallowEqual(a, b)).toEqual(false); 62 | }); 63 | test('null & false should not match', () => { 64 | const a = null; 65 | const b = false; 66 | 67 | expect(shallowEqual(a, b)).toEqual(false); 68 | }); 69 | test('true & false should not match', () => { 70 | const a = false; 71 | const b = true; 72 | 73 | expect(shallowEqual(a, b)).toEqual(false); 74 | }); 75 | 76 | }); 77 | 78 | 79 | }); 80 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | services: 4 | docusaurus: 5 | build: . 6 | ports: 7 | - 3000:3000 8 | - 35729:35729 9 | volumes: 10 | - ./docs:/app/docs 11 | - ./website/blog:/app/website/blog 12 | - ./website/core:/app/website/core 13 | - ./website/i18n:/app/website/i18n 14 | - ./website/pages:/app/website/pages 15 | - ./website/static:/app/website/static 16 | - ./website/sidebars.json:/app/website/sidebars.json 17 | - ./website/siteConfig.js:/app/website/siteConfig.js 18 | working_dir: /app/website 19 | -------------------------------------------------------------------------------- /docs/basic-usage.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: basic-usage 3 | title: Getting Started with Reusable 4 | sidebar_label: Getting Started 5 | --- 6 | 7 | [![Build Status](https://circleci.com/gh/reusablejs/reusable.svg?style=svg)](https://circleci.com/gh/reusablejs/reusable) 8 | [![npm version](https://badge.fury.io/js/reusable.svg)](https://badge.fury.io/js/reusable) 9 | 10 | # Reusable - state management with hooks 11 | 12 | 13 | - Use hooks to manage the store 14 | - One paradigm for both local and shared state 15 | - Easier transition between the two 16 | - Use a single context provider and avoid nesting dozens of providers 17 | - Allow direct subscriptions with selectors for better re-render control 18 | 19 | 20 | # How to use 21 | Pass a custom hook to `createStore`: 22 | 23 | ```javascript 24 | const useCounter = createStore(() => { 25 | const [counter, setCounter] = useState(0); 26 | useEffect(...) 27 | const isOdd = useMemo(...); 28 | 29 | return { 30 | counter, 31 | isOdd, 32 | increment: () => setCounter(prev => prev + 1) 33 | decrement: () => setCounter(prev => prev - 1) 34 | } 35 | }); 36 | ``` 37 | 38 | and get a singleton store, with a hook that subscribes directly to that store: 39 | ```javascript 40 | const MyComponent = () => { 41 | const {counter, increment, decrement} = useCounter(); 42 | } 43 | 44 | const AnotherComponent = () => { 45 | const {counter, increment, decrement} = useCounter(); // same counter 46 | } 47 | ``` 48 | 49 | then wrap your app with a provider: 50 | ```javascript 51 | const App = () => ( 52 | 53 | ... 54 | 55 | ) 56 | ``` 57 | 58 | Note there is no need to provide the store. Stores automatically plug into the top provider 59 | 60 | ## Selectors 61 | For better control over re-renders, use selectors: 62 | 63 | ```javascript 64 | const Comp1 = () => { 65 | const isOdd = useCounter(state => state.isOdd); 66 | } 67 | ``` 68 | Comp1 will only re-render if counter switches between odd and even 69 | 70 | useCounter can take a second parameter that will override the comparison function (defaults to shallow compare): 71 | ```javascript 72 | const Comp1 = () => { 73 | const counter = useCounter(state => state, (prevValue, newValue) => prevValue === newValue); 74 | } 75 | ``` 76 | 77 | 78 | ## Using stores from other stores 79 | Each store can use any other store similar to how components use them: 80 | ```javascript 81 | const useCurrentUser = createStore(() => ...); 82 | const usePosts = createStore(() => ...); 83 | 84 | const useCurrentUserPosts = createStore(() => { 85 | const currentUser = useCurrentUser(); 86 | const posts = usePosts(); 87 | 88 | return ... 89 | }); 90 | ``` 91 | 92 | # Demos 93 | **basic** 94 | 95 | Edit basic 96 | 97 | 98 | **TodoMVC** 99 | 100 | 101 | Edit basic 102 | 103 | 104 | # How does this compare to other state management solutions? 105 | Current state management solutions don't let you manage state using hooks, which causes you to manage local and global state differently, and have a costly transition between the two. 106 | 107 | Reusable solves this by seemingly transforming your custom hooks into global stores. 108 | 109 | ## What about hooks+Context? 110 | Using plain context has some drawbacks and limitations, that led us to write this library: 111 | - Context doesn't support selectors, render bailout, or debouncing 112 | - When managing global state using Context in a large app, you will probably have many small, single-purpose providers. Soon enough you'll find a Provider wrapper hell. 113 | - When you order the providers vertically, you can’t dynamically choose to depend on each other without changing the order, which might break things. 114 | 115 | # How does it work 116 | React hooks must run inside a component, and our store is based on a custom hook. 117 | So in order to have a store that uses a custom hook, we need to create a "host component" for each of our stores. 118 | The `ReusableProvider` component renders a `Stores` component, under which it will render one "host component" per store, which only runs the store's hook, and renders nothing to the DOM. Then, it uses an effect to update all subscribers with the new value. 119 | We use plain pubsub stores under the hood, and do shallowCompare on selector values to decide if we re-render the subscribing component or not. 120 | 121 | Notice that the `ReusableProvider` uses a Context provider at the top-level, but it provides a stable ref that never changes. This means that changing store values, and even dynamically adding stores won't re-render your app. 122 | 123 | ## Feedback / Contributing: 124 | We would love your feedback / suggestions 125 | Please open an issue for discussion before submitting a PR 126 | Thanks 127 | -------------------------------------------------------------------------------- /examples/api/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-env"], 3 | "plugins": [ 4 | ["@babel/transform-runtime"] 5 | ] 6 | } -------------------------------------------------------------------------------- /examples/api/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Reusable API example 6 | 7 | 8 |
9 | 10 | 11 | -------------------------------------------------------------------------------- /examples/api/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "api", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "parcel ./index.html", 8 | "prepare": "../../node_modules/.bin/relative-deps" 9 | }, 10 | "author": "", 11 | "license": "ISC", 12 | "devDependencies": { 13 | "@babel/core": "^7.5.4", 14 | "@babel/plugin-proposal-class-properties": "^7.5.0", 15 | "@babel/plugin-transform-runtime": "^7.5.0", 16 | "@babel/runtime": "^7.5.4", 17 | "@babel/polyfill": "^7.4.4", 18 | "@babel/preset-react": "^7.0.0", 19 | "parcel": "^1.12.3" 20 | }, 21 | "relativeDependencies": { 22 | "reusable": "../../" 23 | }, 24 | "dependencies": { 25 | "react": "^16.8.6", 26 | "react-dom": "^16.8.6", 27 | "reusable": "^1.1.0" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /examples/api/src/Starwars.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { Table } from "./components/Table"; 4 | import { Loader } from "./components/Loader"; 5 | import { useCharacters } from "./stores/characters.store"; 6 | import { useLoadingState } from "./stores/loadingState.store"; 7 | 8 | export const Starwars = () => { 9 | const { characters } = useCharacters(); 10 | const { loading } = useLoadingState(); 11 | const columns = ["name", "gender", "birth_year", "height", "planet"]; 12 | return ( 13 | 14 | {loading || !characters.length ? ( 15 | 16 | ) : ( 17 | 18 | )} 19 | 20 | ); 21 | }; 22 | -------------------------------------------------------------------------------- /examples/api/src/components/Loader.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export const Loader = props => { 4 | return ( 5 |
6 |
7 |
8 | ); 9 | }; 10 | -------------------------------------------------------------------------------- /examples/api/src/components/Table.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { Loader } from "./Loader"; 4 | 5 | export const Table = props => { 6 | const dataColumns = props.columns; 7 | const dataRows = props.data; 8 | const tableHeaders = ( 9 |
10 | 11 | {dataColumns.map((column, key) => { 12 | return ; 13 | })} 14 | 15 | 16 | ); 17 | const tableBody = dataRows.map((row, index) => { 18 | return ( 19 | 20 | {dataColumns.map((column, key) => { 21 | return ( 22 | 25 | ); 26 | })} 27 | 28 | ); 29 | }); 30 | return ( 31 |
{column.toUpperCase()}
23 | {row[column] ? row[column] : } 24 |
32 | {tableHeaders} 33 | {tableBody} 34 |
35 | ); 36 | }; 37 | -------------------------------------------------------------------------------- /examples/api/src/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import { ReusableProvider } from "reusable"; 4 | 5 | import "./style.css"; 6 | import { Starwars } from "./Starwars"; 7 | 8 | function App() { 9 | return ( 10 | 11 | 12 | 13 | ); 14 | } 15 | 16 | const rootElement = document.getElementById("root"); 17 | ReactDOM.render(, rootElement); 18 | -------------------------------------------------------------------------------- /examples/api/src/stores/api.store.js: -------------------------------------------------------------------------------- 1 | import { createStore } from "reusable/dist/index"; 2 | import { useLoadingState } from "./loadingState.store"; 3 | 4 | export const useApi = createStore(() => { 5 | const { setLoadingState } = useLoadingState(); 6 | const handleRequest = async (url, method, payload = {}, options = {}) => { 7 | const config = { 8 | ...options, 9 | method: method, 10 | headers: { 11 | "Content-type": "application/json; charset=UTF-8" 12 | } 13 | }; 14 | if (method === "POST") { 15 | config.body = JSON.stringify(payload); 16 | } 17 | try { 18 | setLoadingState(true); 19 | const response = await fetch(url, config); 20 | const serverData = await response.json(); 21 | setLoadingState(false); 22 | return serverData; 23 | } catch (e) { 24 | console.error(e); 25 | } 26 | }; 27 | const get = (url, payload, options) => { 28 | return handleRequest(url, "GET", payload, options); 29 | }; 30 | 31 | const put = (url, payload, options) => { 32 | return handleRequest(url, "PUT", payload, options); 33 | }; 34 | 35 | const post = (url, payload, options) => { 36 | return handleRequest(url, "POST", payload, options); 37 | }; 38 | return { 39 | get, 40 | post, 41 | put 42 | }; 43 | }); 44 | -------------------------------------------------------------------------------- /examples/api/src/stores/characters.store.js: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { createStore } from "reusable"; 3 | 4 | import { usePlanets } from "./planets.store"; 5 | import { useApi } from "./api.store"; 6 | 7 | export const useCharacters = createStore(() => { 8 | const [characters, setCharacters] = useState([]); 9 | const { get } = useApi(); 10 | const { 11 | planets, 12 | initializePlanetEmptyMap, 13 | setAsyncPlanetData 14 | } = usePlanets(); 15 | useEffect(() => { 16 | try { 17 | handleAsyncCharactersData(); 18 | } catch (e) { 19 | console.error(e); 20 | } 21 | }, []); 22 | 23 | const handleAsyncCharactersData = async () => { 24 | const data = await get("https://swapi.co/api/people"); 25 | const charactersData = data.results; 26 | setCharacters(charactersData); 27 | initializePlanetEmptyMap(charactersData); 28 | await setAsyncCharactersData(charactersData); 29 | }; 30 | 31 | const setAsyncCharactersData = async data => { 32 | await setAsyncPlanetData(); 33 | data.map(character => { 34 | character.planet = planets.get(character.homeworld); 35 | }); 36 | setCharacters([...data]); 37 | }; 38 | return { 39 | characters, 40 | setCharacters 41 | }; 42 | }); 43 | -------------------------------------------------------------------------------- /examples/api/src/stores/loadingState.store.js: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { createStore } from "reusable"; 3 | 4 | export const useLoadingState = createStore(() => { 5 | const [loading, setLoadingState] = useState(false); 6 | return { 7 | loading, 8 | setLoadingState 9 | }; 10 | }); 11 | -------------------------------------------------------------------------------- /examples/api/src/stores/planets.store.js: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { createStore } from "reusable"; 3 | import { useApi } from "./api.store"; 4 | 5 | export const usePlanets = createStore(() => { 6 | const [planets, setPlanets] = useState(new Map()); 7 | const { get } = useApi(); 8 | const initializePlanetEmptyMap = data => { 9 | data.map(character => { 10 | if (!planets.has(character.homeworld)) { 11 | //setting an empty map of "planetUrl" => "" 12 | setPlanets(planets.set(character.homeworld, "")); 13 | } 14 | }); 15 | }; 16 | const setAsyncPlanetData = async () => { 17 | let promises = []; 18 | planets.forEach((value, key) => { 19 | promises.push(get(key)); 20 | }); 21 | const values = await Promise.all(promises); 22 | Object.keys(values).forEach(key => { 23 | setPlanets(planets.set(values[key].url, values[key].name)); 24 | }); 25 | }; 26 | return { 27 | planets, 28 | setPlanets, 29 | initializePlanetEmptyMap, 30 | setAsyncPlanetData 31 | }; 32 | }); 33 | -------------------------------------------------------------------------------- /examples/api/src/style.css: -------------------------------------------------------------------------------- 1 | .table { 2 | width: 100%; 3 | max-width: 100%; 4 | margin-bottom: 20px; 5 | } 6 | 7 | .table-hover > tbody tr:hover { 8 | background-color: #ffe30047; 9 | } 10 | 11 | tbody >tr>td, thead>tr>th{ 12 | text-align: center; 13 | padding: 8px; 14 | line-height: 1.5; 15 | vertical-align: top; 16 | border-top: 1px solid lightgrey; 17 | } 18 | 19 | .table-delete-action { 20 | text-align: center; 21 | cursor: pointer; 22 | } 23 | 24 | html, body, #root, .container { 25 | width: 100%; 26 | height: 100%; 27 | margin: 0; 28 | padding: 0; 29 | } 30 | 31 | .container { 32 | display: flex; 33 | justify-content: center; 34 | align-items: center; 35 | background: #ffffff; 36 | } 37 | 38 | .loader { 39 | background: transparent; 40 | border-radius: 50%; 41 | border: 4px solid #ffe30047; 42 | border-right-color: black; 43 | animation: rotate 550ms infinite linear; 44 | } 45 | 46 | .loader.md { 47 | width: 100px; 48 | height: 100px; 49 | } 50 | 51 | .loader.sm { 52 | width: 15px; 53 | height: 15px; 54 | } 55 | 56 | @keyframes rotate { 57 | to { transform: rotate(1turn) } 58 | } -------------------------------------------------------------------------------- /examples/basic-18/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 10 | 14 | 23 | React App 24 | 25 | 26 | 27 |
28 | 38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /examples/basic-18/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "basic", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "parcel ./index.html", 8 | "prepare": "../../node_modules/.bin/relative-deps" 9 | }, 10 | "author": "", 11 | "license": "ISC", 12 | "devDependencies": { 13 | "@babel/core": "^7.4.3", 14 | "parcel": "1.12.3" 15 | }, 16 | "relativeDependencies": { 17 | "reusable": "../../" 18 | }, 19 | "dependencies": { 20 | "react": "18.2.0", 21 | "react-dom": "18.2.0", 22 | "reusable": "^1.1.0" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /examples/basic-18/src/Footer.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import {useCounter} from './stores/counter.store'; 3 | 4 | export function Footer() { 5 | const [counter, setCounter] = useCounter(); 6 | 7 | return ( 8 |
9 | setCounter(e.target.value)}/> 10 |
11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /examples/basic-18/src/Header.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import {useCounter} from './stores/counter.store'; 3 | 4 | export function Header() { 5 | const [counter] = useCounter(); 6 | 7 | return ( 8 |
9 | {counter} 10 |
11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /examples/basic-18/src/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { createRoot } from 'react-dom/client'; 3 | 4 | import {ReusableProvider} from 'reusable'; 5 | import {Header} from './Header'; 6 | import {Footer} from './Footer'; 7 | 8 | function App() { 9 | return ( 10 | 11 |
12 | Counter in header and footer are reused 13 |