├── .eslintrc.json ├── .github └── workflows │ ├── cd.yml │ └── ci.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── __tests__ ├── 01_basic_spec.tsx └── __snapshots__ │ └── 01_basic_spec.tsx.snap ├── examples ├── 01_minimal │ ├── package.json │ ├── public │ │ └── index.html │ └── src │ │ └── index.js ├── 02_typescript │ ├── package.json │ ├── public │ │ └── index.html │ └── src │ │ ├── App.tsx │ │ ├── Counter.tsx │ │ ├── Person.tsx │ │ ├── context.ts │ │ ├── index.ts │ │ └── state.ts ├── 03_deep │ ├── package.json │ ├── public │ │ └── index.html │ └── src │ │ ├── App.tsx │ │ ├── Counter.tsx │ │ ├── Person.tsx │ │ ├── context.ts │ │ ├── index.ts │ │ └── state.ts ├── 04_immer │ ├── package.json │ ├── public │ │ └── index.html │ └── src │ │ ├── App.tsx │ │ ├── Counter.tsx │ │ ├── Person.tsx │ │ ├── context.ts │ │ ├── index.ts │ │ └── state.ts ├── 05_localstate │ ├── package.json │ ├── public │ │ └── index.html │ └── src │ │ ├── App.tsx │ │ ├── Counter.tsx │ │ ├── context.ts │ │ ├── index.ts │ │ └── state.ts ├── 06_memoization │ ├── package.json │ ├── public │ │ └── index.html │ └── src │ │ ├── App.tsx │ │ ├── Counter.tsx │ │ ├── Person.tsx │ │ ├── context.ts │ │ ├── index.ts │ │ └── state.ts ├── 07_multistore │ ├── package.json │ ├── public │ │ └── index.html │ └── src │ │ ├── App.tsx │ │ ├── Counter.tsx │ │ ├── Person.tsx │ │ ├── context.ts │ │ ├── index.ts │ │ └── state.ts ├── 08_dynamic │ ├── package.json │ ├── public │ │ └── index.html │ └── src │ │ ├── App.tsx │ │ ├── Counter.tsx │ │ ├── Person.tsx │ │ ├── context.ts │ │ ├── index.ts │ │ └── state.ts ├── 09_thunk │ ├── package.json │ ├── public │ │ └── index.html │ └── src │ │ ├── App.tsx │ │ ├── Counter.tsx │ │ ├── Person.tsx │ │ ├── context.ts │ │ ├── index.ts │ │ └── state.ts ├── 11_todolist │ ├── package.json │ ├── public │ │ └── index.html │ └── src │ │ ├── actions │ │ └── index.ts │ │ ├── components │ │ ├── AddTodo.tsx │ │ ├── App.tsx │ │ ├── FilterLink.tsx │ │ ├── Footer.tsx │ │ ├── Todo.tsx │ │ └── VisibleTodoList.tsx │ │ ├── context.ts │ │ ├── index.tsx │ │ ├── reducers │ │ ├── index.ts │ │ ├── todos.ts │ │ └── visibilityFilter.ts │ │ └── types │ │ └── index.ts ├── 12_async │ ├── package.json │ ├── public │ │ └── index.html │ └── src │ │ ├── components │ │ ├── App.tsx │ │ ├── Picker.tsx │ │ └── Posts.tsx │ │ ├── context.ts │ │ ├── hooks │ │ ├── useFetchPostsIfNeeded.ts │ │ ├── useInvalidateSubreddit.ts │ │ └── useSelectSubreddit.ts │ │ ├── index.tsx │ │ └── store │ │ ├── actions.ts │ │ └── reducers.ts └── 13_memo │ ├── package.json │ ├── public │ └── index.html │ └── src │ ├── App.tsx │ ├── TodoItem.tsx │ ├── TodoList.tsx │ ├── context.ts │ ├── index.ts │ └── state.ts ├── package.json ├── src ├── index.ts ├── memo.ts ├── patchStore.ts ├── useSelector.ts ├── useTrackedState.ts └── utils.ts ├── tsconfig.json ├── webpack.config.js └── yarn.lock /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "plugins": [ 4 | "@typescript-eslint", 5 | "react-hooks" 6 | ], 7 | "extends": [ 8 | "plugin:@typescript-eslint/recommended", 9 | "airbnb" 10 | ], 11 | "env": { 12 | "browser": true 13 | }, 14 | "settings": { 15 | "import/resolver": { 16 | "node": { 17 | "extensions": [".js", ".ts", ".tsx"] 18 | } 19 | } 20 | }, 21 | "rules": { 22 | "react-hooks/rules-of-hooks": "error", 23 | "react-hooks/exhaustive-deps": ["error", { "additionalHooks": "useIsomorphicLayoutEffect" }], 24 | "@typescript-eslint/explicit-function-return-type": "off", 25 | "@typescript-eslint/explicit-module-boundary-types": "off", 26 | "@typescript-eslint/no-explicit-any": "off", 27 | "@typescript-eslint/no-empty-interface": "off", 28 | "react/jsx-filename-extension": ["error", { "extensions": [".js", ".tsx"] }], 29 | "react/prop-types": "off", 30 | "react/jsx-one-expression-per-line": "off", 31 | "import/extensions": ["error", "never"], 32 | "import/prefer-default-export": "off", 33 | "import/no-unresolved": ["error", { "ignore": ["reactive-react-redux"] }], 34 | "no-param-reassign": "off", 35 | "no-plusplus": "off", 36 | "no-bitwise": "off", 37 | "default-case": "off", 38 | "no-undef": "off", 39 | "no-use-before-define": "off", 40 | "no-unused-vars": "off" 41 | }, 42 | "overrides": [{ 43 | "files": ["__tests__/**/*"], 44 | "env": { 45 | "jest": true 46 | }, 47 | "rules": { 48 | "import/no-extraneous-dependencies": ["error", { "devDependencies": true }] 49 | } 50 | }] 51 | } 52 | -------------------------------------------------------------------------------- /.github/workflows/cd.yml: -------------------------------------------------------------------------------- 1 | name: CD 2 | 3 | on: 4 | push: 5 | tags: 6 | - v* 7 | 8 | jobs: 9 | publish: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | 14 | - name: Setup Node 15 | uses: actions/setup-node@v1 16 | with: 17 | node-version: '12.x' 18 | registry-url: 'https://registry.npmjs.org' 19 | 20 | - name: Get yarn cache 21 | id: yarn-cache 22 | run: echo "::set-output name=dir::$(yarn cache dir)" 23 | 24 | - name: Cache dependencies 25 | uses: actions/cache@v1 26 | with: 27 | path: ${{ steps.yarn-cache.outputs.dir }} 28 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 29 | restore-keys: | 30 | ${{ runner.os }}-yarn- 31 | 32 | - name: Install dependencies 33 | run: yarn install 34 | 35 | - name: Test 36 | run: yarn test 37 | 38 | - name: Compile 39 | run: yarn run compile 40 | 41 | - name: Publish 42 | run: npm publish 43 | env: 44 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 45 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | pull_request: 6 | 7 | jobs: 8 | test: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v2 12 | 13 | - name: Setup Node 14 | uses: actions/setup-node@v1 15 | with: 16 | node-version: '12.x' 17 | 18 | - name: Get yarn cache 19 | id: yarn-cache 20 | run: echo "::set-output name=dir::$(yarn cache dir)" 21 | 22 | - name: Cache dependencies 23 | uses: actions/cache@v1 24 | with: 25 | path: ${{ steps.yarn-cache.outputs.dir }} 26 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 27 | restore-keys: | 28 | ${{ runner.os }}-yarn- 29 | 30 | - name: Install dependencies 31 | run: yarn install 32 | 33 | - name: Test 34 | run: yarn test 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *~ 2 | *.swp 3 | node_modules 4 | /dist 5 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | ## [Unreleased] 4 | ### Changed 5 | - New API with useMutableSource (#48) 6 | 7 | ## [4.9.0] - 2020-05-10 8 | ### Changed 9 | - Introduce a special `memo` to be used instead of `trackMemo` 10 | - This is technically a breaking change, but released as a minor update. 11 | 12 | ## [4.8.0] - 2020-03-07 13 | ### Changed 14 | - Notify child components updates in callback and effect not in render 15 | 16 | ## [4.7.0] - 2020-03-01 17 | ### Changed 18 | - Add debug value to show tracked paths in useTrackedState 19 | - Unwrap Proxy before wrapping to mitigate possible pitfalls 20 | 21 | ## [4.6.1] - 2020-02-26 22 | ### Changed 23 | - Use improved useIsomorphicLayoutEffect in Provider (#47) 24 | 25 | ## [4.6.0] - 2020-02-24 26 | ### Changed 27 | - A workaround for React render warning (hopefully temporarily) 28 | 29 | ## [4.5.0] - 2020-02-03 30 | ### Changed 31 | - Improve TypeScript typing (#46) 32 | 33 | ## [4.4.1] - 2020-01-24 34 | ### Changed 35 | - Fix typing for createCustomContext (#44) 36 | 37 | ## [4.4.0] - 2019-10-17 38 | ### Added 39 | - A new API getUntrackedObject to revert proxy 40 | 41 | ## [4.3.0] - 2019-10-09 42 | ### Added 43 | - A new API trackMemo to mark objects as used in React.memo 44 | 45 | ## [4.2.1] - 2019-10-05 46 | ### Changed 47 | - Inline useForceUpdate to remove unnecessary deps 48 | 49 | ## [4.2.0] - 2019-07-27 50 | ### Removed 51 | - Remove the experimental useTrackedSelectors hook 52 | 53 | ## [4.1.0] - 2019-07-20 54 | ### Changed 55 | - No useLayoutEffect for invoking listeners (which leads de-opt sync mode) 56 | - Ref: https://github.com/dai-shi/reactive-react-redux/pull/31 57 | 58 | ## [4.0.0] - 2019-06-22 59 | ### Changed 60 | - Direct state context 61 | - Support custom context 62 | - Rename hooks to be somewhat compatible with react-redux hooks 63 | 64 | ## [3.0.0] - 2019-05-18 65 | ### Changed 66 | - New deep proxy instead of proxyequal 67 | - There is no breaking change in API, but it may behave differently from the previous version. 68 | 69 | ## [2.0.1] - 2019-04-15 70 | ### Changed 71 | - Rename src file names to be more consistent 72 | 73 | ## [2.0.0] - 2019-04-13 74 | ### Removed 75 | - Remove experimental useReduxStateMapped 76 | ### Changed 77 | - useLayoutEffect and keep latest state after update (see #20) 78 | - useForceUpdate uses counter instead of boolean (see #20) 79 | - Organize code in multiple files with some improvements 80 | - Update dependencies 81 | ### Added 82 | - New useReduxSelectors (experimental) 83 | 84 | ## [1.8.0] - 2019-04-02 85 | ### Changed 86 | - Improve useReduxStateMapped with proxyequal 87 | 88 | ## [1.7.0] - 2019-04-01 89 | ### Added 90 | - Implement useReduxStateMapped (experimental/unstable/undocumented) 91 | ### Changed 92 | - Rename project 93 | - Old "react-hooks-easy-redux" 94 | - New "reactive-react-redux" 95 | 96 | ## [1.6.0] - 2019-03-25 97 | ### Changed 98 | - Memoize patched store with batchedUpdates 99 | 100 | ## [1.5.0] - 2019-03-25 101 | ### Changed 102 | - No running callback in every commit phase (reverting #5) 103 | 104 | ## [1.4.0] - 2019-03-25 105 | ### Changed 106 | - Avoid recalculating collectValuables for optimization 107 | - Use unstable_batchedUpdates for optimization 108 | - This is not a breaking change as it has a fallback 109 | - Not tested with react-native (help wanted) 110 | 111 | ## [1.3.0] - 2019-03-03 112 | ### Changed 113 | - Better handling stale props issue 114 | 115 | ## [1.2.0] - 2019-02-25 116 | ### Changed 117 | - Cache proxy state for more performance 118 | 119 | ## [1.1.0] - 2019-02-17 120 | ### Changed 121 | - Improve useRef usage for concurrent mode 122 | 123 | ## [1.0.0] - 2019-02-09 124 | ### Changed 125 | - Improve initialization for concurrent mode 126 | - Updated dependencies (React 16.8) 127 | 128 | ## [0.10.0] - 2019-01-29 129 | ### Changed 130 | - Do not use useMemo as a semantic guarantee 131 | 132 | ## [0.9.0] - 2019-01-10 133 | ### Added 134 | - useReduxStateSimple for shallow object comparison 135 | 136 | ## [0.8.0] - 2018-12-24 137 | ### Changed 138 | - No spread guards in proxyequal for better compatibility 139 | 140 | ## [0.7.0] - 2018-12-19 141 | ### Added 142 | - Better warning message for no ReduxProvider 143 | ### Changed 144 | - Refactor to support dynamic updating 145 | 146 | ## [0.6.0] - 2018-12-17 147 | ### Changed 148 | - Support changing store 149 | 150 | ## [0.5.0] - 2018-12-13 151 | ### Changed 152 | - Fix types and examples for the previous change 153 | 154 | ## [0.4.0] - 2018-12-13 155 | ### Changed 156 | - Gave up bailOutHack and use subscriptions 157 | 158 | ## [0.3.0] - 2018-11-20 159 | ### Changed 160 | - bailOutHack with ErrorBoundary 161 | 162 | ## [0.2.0] - 2018-11-17 163 | ### Added 164 | - Use proxyequal for deep change detection 165 | 166 | ## [0.1.0] - 2018-11-15 167 | ### Added 168 | - Initial experimental release 169 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018-2020 Daishi Kato 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | This project is no longer maintained. 2 | [react-tracked](https://react-tracked.js.org) works with react-redux 3 | and covers the use case of reactive-react-redux. 4 | Redux docs officially recommends [proxy-memoize](https://redux.js.org/usage/deriving-data-selectors#proxy-memoize) as a selector library, 5 | and it provides similar developer experience to that of reactive-react-redux. 6 | Both are good options. 7 | 8 | --- 9 | 10 | There are several projects related to this repo. 11 | Here's the index of those. 12 | 13 | - reactive-react-redux v5-alpha (this repo): This has an experimental react-redux binding with useMutableSource. It provides useTrackedState, which tracks the usage of state in render, and it's originally proposed in this repo. 14 | - [react-tracked](https://github.com/dai-shi/react-tracked): This project is to provide useTrackedState with React Context. v1.6 provides createTrackedSelector that will create useTrackedState from useSelector. 15 | - [react-redux #1503](https://github.com/reduxjs/react-redux/pull/1503): A pull request to add useTrackedState to the official react-redux library. 16 | - [proxy-memoize](https://github.com/dai-shi/proxy-memoize): This is another project which is not tied to React, but combined with useSelector, we get a similar functionality like useTrackedState. 17 | 18 | --- 19 | 20 | # reactive-react-redux 21 | 22 | [![CI](https://img.shields.io/github/workflow/status/dai-shi/reactive-react-redux/CI)](https://github.com/dai-shi/reactive-react-redux/actions?query=workflow%3ACI) 23 | [![npm](https://img.shields.io/npm/v/reactive-react-redux)](https://www.npmjs.com/package/reactive-react-redux) 24 | [![size](https://img.shields.io/bundlephobia/minzip/reactive-react-redux)](https://bundlephobia.com/result?p=reactive-react-redux) 25 | [![discord](https://img.shields.io/discord/627656437971288081)](https://discord.gg/MrQdmzd) 26 | 27 | React Redux binding with React Hooks and Proxy 28 | 29 | > If you are looking for a non-Redux library, please visit [react-tracked](https://github.com/dai-shi/react-tracked) which has the same hooks API. 30 | 31 | ## Introduction 32 | 33 | This is a library to bind React and Redux with Hooks API. 34 | It has mostly the same API as the official 35 | [react-redux Hooks API](https://react-redux.js.org/api/hooks), 36 | so it can be used as a drop-in replacement 37 | if you are using only basic functionality. 38 | 39 | There are two major features in this library 40 | that are not in the official react-redux. 41 | 42 | ### 1. useTrackedState hook 43 | 44 | This library provides another hook `useTrackedState` 45 | which is a simpler API than already simple `useSelector`. 46 | It returns an entire state, but the library takes care of 47 | optimization of re-renders. 48 | Most likely, `useTrackedState` performs better than 49 | `useSelector` without perfectly tuned selectors. 50 | 51 | Technically, `useTrackedState` has no [stale props](https://react-redux.js.org/api/hooks#stale-props-and-zombie-children) issue. 52 | 53 | ### 2. useMutableSource without Context 54 | 55 | react-redux v7 has APIs around Context. 56 | This library is implemented with useMutableSource, 57 | and it patches the Redux store. 58 | APIs are provided without Context. 59 | It's up to developers to use Context based on them. 60 | Check out `./examples/11_todolist/src/context.ts`. 61 | 62 | There's another difference from react-redux v7. 63 | This library directly use useMutableSource, and requires 64 | useCallback for the selector in useSelector. 65 | [equalityFn](https://react-redux.js.org/api/hooks#equality-comparisons-and-updates) is not supported. 66 | 67 | ## How tracking works 68 | 69 | A hook `useTrackedState` returns an entire Redux state object with Proxy, 70 | and it keeps track of which properties of the object are used 71 | in render. When the state is updated, this hook checks 72 | whether used properties are changed. 73 | Only if it detects changes in the state, 74 | it triggers a component to re-render. 75 | 76 | ## Install 77 | 78 | ```bash 79 | npm install reactive-react-redux 80 | ``` 81 | 82 | ## Usage (useTrackedState) 83 | 84 | ```javascript 85 | import React from 'react'; 86 | import { createStore } from 'redux'; 87 | import { 88 | patchStore, 89 | useTrackedState, 90 | } from 'reactive-react-redux'; 91 | 92 | const initialState = { 93 | count: 0, 94 | text: 'hello', 95 | }; 96 | 97 | const reducer = (state = initialState, action) => { 98 | switch (action.type) { 99 | case 'increment': return { ...state, count: state.count + 1 }; 100 | case 'decrement': return { ...state, count: state.count - 1 }; 101 | case 'setText': return { ...state, text: action.text }; 102 | default: return state; 103 | } 104 | }; 105 | 106 | const store = patchStore(createStore(reducer)); 107 | 108 | const Counter = () => { 109 | const state = useTrackedState(store); 110 | const { dispatch } = store; 111 | return ( 112 |
113 | {Math.random()} 114 |
115 | Count: {state.count} 116 | 117 | 118 |
119 |
120 | ); 121 | }; 122 | 123 | const TextBox = () => { 124 | const state = useTrackedState(store); 125 | const { dispatch } = store; 126 | return ( 127 |
128 | {Math.random()} 129 |
130 | Text: {state.text} 131 | dispatch({ type: 'setText', text: event.target.value })} /> 132 |
133 |
134 | ); 135 | }; 136 | 137 | const App = () => ( 138 | <> 139 |

Counter

140 | 141 | 142 |

TextBox

143 | 144 | 145 | 146 | ); 147 | ``` 148 | 149 | ## API 150 | 151 | 152 | 153 | ### patchStore 154 | 155 | patch Redux store for React 156 | 157 | #### Parameters 158 | 159 | - `store` **Store<State, Action>** 160 | 161 | #### Examples 162 | 163 | ```javascript 164 | import { createStore } from 'redux'; 165 | import { patchStore } from 'reactive-react-redux'; 166 | 167 | const reducer = ...; 168 | const store = patchStore(createStore(reducer)); 169 | ``` 170 | 171 | ### useTrackedState 172 | 173 | useTrackedState hook 174 | 175 | It return the Redux state wrapped by Proxy, 176 | and the state prperty access is tracked. 177 | It will only re-render if accessed properties are changed. 178 | 179 | #### Parameters 180 | 181 | - `patchedStore` **PatchedStore<State, Action>** 182 | - `opts` **Opts** (optional, default `{}`) 183 | 184 | #### Examples 185 | 186 | ```javascript 187 | import { useTrackedState } from 'reactive-react-redux'; 188 | 189 | const Component = () => { 190 | const state = useTrackedState(store); 191 | ... 192 | }; 193 | ``` 194 | 195 | ### useSelector 196 | 197 | useSelector hook 198 | 199 | selector has to be stable. Either define it outside render 200 | or use useCallback if selector uses props. 201 | 202 | #### Parameters 203 | 204 | - `patchedStore` **PatchedStore<State, Action>** 205 | - `selector` **function (state: State): Selected** 206 | 207 | #### Examples 208 | 209 | ```javascript 210 | import { useCallback } from 'react'; 211 | import { useSelector } from 'reactive-react-redux'; 212 | 213 | const Component = ({ count }) => { 214 | const isBigger = useSelector(store, useCallack(state => state.count > count, [count])); 215 | ... 216 | }; 217 | ``` 218 | 219 | ### memo 220 | 221 | memo 222 | 223 | Using `React.memo` with tracked state is not compatible, 224 | because `React.memo` stops state access, thus no tracking occurs. 225 | This is a special memo to be used instead of `React.memo` with tracking support. 226 | 227 | #### Parameters 228 | 229 | - `Component` **any** 230 | - `areEqual` **any?** 231 | 232 | #### Examples 233 | 234 | ```javascript 235 | import { memo } from 'reactive-react-redux'; 236 | 237 | const ChildComponent = memo(({ obj1, obj2 }) => { 238 | // ... 239 | }); 240 | ``` 241 | 242 | ## Recipes 243 | 244 | ### Context 245 | 246 | You can create Context based APIs like react-redux v7. 247 | 248 | ```typescript 249 | import { createContext, createElement, useContext } from 'react'; 250 | import { 251 | PatchedStore, 252 | useSelector as useSelectorOrig, 253 | useTrackedState as useTrackedStateOrig, 254 | } from 'reactive-react-redux'; 255 | 256 | export type State = ...; 257 | 258 | export type Action = ...; 259 | 260 | const Context = createContext(new Proxy({}, { 261 | get() { throw new Error('use Provider'); }, 262 | }) as PatchedStore); 263 | 264 | export const Provider: React.FC<{ store: PatchedStore }> = ({ 265 | store, 266 | children, 267 | }) => createElement(Context.Provider, { value: store }, children); 268 | 269 | export const useDispatch = () => useContext(Context).dispatch; 270 | 271 | export const useSelector = ( 272 | selector: (state: State) => Selected, 273 | ) => useSelectorOrig(useContext(Context), selector); 274 | 275 | export const useTrackedState = () => useTrackedStateOrig(useContext(Context)); 276 | ``` 277 | 278 | ### useTrackedSelector 279 | 280 | You can create a selector hook with tracking support. 281 | 282 | ```javascript 283 | import { useTrackedState } from 'reactive-react-redux'; 284 | 285 | export const useTrackedSelector = (patchedStore, selector) => selector(useTrackedState(patchedStore)); 286 | ``` 287 | 288 | Please refer [this issue](https://github.com/dai-shi/reactive-react-redux/issues/41) for more information. 289 | 290 | ### useTracked 291 | 292 | You can combine useTrackedState and useDispatch to 293 | make a hook that returns a tuple like `useReducer`. 294 | 295 | ```javascript 296 | import { useTrackedState, useDispatch } from 'reactive-react-redux'; 297 | 298 | export const useTracked = (patchedStore) => { 299 | const state = useTrackedState(patchedStore); 300 | const dispatch = useDispatch(patchedStore); 301 | return useMemo(() => [state, dispatch], [state, dispatch]); 302 | }; 303 | ``` 304 | 305 | ## Caveats 306 | 307 | Proxy and state usage tracking may not work 100% as expected. 308 | There are some limitations and workarounds. 309 | 310 | ### Proxied states are referentially equal only in per-hook basis 311 | 312 | ```javascript 313 | const state1 = useTrackedState(patchedStore); 314 | const state2 = useTrackedState(patchedStore); 315 | // state1 and state2 is not referentially equal 316 | // even if the underlying redux state is referentially equal. 317 | ``` 318 | 319 | You should use `useTrackedState` only once in a component. 320 | 321 | ### An object referential change doesn't trigger re-render if an property of the object is accessed in previous render 322 | 323 | ```javascript 324 | const state = useTrackedState(patchedStore); 325 | const { foo } = state; 326 | return ; 327 | 328 | const Child = React.memo(({ foo }) => { 329 | // ... 330 | }; 331 | // if foo doesn't change, Child won't render, so foo.id is only marked as used. 332 | // it won't trigger Child to re-render even if foo is changed. 333 | ``` 334 | 335 | You need to use a special `memo` provided by this library. 336 | 337 | ```javascript 338 | import { memo } from 'reactive-react-redux'; 339 | 340 | const Child = memo(({ foo }) => { 341 | // ... 342 | }; 343 | ``` 344 | 345 | ### Proxied state might behave unexpectedly outside render 346 | 347 | Proxies are basically transparent, and it should behave like normal objects. 348 | However, there can be edge cases where it behaves unexpectedly. 349 | For example, if you console.log a proxied value, 350 | it will display a proxy wrapping an object. 351 | Notice, it will be kept tracking outside render, 352 | so any prorerty access will mark as used to trigger re-render on updates. 353 | 354 | useTrackedState will unwrap a Proxy before wrapping with a new Proxy, 355 | hence, it will work fine in usual use cases. 356 | There's only one known pitfall: If you wrap proxied state with your own Proxy 357 | outside the control of useTrackedState, 358 | it might lead memory leaks, because useTrackedState 359 | wouldn't know how to unwrap your own Proxy. 360 | 361 | To work around such edge cases, the first option is to use primitive values. 362 | 363 | ```javascript 364 | const state = useTrackedState(patchedStore); 365 | const dispatch = useUpdate(patchedStore); 366 | dispatch({ type: 'FOO', value: state.fooObj }); // Instead of using objects, 367 | dispatch({ type: 'FOO', value: state.fooStr }); // Use primitives. 368 | ``` 369 | 370 | The second option is to use `getUntrackedObject`. 371 | 372 | ```javascript 373 | import { getUntrackedObject } from 'react-tracked'; 374 | dispatch({ type: 'FOO', value: getUntrackedObject(state.fooObj) }); 375 | ``` 376 | 377 | You could implement a special dispatch function to do this automatically. 378 | 379 | ## Examples 380 | 381 | The [examples](examples) folder contains working examples. 382 | You can run one of them with 383 | 384 | ```bash 385 | PORT=8080 npm run examples:01_minimal 386 | ``` 387 | 388 | and open in your web browser. 389 | 390 | You can also try them in codesandbox.io: 391 | [01](https://codesandbox.io/s/github/dai-shi/reactive-react-redux/tree/master/examples/01_minimal) 392 | [02](https://codesandbox.io/s/github/dai-shi/reactive-react-redux/tree/master/examples/02_typescript) 393 | [03](https://codesandbox.io/s/github/dai-shi/reactive-react-redux/tree/master/examples/03_deep) 394 | [04](https://codesandbox.io/s/github/dai-shi/reactive-react-redux/tree/master/examples/04_immer) 395 | [05](https://codesandbox.io/s/github/dai-shi/reactive-react-redux/tree/master/examples/05_localstate) 396 | [06](https://codesandbox.io/s/github/dai-shi/reactive-react-redux/tree/master/examples/06_memoization) 397 | [07](https://codesandbox.io/s/github/dai-shi/reactive-react-redux/tree/master/examples/07_multistore) 398 | [08](https://codesandbox.io/s/github/dai-shi/reactive-react-redux/tree/master/examples/08_dynamic) 399 | [09](https://codesandbox.io/s/github/dai-shi/reactive-react-redux/tree/master/examples/09_thunk) 400 | [11](https://codesandbox.io/s/github/dai-shi/reactive-react-redux/tree/master/examples/11_todolist) 401 | [12](https://codesandbox.io/s/github/dai-shi/reactive-react-redux/tree/master/examples/12_async) 402 | [13](https://codesandbox.io/s/github/dai-shi/reactive-react-redux/tree/master/examples/13_memo) 403 | 404 | ## Benchmarks 405 | 406 | benchmark result 407 | 408 | See [#32](https://github.com/dai-shi/reactive-react-redux/issues/32) for details. 409 | 410 | ## Blogs 411 | 412 | - [A deadly simple React bindings library for Redux with Hooks API](https://blog.axlight.com/posts/a-deadly-simple-react-bindings-library-for-redux-with-hooks-api/) 413 | - [Developing React custom hooks for Redux without react-redux](https://blog.axlight.com/posts/developing-react-custom-hooks-for-redux-without-react-redux/) 414 | - [Integrating React and Redux, with Hooks and Proxies](https://frontarm.com/daishi-kato/redux-custom-hooks/) 415 | - [New React Redux coding style with hooks without selectors](https://blog.axlight.com/posts/new-react-redux-coding-style-with-hooks-without-selectors/) 416 | - [Benchmark alpha-released hooks API in React Redux with alternatives](https://blog.axlight.com/posts/benchmark-alpha-released-hooks-api-in-react-redux-with-alternatives/) 417 | - [Four patterns for global state with React hooks: Context or Redux](https://blog.axlight.com/posts/four-patterns-for-global-state-with-react-hooks-context-or-redux/) 418 | - [Redux meets hooks for non-redux users: a small concrete example with reactive-react-redux](https://blog.axlight.com/posts/redux-meets-hooks-for-non-redux-users-a-small-concrete-example-with-reactive-react-redux/) 419 | - [Redux-less context-based useSelector hook that has same performance as React-Redux](https://blog.axlight.com/posts/benchmark-react-tracked/) 420 | - [What is state usage tracking? A novel approach to intuitive and performant global state with React hooks and Proxy](https://blog.axlight.com/posts/what-is-state-usage-tracking-a-novel-approach-to-intuitive-and-performant-api-with-react-hooks-and-proxy/) 421 | - [Effortless render optimization with state usage tracking with React hooks](https://blog.axlight.com/posts/effortless-render-optimization-with-state-usage-tracking-with-react-hooks/) 422 | - [How I developed a Concurrent Mode friendly library for React Redux](https://blog.axlight.com/posts/how-i-developed-a-concurrent-mode-friendly-library-for-react-redux/) 423 | - [React hooks-oriented Redux coding pattern without thunks and action creators](https://blog.axlight.com/posts/react-hooks-oriented-redux-coding-pattern-without-thunks-and-action-creators/) 424 | -------------------------------------------------------------------------------- /__tests__/01_basic_spec.tsx: -------------------------------------------------------------------------------- 1 | import React, { StrictMode } from 'react'; 2 | import { AnyAction, createStore } from 'redux'; 3 | 4 | import { render, fireEvent, cleanup } from '@testing-library/react'; 5 | 6 | import { 7 | patchStore, 8 | useSelector, 9 | useTrackedState, 10 | } from '../src/index'; 11 | 12 | describe('basic spec', () => { 13 | afterEach(cleanup); 14 | 15 | it('hooks are defiend', () => { 16 | expect(useSelector).toBeDefined(); 17 | expect(useTrackedState).toBeDefined(); 18 | }); 19 | 20 | it('create a component', () => { 21 | const initialState = { 22 | count1: 0, 23 | }; 24 | type State = typeof initialState; 25 | const reducer = (state = initialState, action: AnyAction) => { 26 | if (action.type === 'increment') { 27 | return { ...state, count1: state.count1 + 1 }; 28 | } 29 | return state; 30 | }; 31 | const store = patchStore(createStore(reducer)); 32 | const Counter = () => { 33 | const value = useTrackedState(store); 34 | const { dispatch } = store; 35 | return ( 36 |
37 | {value.count1} 38 | 39 |
40 | ); 41 | }; 42 | const App = () => ( 43 | 44 | <> 45 | 46 | 47 | 48 | 49 | ); 50 | const { getAllByText, container } = render(); 51 | expect(container).toMatchSnapshot(); 52 | fireEvent.click(getAllByText('+1')[0]); 53 | expect(container).toMatchSnapshot(); 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /__tests__/__snapshots__/01_basic_spec.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`basic spec create a component 1`] = ` 4 |
5 |
6 | 7 | 0 8 | 9 | 14 |
15 |
16 | 17 | 0 18 | 19 | 24 |
25 |
26 | `; 27 | 28 | exports[`basic spec create a component 2`] = ` 29 |
30 |
31 | 32 | 1 33 | 34 | 39 |
40 |
41 | 42 | 1 43 | 44 | 49 |
50 |
51 | `; 52 | -------------------------------------------------------------------------------- /examples/01_minimal/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "reactive-react-redux-example", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "react": "experimental", 7 | "react-dom": "experimental", 8 | "reactive-react-redux": "latest", 9 | "react-scripts": "latest", 10 | "redux": "latest" 11 | }, 12 | "scripts": { 13 | "start": "react-scripts start", 14 | "build": "react-scripts build", 15 | "test": "react-scripts test", 16 | "eject": "react-scripts eject" 17 | }, 18 | "browserslist": [ 19 | ">0.2%", 20 | "not dead", 21 | "not ie <= 11", 22 | "not op_mini all" 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /examples/01_minimal/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | reactive-react-redux example 4 | 5 | 6 |
7 | 8 | 9 | -------------------------------------------------------------------------------- /examples/01_minimal/src/index.js: -------------------------------------------------------------------------------- 1 | import React, { StrictMode } from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { createStore } from 'redux'; 4 | 5 | import { 6 | patchStore, 7 | useTrackedState, 8 | } from 'reactive-react-redux'; 9 | 10 | const initialState = { 11 | count: 0, 12 | text: 'hello', 13 | }; 14 | 15 | const reducer = (state = initialState, action) => { 16 | switch (action.type) { 17 | case 'increment': return { ...state, count: state.count + 1 }; 18 | case 'decrement': return { ...state, count: state.count - 1 }; 19 | case 'setText': return { ...state, text: action.text }; 20 | default: return state; 21 | } 22 | }; 23 | 24 | const store = patchStore(createStore(reducer)); 25 | 26 | const Counter = () => { 27 | const state = useTrackedState(store); 28 | const { dispatch } = store; 29 | return ( 30 |
31 | {Math.random()} 32 |
33 | Count: {state.count} 34 | 35 | 36 |
37 |
38 | ); 39 | }; 40 | 41 | const TextBox = () => { 42 | const state = useTrackedState(store); 43 | const { dispatch } = store; 44 | return ( 45 |
46 | {Math.random()} 47 |
48 | Text: {state.text} 49 | dispatch({ type: 'setText', text: event.target.value })} /> 50 |
51 |
52 | ); 53 | }; 54 | 55 | const App = () => ( 56 | 57 | <> 58 |

Counter

59 | 60 | 61 |

TextBox

62 | 63 | 64 | 65 |
66 | ); 67 | 68 | ReactDOM.unstable_createRoot(document.getElementById('app')).render(); 69 | -------------------------------------------------------------------------------- /examples/02_typescript/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "reactive-react-redux-example", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@types/react": "latest", 7 | "@types/react-dom": "latest", 8 | "react": "experimental", 9 | "react-dom": "experimental", 10 | "reactive-react-redux": "latest", 11 | "react-scripts": "latest", 12 | "redux": "latest", 13 | "typescript": "latest" 14 | }, 15 | "scripts": { 16 | "start": "react-scripts start", 17 | "build": "react-scripts build", 18 | "test": "react-scripts test", 19 | "eject": "react-scripts eject" 20 | }, 21 | "browserslist": [ 22 | ">0.2%", 23 | "not dead", 24 | "not ie <= 11", 25 | "not op_mini all" 26 | ] 27 | } 28 | -------------------------------------------------------------------------------- /examples/02_typescript/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | reactive-react-redux example 4 | 5 | 6 |
7 | 8 | 9 | -------------------------------------------------------------------------------- /examples/02_typescript/src/App.tsx: -------------------------------------------------------------------------------- 1 | import React, { StrictMode } from 'react'; 2 | import { createStore } from 'redux'; 3 | 4 | import { reducer } from './state'; 5 | import { Provider } from './context'; 6 | 7 | import Counter from './Counter'; 8 | import Person from './Person'; 9 | 10 | const store = createStore(reducer); 11 | 12 | const App = () => ( 13 | 14 | 15 |

Counter

16 | 17 | 18 |

Person

19 | 20 | 21 |
22 |
23 | ); 24 | 25 | export default App; 26 | -------------------------------------------------------------------------------- /examples/02_typescript/src/Counter.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { useDispatch, useTrackedState } from './context'; 4 | 5 | const Counter = () => { 6 | const state = useTrackedState(); 7 | const dispatch = useDispatch(); 8 | return ( 9 |
10 | {Math.random()} 11 |
12 | Count: {state.count} 13 | 14 | 15 |
16 |
17 | ); 18 | }; 19 | 20 | export default Counter; 21 | -------------------------------------------------------------------------------- /examples/02_typescript/src/Person.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { useDispatch, useTrackedState } from './context'; 4 | 5 | const Counter: React.FC<{ firstName: string }> = ({ firstName }) => { 6 | const state = useTrackedState(); 7 | const dispatch = useDispatch(); 8 | return ( 9 |
10 | {Math.random()} 11 | {firstName} 12 |
13 | Count: {state.count} 14 | 15 | 16 |
17 |
18 | ); 19 | }; 20 | 21 | const Person = () => { 22 | const state = useTrackedState(); 23 | const dispatch = useDispatch(); 24 | return ( 25 |
26 | {Math.random()} 27 | 28 |
29 | First Name: 30 | { 33 | const firstName = event.target.value; 34 | dispatch({ firstName, type: 'setFirstName' }); 35 | }} 36 | /> 37 |
38 |
39 | Last Name: 40 | { 43 | const lastName = event.target.value; 44 | dispatch({ lastName, type: 'setLastName' }); 45 | }} 46 | /> 47 |
48 |
49 | Age: 50 | { 53 | const age = Number(event.target.value) || 0; 54 | dispatch({ age, type: 'setAge' }); 55 | }} 56 | /> 57 |
58 |
59 | ); 60 | }; 61 | 62 | export default Person; 63 | -------------------------------------------------------------------------------- /examples/02_typescript/src/context.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createContext, 3 | createElement, 4 | useContext, 5 | useMemo, 6 | } from 'react'; 7 | import { Store } from 'redux'; 8 | import { 9 | PatchedStore, 10 | patchStore, 11 | useSelector as useSelectorOrig, 12 | useTrackedState as useTrackedStateOrig, 13 | } from 'reactive-react-redux'; 14 | 15 | import { State, Action } from './state'; 16 | 17 | // Context based APIs 18 | 19 | const Context = createContext(new Proxy({}, { 20 | get() { throw new Error('use Provider'); }, 21 | }) as PatchedStore); 22 | 23 | export const Provider: React.FC<{ store: Store }> = ({ 24 | store, 25 | children, 26 | }) => { 27 | const value = useMemo(() => patchStore(store), [store]); 28 | return createElement(Context.Provider, { value }, children); 29 | }; 30 | 31 | export const useDispatch = () => useContext(Context).dispatch; 32 | 33 | export const useSelector = ( 34 | selector: (state: State) => Selected, 35 | ) => useSelectorOrig(useContext(Context), selector); 36 | 37 | export const useTrackedState = () => useTrackedStateOrig(useContext(Context)); 38 | -------------------------------------------------------------------------------- /examples/02_typescript/src/index.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line spaced-comment 2 | /// 3 | 4 | import React from 'react'; 5 | import { unstable_createRoot as createRoot } from 'react-dom'; 6 | 7 | import App from './App'; 8 | 9 | const ele = document.getElementById('app'); 10 | if (!ele) throw new Error('no app'); 11 | createRoot(ele).render(React.createElement(App)); 12 | -------------------------------------------------------------------------------- /examples/02_typescript/src/state.ts: -------------------------------------------------------------------------------- 1 | const initialState = { 2 | count: 0, 3 | person: { 4 | age: 0, 5 | firstName: '', 6 | lastName: '', 7 | }, 8 | }; 9 | 10 | export type State = typeof initialState; 11 | 12 | export type Action = 13 | | { type: 'increment' } 14 | | { type: 'decrement' } 15 | | { type: 'setFirstName'; firstName: string } 16 | | { type: 'setLastName'; lastName: string } 17 | | { type: 'setAge'; age: number }; 18 | 19 | export const reducer = (state = initialState, action: Action) => { 20 | switch (action.type) { 21 | case 'increment': return { 22 | ...state, 23 | count: state.count + 1, 24 | }; 25 | case 'decrement': return { 26 | ...state, 27 | count: state.count - 1, 28 | }; 29 | case 'setFirstName': return { 30 | ...state, 31 | person: { 32 | ...state.person, 33 | firstName: action.firstName, 34 | }, 35 | }; 36 | case 'setLastName': return { 37 | ...state, 38 | person: { 39 | ...state.person, 40 | lastName: action.lastName, 41 | }, 42 | }; 43 | case 'setAge': return { 44 | ...state, 45 | person: { 46 | ...state.person, 47 | age: action.age, 48 | }, 49 | }; 50 | default: return state; 51 | } 52 | }; 53 | -------------------------------------------------------------------------------- /examples/03_deep/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "reactive-react-redux-example", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@types/react": "latest", 7 | "@types/react-dom": "latest", 8 | "react": "experimental", 9 | "react-dom": "experimental", 10 | "reactive-react-redux": "latest", 11 | "react-scripts": "latest", 12 | "redux": "latest", 13 | "typescript": "latest" 14 | }, 15 | "scripts": { 16 | "start": "react-scripts start", 17 | "build": "react-scripts build", 18 | "test": "react-scripts test", 19 | "eject": "react-scripts eject" 20 | }, 21 | "browserslist": [ 22 | ">0.2%", 23 | "not dead", 24 | "not ie <= 11", 25 | "not op_mini all" 26 | ] 27 | } 28 | -------------------------------------------------------------------------------- /examples/03_deep/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | reactive-react-redux example 4 | 5 | 6 |
7 | 8 | 9 | -------------------------------------------------------------------------------- /examples/03_deep/src/App.tsx: -------------------------------------------------------------------------------- 1 | import React, { StrictMode } from 'react'; 2 | import { createStore } from 'redux'; 3 | 4 | import { reducer } from './state'; 5 | import { Provider } from './context'; 6 | 7 | import Counter from './Counter'; 8 | import Person from './Person'; 9 | 10 | const store = createStore(reducer); 11 | 12 | const App = () => ( 13 | 14 | 15 |

Counter

16 | 17 | 18 |

Person

19 | 20 | 21 |
22 |
23 | ); 24 | 25 | export default App; 26 | -------------------------------------------------------------------------------- /examples/03_deep/src/Counter.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { useDispatch, useTrackedState } from './context'; 4 | 5 | const Counter = () => { 6 | const state = useTrackedState(); 7 | const dispatch = useDispatch(); 8 | return ( 9 |
10 | {Math.random()} 11 |
12 | Count: {state.count} 13 | 14 | 15 |
16 |
17 | ); 18 | }; 19 | 20 | export default Counter; 21 | -------------------------------------------------------------------------------- /examples/03_deep/src/Person.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { useDispatch, useTrackedState } from './context'; 4 | 5 | const TextBox: React.SFC<{ text: string }> = ({ text }) => { 6 | // eslint-disable-next-line no-console 7 | console.log('rendering text:', text); 8 | return {text}; 9 | }; 10 | 11 | const PersonFirstName = () => { 12 | const state = useTrackedState(); 13 | const dispatch = useDispatch(); 14 | return ( 15 |
16 | First Name: 17 | 18 | { 21 | const firstName = event.target.value; 22 | dispatch({ firstName, type: 'setFirstName' }); 23 | }} 24 | /> 25 |
26 | ); 27 | }; 28 | 29 | const PersonLastName = () => { 30 | const state = useTrackedState(); 31 | const dispatch = useDispatch(); 32 | return ( 33 |
34 | Last Name: 35 | 36 | { 39 | const lastName = event.target.value; 40 | dispatch({ lastName, type: 'setLastName' }); 41 | }} 42 | /> 43 |
44 | ); 45 | }; 46 | 47 | const PersonAge = () => { 48 | const state = useTrackedState(); 49 | const dispatch = useDispatch(); 50 | return ( 51 |
52 | Age: 53 | { 56 | const age = Number(event.target.value) || 0; 57 | dispatch({ age, type: 'setAge' }); 58 | }} 59 | /> 60 |
61 | ); 62 | }; 63 | 64 | const Person = () => ( 65 | <> 66 | 67 | 68 | 69 | 70 | ); 71 | 72 | export default Person; 73 | -------------------------------------------------------------------------------- /examples/03_deep/src/context.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createContext, 3 | createElement, 4 | useContext, 5 | useMemo, 6 | } from 'react'; 7 | import { Store } from 'redux'; 8 | import { 9 | PatchedStore, 10 | patchStore, 11 | useSelector as useSelectorOrig, 12 | useTrackedState as useTrackedStateOrig, 13 | } from 'reactive-react-redux'; 14 | 15 | import { State, Action } from './state'; 16 | 17 | // Context based APIs 18 | 19 | const Context = createContext(new Proxy({}, { 20 | get() { throw new Error('use Provider'); }, 21 | }) as PatchedStore); 22 | 23 | export const Provider: React.FC<{ store: Store }> = ({ 24 | store, 25 | children, 26 | }) => { 27 | const value = useMemo(() => patchStore(store), [store]); 28 | return createElement(Context.Provider, { value }, children); 29 | }; 30 | 31 | export const useDispatch = () => useContext(Context).dispatch; 32 | 33 | export const useSelector = ( 34 | selector: (state: State) => Selected, 35 | ) => useSelectorOrig(useContext(Context), selector); 36 | 37 | export const useTrackedState = () => useTrackedStateOrig(useContext(Context)); 38 | -------------------------------------------------------------------------------- /examples/03_deep/src/index.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line spaced-comment 2 | /// 3 | 4 | import React from 'react'; 5 | import { unstable_createRoot as createRoot } from 'react-dom'; 6 | 7 | import App from './App'; 8 | 9 | const ele = document.getElementById('app'); 10 | if (!ele) throw new Error('no app'); 11 | createRoot(ele).render(React.createElement(App)); 12 | -------------------------------------------------------------------------------- /examples/03_deep/src/state.ts: -------------------------------------------------------------------------------- 1 | const initialState = { 2 | count: 0, 3 | person: { 4 | age: 0, 5 | firstName: '', 6 | lastName: '', 7 | }, 8 | }; 9 | 10 | export type State = typeof initialState; 11 | 12 | export type Action = 13 | | { type: 'increment' } 14 | | { type: 'decrement' } 15 | | { type: 'setFirstName'; firstName: string } 16 | | { type: 'setLastName'; lastName: string } 17 | | { type: 'setAge'; age: number }; 18 | 19 | export const reducer = (state = initialState, action: Action) => { 20 | switch (action.type) { 21 | case 'increment': return { 22 | ...state, 23 | count: state.count + 1, 24 | }; 25 | case 'decrement': return { 26 | ...state, 27 | count: state.count - 1, 28 | }; 29 | case 'setFirstName': return { 30 | ...state, 31 | person: { 32 | ...state.person, 33 | firstName: action.firstName, 34 | }, 35 | }; 36 | case 'setLastName': return { 37 | ...state, 38 | person: { 39 | ...state.person, 40 | lastName: action.lastName, 41 | }, 42 | }; 43 | case 'setAge': return { 44 | ...state, 45 | person: { 46 | ...state.person, 47 | age: action.age, 48 | }, 49 | }; 50 | default: return state; 51 | } 52 | }; 53 | -------------------------------------------------------------------------------- /examples/04_immer/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "reactive-react-redux-example", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@types/react": "latest", 7 | "@types/react-dom": "latest", 8 | "immer": "latest", 9 | "react": "experimental", 10 | "react-dom": "experimental", 11 | "reactive-react-redux": "latest", 12 | "react-scripts": "latest", 13 | "redux": "latest", 14 | "typescript": "latest" 15 | }, 16 | "scripts": { 17 | "start": "react-scripts start", 18 | "build": "react-scripts build", 19 | "test": "react-scripts test", 20 | "eject": "react-scripts eject" 21 | }, 22 | "browserslist": [ 23 | ">0.2%", 24 | "not dead", 25 | "not ie <= 11", 26 | "not op_mini all" 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /examples/04_immer/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | reactive-react-redux example 4 | 5 | 6 |
7 | 8 | 9 | -------------------------------------------------------------------------------- /examples/04_immer/src/App.tsx: -------------------------------------------------------------------------------- 1 | import React, { StrictMode } from 'react'; 2 | import { createStore } from 'redux'; 3 | 4 | import { reducer } from './state'; 5 | import { Provider } from './context'; 6 | 7 | import Counter from './Counter'; 8 | import Person from './Person'; 9 | 10 | const store = createStore(reducer); 11 | 12 | const App = () => ( 13 | 14 | 15 |

Counter

16 | 17 | 18 |

Person

19 | 20 | 21 |
22 |
23 | ); 24 | 25 | export default App; 26 | -------------------------------------------------------------------------------- /examples/04_immer/src/Counter.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { useDispatch, useTrackedState } from './context'; 4 | 5 | const Counter = () => { 6 | const state = useTrackedState(); 7 | const dispatch = useDispatch(); 8 | return ( 9 |
10 | {Math.random()} 11 |
12 | Count: {state.count} 13 | 14 | 15 |
16 |
17 | ); 18 | }; 19 | 20 | export default Counter; 21 | -------------------------------------------------------------------------------- /examples/04_immer/src/Person.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { useDispatch, useTrackedState } from './context'; 4 | 5 | const TextBox: React.SFC<{ text: string }> = ({ text }) => { 6 | // eslint-disable-next-line no-console 7 | console.log('rendering text:', text); 8 | return {text}; 9 | }; 10 | 11 | const PersonFirstName = () => { 12 | const state = useTrackedState(); 13 | const dispatch = useDispatch(); 14 | return ( 15 |
16 | First Name: 17 | 18 | { 21 | const firstName = event.target.value; 22 | dispatch({ firstName, type: 'setFirstName' }); 23 | }} 24 | /> 25 |
26 | ); 27 | }; 28 | 29 | const PersonLastName = () => { 30 | const state = useTrackedState(); 31 | const dispatch = useDispatch(); 32 | return ( 33 |
34 | Last Name: 35 | 36 | { 39 | const lastName = event.target.value; 40 | dispatch({ lastName, type: 'setLastName' }); 41 | }} 42 | /> 43 |
44 | ); 45 | }; 46 | 47 | const PersonAge = () => { 48 | const state = useTrackedState(); 49 | const dispatch = useDispatch(); 50 | return ( 51 |
52 | Age: 53 | { 56 | const age = Number(event.target.value) || 0; 57 | dispatch({ age, type: 'setAge' }); 58 | }} 59 | /> 60 |
61 | ); 62 | }; 63 | 64 | const Person = () => ( 65 | <> 66 | 67 | 68 | 69 | 70 | ); 71 | 72 | export default Person; 73 | -------------------------------------------------------------------------------- /examples/04_immer/src/context.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createContext, 3 | createElement, 4 | useContext, 5 | useMemo, 6 | } from 'react'; 7 | import { Store } from 'redux'; 8 | import { 9 | PatchedStore, 10 | patchStore, 11 | useSelector as useSelectorOrig, 12 | useTrackedState as useTrackedStateOrig, 13 | } from 'reactive-react-redux'; 14 | 15 | import { State, Action } from './state'; 16 | 17 | // Context based APIs 18 | 19 | const Context = createContext(new Proxy({}, { 20 | get() { throw new Error('use Provider'); }, 21 | }) as PatchedStore); 22 | 23 | export const Provider: React.FC<{ store: Store }> = ({ 24 | store, 25 | children, 26 | }) => { 27 | const value = useMemo(() => patchStore(store), [store]); 28 | return createElement(Context.Provider, { value }, children); 29 | }; 30 | 31 | export const useDispatch = () => useContext(Context).dispatch; 32 | 33 | export const useSelector = ( 34 | selector: (state: State) => Selected, 35 | ) => useSelectorOrig(useContext(Context), selector); 36 | 37 | export const useTrackedState = () => useTrackedStateOrig(useContext(Context)); 38 | -------------------------------------------------------------------------------- /examples/04_immer/src/index.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line spaced-comment 2 | /// 3 | 4 | import React from 'react'; 5 | import { unstable_createRoot as createRoot } from 'react-dom'; 6 | 7 | import App from './App'; 8 | 9 | const ele = document.getElementById('app'); 10 | if (!ele) throw new Error('no app'); 11 | createRoot(ele).render(React.createElement(App)); 12 | -------------------------------------------------------------------------------- /examples/04_immer/src/state.ts: -------------------------------------------------------------------------------- 1 | import { produce } from 'immer'; 2 | 3 | const initialState = { 4 | count: 0, 5 | person: { 6 | age: 0, 7 | firstName: '', 8 | lastName: '', 9 | }, 10 | }; 11 | 12 | export type State = typeof initialState; 13 | 14 | export type Action = 15 | | { type: 'increment' } 16 | | { type: 'decrement' } 17 | | { type: 'setFirstName'; firstName: string } 18 | | { type: 'setLastName'; lastName: string } 19 | | { type: 'setAge'; age: number }; 20 | 21 | export const reducer = (state = initialState, action: Action) => produce(state, (draft) => { 22 | switch (action.type) { 23 | case 'increment': draft.count += 1; break; 24 | case 'decrement': draft.count -= 1; break; 25 | case 'setFirstName': draft.person.firstName = action.firstName; break; 26 | case 'setLastName': draft.person.lastName = action.lastName; break; 27 | case 'setAge': draft.person.age = action.age; break; 28 | } 29 | }); 30 | -------------------------------------------------------------------------------- /examples/05_localstate/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "reactive-react-redux-example", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@types/react": "latest", 7 | "@types/react-dom": "latest", 8 | "react": "experimental", 9 | "react-dom": "experimental", 10 | "reactive-react-redux": "latest", 11 | "react-scripts": "latest", 12 | "redux": "latest", 13 | "typescript": "latest" 14 | }, 15 | "scripts": { 16 | "start": "react-scripts start", 17 | "build": "react-scripts build", 18 | "test": "react-scripts test", 19 | "eject": "react-scripts eject" 20 | }, 21 | "browserslist": [ 22 | ">0.2%", 23 | "not dead", 24 | "not ie <= 11", 25 | "not op_mini all" 26 | ] 27 | } 28 | -------------------------------------------------------------------------------- /examples/05_localstate/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | reactive-react-redux example 4 | 5 | 6 |
7 | 8 | 9 | -------------------------------------------------------------------------------- /examples/05_localstate/src/App.tsx: -------------------------------------------------------------------------------- 1 | import React, { StrictMode } from 'react'; 2 | import { createStore } from 'redux'; 3 | 4 | import { reducer } from './state'; 5 | import { Provider } from './context'; 6 | 7 | import Counter from './Counter'; 8 | 9 | const store = createStore(reducer); 10 | 11 | const App = () => ( 12 | 13 | 14 |

Counter

15 | 16 |

Counter

17 | 18 |
19 |
20 | ); 21 | 22 | export default App; 23 | -------------------------------------------------------------------------------- /examples/05_localstate/src/Counter.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | 3 | import { useDispatch, useTrackedState } from './context'; 4 | 5 | const Counter = () => { 6 | const [count, setCount] = useState(0); 7 | const state = useTrackedState(); 8 | const dispatch = useDispatch(); 9 | return ( 10 |
11 |
12 | Local count: {count} 13 | 14 | 15 |
16 |
17 | Global count: {state.count} 18 | 19 | 20 |
21 |
22 | ); 23 | }; 24 | 25 | export default Counter; 26 | -------------------------------------------------------------------------------- /examples/05_localstate/src/context.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createContext, 3 | createElement, 4 | useContext, 5 | useMemo, 6 | } from 'react'; 7 | import { Store } from 'redux'; 8 | import { 9 | PatchedStore, 10 | patchStore, 11 | useSelector as useSelectorOrig, 12 | useTrackedState as useTrackedStateOrig, 13 | } from 'reactive-react-redux'; 14 | 15 | import { State, Action } from './state'; 16 | 17 | // Context based APIs 18 | 19 | const Context = createContext(new Proxy({}, { 20 | get() { throw new Error('use Provider'); }, 21 | }) as PatchedStore); 22 | 23 | export const Provider: React.FC<{ store: Store }> = ({ 24 | store, 25 | children, 26 | }) => { 27 | const value = useMemo(() => patchStore(store), [store]); 28 | return createElement(Context.Provider, { value }, children); 29 | }; 30 | 31 | export const useDispatch = () => useContext(Context).dispatch; 32 | 33 | export const useSelector = ( 34 | selector: (state: State) => Selected, 35 | ) => useSelectorOrig(useContext(Context), selector); 36 | 37 | export const useTrackedState = () => useTrackedStateOrig(useContext(Context)); 38 | -------------------------------------------------------------------------------- /examples/05_localstate/src/index.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line spaced-comment 2 | /// 3 | 4 | import React from 'react'; 5 | import { unstable_createRoot as createRoot } from 'react-dom'; 6 | 7 | import App from './App'; 8 | 9 | const ele = document.getElementById('app'); 10 | if (!ele) throw new Error('no app'); 11 | createRoot(ele).render(React.createElement(App)); 12 | -------------------------------------------------------------------------------- /examples/05_localstate/src/state.ts: -------------------------------------------------------------------------------- 1 | const initialState = { 2 | count: 0, 3 | }; 4 | 5 | export type State = typeof initialState; 6 | 7 | export type Action = 8 | | { type: 'increment' } 9 | | { type: 'decrement' }; 10 | 11 | export const reducer = (state = initialState, action: Action) => { 12 | switch (action.type) { 13 | case 'increment': return { 14 | ...state, 15 | count: state.count + 1, 16 | }; 17 | case 'decrement': return { 18 | ...state, 19 | count: state.count - 1, 20 | }; 21 | default: return state; 22 | } 23 | }; 24 | -------------------------------------------------------------------------------- /examples/06_memoization/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "reactive-react-redux-example", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@types/react": "latest", 7 | "@types/react-dom": "latest", 8 | "react": "experimental", 9 | "react-dom": "experimental", 10 | "reactive-react-redux": "latest", 11 | "react-scripts": "latest", 12 | "redux": "latest", 13 | "typescript": "latest" 14 | }, 15 | "scripts": { 16 | "start": "react-scripts start", 17 | "build": "react-scripts build", 18 | "test": "react-scripts test", 19 | "eject": "react-scripts eject" 20 | }, 21 | "browserslist": [ 22 | ">0.2%", 23 | "not dead", 24 | "not ie <= 11", 25 | "not op_mini all" 26 | ] 27 | } 28 | -------------------------------------------------------------------------------- /examples/06_memoization/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | reactive-react-redux example 4 | 5 | 6 |
7 | 8 | 9 | -------------------------------------------------------------------------------- /examples/06_memoization/src/App.tsx: -------------------------------------------------------------------------------- 1 | import React, { StrictMode } from 'react'; 2 | import { createStore } from 'redux'; 3 | 4 | import { reducer } from './state'; 5 | import { Provider } from './context'; 6 | 7 | import Counter from './Counter'; 8 | import Person from './Person'; 9 | 10 | const store = createStore(reducer); 11 | 12 | const App = () => ( 13 | 14 | 15 |

Counter

16 | 17 | 18 |

Person

19 | 20 | 21 |
22 |
23 | ); 24 | 25 | export default App; 26 | -------------------------------------------------------------------------------- /examples/06_memoization/src/Counter.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { useDispatch, useTrackedState } from './context'; 4 | 5 | const Counter = () => { 6 | const state = useTrackedState(); 7 | const dispatch = useDispatch(); 8 | return ( 9 |
10 | {Math.random()} 11 |
12 | Count: {state.count} 13 | 14 | 15 |
16 |
17 | ); 18 | }; 19 | 20 | export default Counter; 21 | -------------------------------------------------------------------------------- /examples/06_memoization/src/Person.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo } from 'react'; 2 | 3 | import { useDispatch, useTrackedState } from './context'; 4 | 5 | const TextBox: React.SFC<{ text: string }> = ({ text }) => { 6 | // eslint-disable-next-line no-console 7 | console.log('rendering text:', text); 8 | return {text}; 9 | }; 10 | 11 | const Person = () => { 12 | const state = useTrackedState(); 13 | const dispatch = useDispatch(); 14 | const { age, firstName, lastName } = useMemo( 15 | () => ({ 16 | age: state.person.age, 17 | firstName: state.person.name.firstName, 18 | lastName: state.person.name.lastName, 19 | }), 20 | [state.person], 21 | ); 22 | return ( 23 |
24 |
25 | First Name: 26 | 27 | { 30 | const newFirstName = event.target.value; 31 | dispatch({ firstName: newFirstName, type: 'setFirstName' }); 32 | }} 33 | /> 34 |
35 |
36 | Last Name: 37 | 38 | { 41 | const newLastName = event.target.value; 42 | dispatch({ lastName: newLastName, type: 'setLastName' }); 43 | }} 44 | /> 45 |
46 |
47 | Age: 48 | { 51 | const newAge = Number(event.target.value) || 0; 52 | dispatch({ age: newAge, type: 'setAge' }); 53 | }} 54 | /> 55 |
56 |
57 | ); 58 | }; 59 | 60 | export default Person; 61 | -------------------------------------------------------------------------------- /examples/06_memoization/src/context.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createContext, 3 | createElement, 4 | useContext, 5 | useMemo, 6 | } from 'react'; 7 | import { Store } from 'redux'; 8 | import { 9 | PatchedStore, 10 | patchStore, 11 | useSelector as useSelectorOrig, 12 | useTrackedState as useTrackedStateOrig, 13 | } from 'reactive-react-redux'; 14 | 15 | import { State, Action } from './state'; 16 | 17 | // Context based APIs 18 | 19 | const Context = createContext(new Proxy({}, { 20 | get() { throw new Error('use Provider'); }, 21 | }) as PatchedStore); 22 | 23 | export const Provider: React.FC<{ store: Store }> = ({ 24 | store, 25 | children, 26 | }) => { 27 | const value = useMemo(() => patchStore(store), [store]); 28 | return createElement(Context.Provider, { value }, children); 29 | }; 30 | 31 | export const useDispatch = () => useContext(Context).dispatch; 32 | 33 | export const useSelector = ( 34 | selector: (state: State) => Selected, 35 | ) => useSelectorOrig(useContext(Context), selector); 36 | 37 | export const useTrackedState = () => useTrackedStateOrig(useContext(Context)); 38 | -------------------------------------------------------------------------------- /examples/06_memoization/src/index.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line spaced-comment 2 | /// 3 | 4 | import React from 'react'; 5 | import { unstable_createRoot as createRoot } from 'react-dom'; 6 | 7 | import App from './App'; 8 | 9 | const ele = document.getElementById('app'); 10 | if (!ele) throw new Error('no app'); 11 | createRoot(ele).render(React.createElement(App)); 12 | -------------------------------------------------------------------------------- /examples/06_memoization/src/state.ts: -------------------------------------------------------------------------------- 1 | const initialState = { 2 | count: 0, 3 | person: { 4 | age: 0, 5 | name: { 6 | firstName: '', 7 | lastName: '', 8 | }, 9 | }, 10 | }; 11 | 12 | export type State = typeof initialState; 13 | 14 | export type Action = 15 | | { type: 'increment' } 16 | | { type: 'decrement' } 17 | | { type: 'setFirstName'; firstName: string } 18 | | { type: 'setLastName'; lastName: string } 19 | | { type: 'setAge'; age: number }; 20 | 21 | export const reducer = (state = initialState, action: Action) => { 22 | switch (action.type) { 23 | case 'increment': return { 24 | ...state, 25 | count: state.count + 1, 26 | }; 27 | case 'decrement': return { 28 | ...state, 29 | count: state.count - 1, 30 | }; 31 | case 'setFirstName': return { 32 | ...state, 33 | person: { 34 | ...state.person, 35 | name: { 36 | ...state.person.name, 37 | firstName: action.firstName, 38 | }, 39 | }, 40 | }; 41 | case 'setLastName': return { 42 | ...state, 43 | person: { 44 | ...state.person, 45 | name: { 46 | ...state.person.name, 47 | lastName: action.lastName, 48 | }, 49 | }, 50 | }; 51 | case 'setAge': return { 52 | ...state, 53 | person: { 54 | ...state.person, 55 | age: action.age, 56 | }, 57 | }; 58 | default: return state; 59 | } 60 | }; 61 | -------------------------------------------------------------------------------- /examples/07_multistore/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "reactive-react-redux-example", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@types/react": "latest", 7 | "@types/react-dom": "latest", 8 | "react": "experimental", 9 | "react-dom": "experimental", 10 | "reactive-react-redux": "latest", 11 | "react-scripts": "latest", 12 | "redux": "latest", 13 | "typescript": "latest" 14 | }, 15 | "scripts": { 16 | "start": "react-scripts start", 17 | "build": "react-scripts build", 18 | "test": "react-scripts test", 19 | "eject": "react-scripts eject" 20 | }, 21 | "browserslist": [ 22 | ">0.2%", 23 | "not dead", 24 | "not ie <= 11", 25 | "not op_mini all" 26 | ] 27 | } 28 | -------------------------------------------------------------------------------- /examples/07_multistore/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | reactive-react-redux example 4 | 5 | 6 |
7 | 8 | 9 | -------------------------------------------------------------------------------- /examples/07_multistore/src/App.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, StrictMode } from 'react'; 2 | import { createStore } from 'redux'; 3 | 4 | import { reducer } from './state'; 5 | import { Provider } from './context'; 6 | 7 | import Counter from './Counter'; 8 | import Person from './Person'; 9 | 10 | const store1 = createStore(reducer); 11 | const store2 = createStore(reducer); 12 | 13 | const App = () => { 14 | const [store, setStore] = useState(store1); 15 | return ( 16 | 17 |
18 | 19 | 20 | 21 |

Counter

22 | 23 | 24 |

Person

25 | 26 | 27 |
28 |
29 |
30 | ); 31 | }; 32 | 33 | export default App; 34 | -------------------------------------------------------------------------------- /examples/07_multistore/src/Counter.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { useDispatch, useTrackedState } from './context'; 4 | 5 | const Counter = () => { 6 | const state = useTrackedState(); 7 | const dispatch = useDispatch(); 8 | return ( 9 |
10 |
11 | Count: {state.count} 12 | 13 | 14 |
15 |
16 | ); 17 | }; 18 | 19 | export default Counter; 20 | -------------------------------------------------------------------------------- /examples/07_multistore/src/Person.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { useDispatch, useTrackedState } from './context'; 4 | 5 | const Person = () => { 6 | const state = useTrackedState(); 7 | const dispatch = useDispatch(); 8 | return ( 9 |
10 |
11 | First Name: 12 | { 15 | const firstName = event.target.value; 16 | dispatch({ firstName, type: 'setFirstName' }); 17 | }} 18 | /> 19 |
20 |
21 | Last Name: 22 | { 25 | const lastName = event.target.value; 26 | dispatch({ lastName, type: 'setLastName' }); 27 | }} 28 | /> 29 |
30 |
31 | Age: 32 | { 35 | const age = Number(event.target.value) || 0; 36 | dispatch({ age, type: 'setAge' }); 37 | }} 38 | /> 39 |
40 |
41 | ); 42 | }; 43 | 44 | export default Person; 45 | -------------------------------------------------------------------------------- /examples/07_multistore/src/context.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createContext, 3 | createElement, 4 | useContext, 5 | useMemo, 6 | } from 'react'; 7 | import { Store } from 'redux'; 8 | import { 9 | PatchedStore, 10 | patchStore, 11 | useSelector as useSelectorOrig, 12 | useTrackedState as useTrackedStateOrig, 13 | } from 'reactive-react-redux'; 14 | 15 | import { State, Action } from './state'; 16 | 17 | // Context based APIs 18 | 19 | const Context = createContext(new Proxy({}, { 20 | get() { throw new Error('use Provider'); }, 21 | }) as PatchedStore); 22 | 23 | export const Provider: React.FC<{ store: Store }> = ({ 24 | store, 25 | children, 26 | }) => { 27 | const value = useMemo(() => patchStore(store), [store]); 28 | return createElement(Context.Provider, { value }, children); 29 | }; 30 | 31 | export const useDispatch = () => useContext(Context).dispatch; 32 | 33 | export const useSelector = ( 34 | selector: (state: State) => Selected, 35 | ) => useSelectorOrig(useContext(Context), selector); 36 | 37 | export const useTrackedState = () => useTrackedStateOrig(useContext(Context)); 38 | -------------------------------------------------------------------------------- /examples/07_multistore/src/index.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line spaced-comment 2 | /// 3 | 4 | import React from 'react'; 5 | import { unstable_createRoot as createRoot } from 'react-dom'; 6 | 7 | import App from './App'; 8 | 9 | const ele = document.getElementById('app'); 10 | if (!ele) throw new Error('no app'); 11 | createRoot(ele).render(React.createElement(App)); 12 | -------------------------------------------------------------------------------- /examples/07_multistore/src/state.ts: -------------------------------------------------------------------------------- 1 | const initialState = { 2 | count: 0, 3 | person: { 4 | age: 0, 5 | firstName: '', 6 | lastName: '', 7 | }, 8 | }; 9 | 10 | export type State = typeof initialState; 11 | 12 | export type Action = 13 | | { type: 'increment' } 14 | | { type: 'decrement' } 15 | | { type: 'setFirstName'; firstName: string } 16 | | { type: 'setLastName'; lastName: string } 17 | | { type: 'setAge'; age: number }; 18 | 19 | export const reducer = (state = initialState, action: Action) => { 20 | switch (action.type) { 21 | case 'increment': return { 22 | ...state, 23 | count: state.count + 1, 24 | }; 25 | case 'decrement': return { 26 | ...state, 27 | count: state.count - 1, 28 | }; 29 | case 'setFirstName': return { 30 | ...state, 31 | person: { 32 | ...state.person, 33 | firstName: action.firstName, 34 | }, 35 | }; 36 | case 'setLastName': return { 37 | ...state, 38 | person: { 39 | ...state.person, 40 | lastName: action.lastName, 41 | }, 42 | }; 43 | case 'setAge': return { 44 | ...state, 45 | person: { 46 | ...state.person, 47 | age: action.age, 48 | }, 49 | }; 50 | default: return state; 51 | } 52 | }; 53 | -------------------------------------------------------------------------------- /examples/08_dynamic/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "reactive-react-redux-example", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@types/react": "latest", 7 | "@types/react-dom": "latest", 8 | "react": "experimental", 9 | "react-dom": "experimental", 10 | "reactive-react-redux": "latest", 11 | "react-scripts": "latest", 12 | "redux": "latest", 13 | "typescript": "latest" 14 | }, 15 | "scripts": { 16 | "start": "react-scripts start", 17 | "build": "react-scripts build", 18 | "test": "react-scripts test", 19 | "eject": "react-scripts eject" 20 | }, 21 | "browserslist": [ 22 | ">0.2%", 23 | "not dead", 24 | "not ie <= 11", 25 | "not op_mini all" 26 | ] 27 | } 28 | -------------------------------------------------------------------------------- /examples/08_dynamic/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | reactive-react-redux example 4 | 5 | 6 |
7 | 8 | 9 | -------------------------------------------------------------------------------- /examples/08_dynamic/src/App.tsx: -------------------------------------------------------------------------------- 1 | import React, { StrictMode } from 'react'; 2 | import { createStore } from 'redux'; 3 | 4 | import { reducer } from './state'; 5 | import { Provider } from './context'; 6 | 7 | import Counter from './Counter'; 8 | import Person from './Person'; 9 | 10 | const store = createStore(reducer); 11 | 12 | const App = () => ( 13 | 14 | 15 |

Counter

16 | 17 | 18 |

Person

19 | 20 | 21 |
22 |
23 | ); 24 | 25 | export default App; 26 | -------------------------------------------------------------------------------- /examples/08_dynamic/src/Counter.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | 3 | import { useDispatch, useTrackedState } from './context'; 4 | 5 | const Counter = () => { 6 | const [index, setIndex] = useState(0); 7 | const state = useTrackedState(); 8 | const dispatch = useDispatch(); 9 | return ( 10 |
11 | {Math.random()} 12 |
13 | Count: {state.count[index]} 14 | 15 | 16 | setIndex(Number(e.target.value) || 0)} /> 17 |
18 |
19 | ); 20 | }; 21 | 22 | export default Counter; 23 | -------------------------------------------------------------------------------- /examples/08_dynamic/src/Person.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | 3 | import { useDispatch, useTrackedState } from './context'; 4 | 5 | const Person = () => { 6 | const [mode, setMode] = useState('first'); 7 | const state = useTrackedState(); 8 | const dispatch = useDispatch(); 9 | return ( 10 |
11 | {Math.random()} 12 | {mode === 'first' && ( 13 |
14 | First Name: 15 | { 18 | const firstName = event.target.value; 19 | dispatch({ firstName, type: 'setFirstName' }); 20 | }} 21 | /> 22 | 23 |
24 | )} 25 | {mode === 'last' && ( 26 |
27 | Last Name: 28 | { 31 | const lastName = event.target.value; 32 | dispatch({ lastName, type: 'setLastName' }); 33 | }} 34 | /> 35 | 36 |
37 | )} 38 |
39 | Age: 40 | { 43 | const age = Number(event.target.value) || 0; 44 | dispatch({ age, type: 'setAge' }); 45 | }} 46 | /> 47 |
48 |
49 | ); 50 | }; 51 | 52 | export default Person; 53 | -------------------------------------------------------------------------------- /examples/08_dynamic/src/context.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createContext, 3 | createElement, 4 | useContext, 5 | useMemo, 6 | } from 'react'; 7 | import { Store } from 'redux'; 8 | import { 9 | PatchedStore, 10 | patchStore, 11 | useSelector as useSelectorOrig, 12 | useTrackedState as useTrackedStateOrig, 13 | } from 'reactive-react-redux'; 14 | 15 | import { State, Action } from './state'; 16 | 17 | // Context based APIs 18 | 19 | const Context = createContext(new Proxy({}, { 20 | get() { throw new Error('use Provider'); }, 21 | }) as PatchedStore); 22 | 23 | export const Provider: React.FC<{ store: Store }> = ({ 24 | store, 25 | children, 26 | }) => { 27 | const value = useMemo(() => patchStore(store), [store]); 28 | return createElement(Context.Provider, { value }, children); 29 | }; 30 | 31 | export const useDispatch = () => useContext(Context).dispatch; 32 | 33 | export const useSelector = ( 34 | selector: (state: State) => Selected, 35 | ) => useSelectorOrig(useContext(Context), selector); 36 | 37 | export const useTrackedState = () => useTrackedStateOrig(useContext(Context)); 38 | -------------------------------------------------------------------------------- /examples/08_dynamic/src/index.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line spaced-comment 2 | /// 3 | 4 | import React from 'react'; 5 | import { unstable_createRoot as createRoot } from 'react-dom'; 6 | 7 | import App from './App'; 8 | 9 | const ele = document.getElementById('app'); 10 | if (!ele) throw new Error('no app'); 11 | createRoot(ele).render(React.createElement(App)); 12 | -------------------------------------------------------------------------------- /examples/08_dynamic/src/state.ts: -------------------------------------------------------------------------------- 1 | const initialState = { 2 | count: [0, 0, 0], 3 | person: { 4 | age: 0, 5 | firstName: '', 6 | lastName: '', 7 | }, 8 | }; 9 | 10 | export type State = typeof initialState; 11 | 12 | export type Action = 13 | | { type: 'dummy' } // XXX typescript somehow complaints without this 14 | | { type: 'increment'; index: number } 15 | | { type: 'decrement'; index: number } 16 | | { type: 'setFirstName'; firstName: string } 17 | | { type: 'setLastName'; lastName: string } 18 | | { type: 'setAge'; age: number }; 19 | 20 | export const reducer = (state = initialState, action: Action) => { 21 | switch (action.type) { 22 | case 'increment': return { 23 | ...state, 24 | count: [ 25 | ...state.count.slice(0, action.index), 26 | state.count[action.index] + 1, 27 | ...state.count.slice(action.index + 1), 28 | ], 29 | }; 30 | case 'decrement': return { 31 | ...state, 32 | count: [ 33 | ...state.count.slice(0, action.index), 34 | state.count[action.index] - 1, 35 | ...state.count.slice(action.index + 1), 36 | ], 37 | }; 38 | case 'setFirstName': return { 39 | ...state, 40 | person: { 41 | ...state.person, 42 | firstName: action.firstName, 43 | }, 44 | }; 45 | case 'setLastName': return { 46 | ...state, 47 | person: { 48 | ...state.person, 49 | lastName: action.lastName, 50 | }, 51 | }; 52 | case 'setAge': return { 53 | ...state, 54 | person: { 55 | ...state.person, 56 | age: action.age, 57 | }, 58 | }; 59 | default: return state; 60 | } 61 | }; 62 | -------------------------------------------------------------------------------- /examples/09_thunk/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "reactive-react-redux-example", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@types/react": "latest", 7 | "@types/react-dom": "latest", 8 | "react": "experimental", 9 | "react-dom": "experimental", 10 | "reactive-react-redux": "latest", 11 | "react-scripts": "latest", 12 | "redux": "latest", 13 | "redux-thunk": "latest", 14 | "typescript": "latest" 15 | }, 16 | "scripts": { 17 | "start": "react-scripts start", 18 | "build": "react-scripts build", 19 | "test": "react-scripts test", 20 | "eject": "react-scripts eject" 21 | }, 22 | "browserslist": [ 23 | ">0.2%", 24 | "not dead", 25 | "not ie <= 11", 26 | "not op_mini all" 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /examples/09_thunk/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | reactive-react-redux example 4 | 5 | 6 |
7 | 8 | 9 | -------------------------------------------------------------------------------- /examples/09_thunk/src/App.tsx: -------------------------------------------------------------------------------- 1 | import React, { StrictMode } from 'react'; 2 | import { createStore, applyMiddleware } from 'redux'; 3 | import reduxThunk from 'redux-thunk'; 4 | 5 | import { reducer } from './state'; 6 | import { Provider } from './context'; 7 | 8 | import Counter from './Counter'; 9 | import Person from './Person'; 10 | 11 | const store = createStore(reducer, applyMiddleware(reduxThunk)); 12 | 13 | const App = () => ( 14 | 15 | 16 |

Counter

17 | 18 | 19 |

Person

20 | 21 | 22 |
23 |
24 | ); 25 | 26 | export default App; 27 | -------------------------------------------------------------------------------- /examples/09_thunk/src/Counter.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { useDispatch, useTrackedState } from './context'; 4 | 5 | const Counter = () => { 6 | const state = useTrackedState(); 7 | const dispatch = useDispatch(); 8 | return ( 9 |
10 | {Math.random()} 11 |
12 | Count: {state.count} 13 | 14 | 15 |
16 |
17 | ); 18 | }; 19 | 20 | export default Counter; 21 | -------------------------------------------------------------------------------- /examples/09_thunk/src/Person.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Dispatch } from 'redux'; 3 | import { ThunkDispatch } from 'redux-thunk'; 4 | 5 | import { State, Action } from './state'; 6 | import { useDispatch, useTrackedState } from './context'; 7 | 8 | const Counter: React.FC<{ firstName: string }> = ({ firstName }) => { 9 | const state = useTrackedState(); 10 | const dispatch = useDispatch(); 11 | return ( 12 |
13 | {Math.random()} 14 | {firstName} 15 |
16 | Count: {state.count} 17 | 18 | 19 |
20 |
21 | ); 22 | }; 23 | 24 | const Person = () => { 25 | const state = useTrackedState(); 26 | const dispatch = useDispatch(); 27 | const setRandomFirstName = () => { 28 | const dispatchForThunk = dispatch as ThunkDispatch; 29 | dispatchForThunk(async (d: Dispatch) => { 30 | d({ firstName: 'Loading...', type: 'setFirstName' }); 31 | try { 32 | const id = Math.floor(100 * Math.random()); 33 | const url = `https://jsonplaceholder.typicode.com/posts/${id}`; 34 | const response = await fetch(url); 35 | const body = await response.json(); 36 | d({ firstName: body.title.split(' ')[0], type: 'setFirstName' }); 37 | } catch (e) { 38 | d({ firstName: 'ERROR: fetching', type: 'setFirstName' }); 39 | } 40 | }); 41 | }; 42 | return ( 43 |
44 | {Math.random()} 45 | 46 | 47 |
48 | First Name: 49 | { 52 | const firstName = event.target.value; 53 | dispatch({ firstName, type: 'setFirstName' }); 54 | }} 55 | /> 56 |
57 |
58 | Last Name: 59 | { 62 | const lastName = event.target.value; 63 | dispatch({ lastName, type: 'setLastName' }); 64 | }} 65 | /> 66 |
67 |
68 | Age: 69 | { 72 | const age = Number(event.target.value) || 0; 73 | dispatch({ age, type: 'setAge' }); 74 | }} 75 | /> 76 |
77 |
78 | ); 79 | }; 80 | 81 | export default Person; 82 | -------------------------------------------------------------------------------- /examples/09_thunk/src/context.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createContext, 3 | createElement, 4 | useContext, 5 | useMemo, 6 | } from 'react'; 7 | import { Store } from 'redux'; 8 | import { 9 | PatchedStore, 10 | patchStore, 11 | useSelector as useSelectorOrig, 12 | useTrackedState as useTrackedStateOrig, 13 | } from 'reactive-react-redux'; 14 | 15 | import { State, Action } from './state'; 16 | 17 | // Context based APIs 18 | 19 | const Context = createContext(new Proxy({}, { 20 | get() { throw new Error('use Provider'); }, 21 | }) as PatchedStore); 22 | 23 | export const Provider: React.FC<{ store: Store }> = ({ 24 | store, 25 | children, 26 | }) => { 27 | const value = useMemo(() => patchStore(store), [store]); 28 | return createElement(Context.Provider, { value }, children); 29 | }; 30 | 31 | export const useDispatch = () => useContext(Context).dispatch; 32 | 33 | export const useSelector = ( 34 | selector: (state: State) => Selected, 35 | ) => useSelectorOrig(useContext(Context), selector); 36 | 37 | export const useTrackedState = () => useTrackedStateOrig(useContext(Context)); 38 | -------------------------------------------------------------------------------- /examples/09_thunk/src/index.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line spaced-comment 2 | /// 3 | 4 | import React from 'react'; 5 | import { unstable_createRoot as createRoot } from 'react-dom'; 6 | 7 | import App from './App'; 8 | 9 | const ele = document.getElementById('app'); 10 | if (!ele) throw new Error('no app'); 11 | createRoot(ele).render(React.createElement(App)); 12 | -------------------------------------------------------------------------------- /examples/09_thunk/src/state.ts: -------------------------------------------------------------------------------- 1 | const initialState = { 2 | count: 0, 3 | person: { 4 | age: 0, 5 | firstName: '', 6 | lastName: '', 7 | }, 8 | }; 9 | 10 | export type State = typeof initialState; 11 | 12 | export type Action = 13 | | { type: 'increment' } 14 | | { type: 'decrement' } 15 | | { type: 'setFirstName'; firstName: string } 16 | | { type: 'setLastName'; lastName: string } 17 | | { type: 'setAge'; age: number }; 18 | 19 | export const reducer = (state = initialState, action: Action) => { 20 | switch (action.type) { 21 | case 'increment': return { 22 | ...state, 23 | count: state.count + 1, 24 | }; 25 | case 'decrement': return { 26 | ...state, 27 | count: state.count - 1, 28 | }; 29 | case 'setFirstName': return { 30 | ...state, 31 | person: { 32 | ...state.person, 33 | firstName: action.firstName, 34 | }, 35 | }; 36 | case 'setLastName': return { 37 | ...state, 38 | person: { 39 | ...state.person, 40 | lastName: action.lastName, 41 | }, 42 | }; 43 | case 'setAge': return { 44 | ...state, 45 | person: { 46 | ...state.person, 47 | age: action.age, 48 | }, 49 | }; 50 | default: return state; 51 | } 52 | }; 53 | -------------------------------------------------------------------------------- /examples/11_todolist/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "reactive-react-redux-example", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@types/react": "latest", 7 | "@types/react-dom": "latest", 8 | "react": "experimental", 9 | "react-dom": "experimental", 10 | "reactive-react-redux": "latest", 11 | "react-scripts": "latest", 12 | "redux": "latest", 13 | "typescript": "latest" 14 | }, 15 | "scripts": { 16 | "start": "react-scripts start", 17 | "build": "react-scripts build", 18 | "test": "react-scripts test", 19 | "eject": "react-scripts eject" 20 | }, 21 | "browserslist": [ 22 | ">0.2%", 23 | "not dead", 24 | "not ie <= 11", 25 | "not op_mini all" 26 | ] 27 | } 28 | -------------------------------------------------------------------------------- /examples/11_todolist/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | reactive-react-redux example 4 | 5 | 6 |
7 | 8 | 9 | -------------------------------------------------------------------------------- /examples/11_todolist/src/actions/index.ts: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react'; 2 | 3 | import { useDispatch } from '../context'; 4 | import { VisibilityFilterType } from '../types'; 5 | 6 | let nextTodoId = 0; 7 | 8 | export const useAddTodo = () => { 9 | const dispatch = useDispatch(); 10 | return useCallback((text: string) => { 11 | dispatch({ 12 | type: 'ADD_TODO', 13 | id: nextTodoId++, 14 | text, 15 | }); 16 | }, [dispatch]); 17 | }; 18 | 19 | export const useSetVisibilityFilter = () => { 20 | const dispatch = useDispatch(); 21 | return useCallback((filter: VisibilityFilterType) => { 22 | dispatch({ 23 | type: 'SET_VISIBILITY_FILTER', 24 | filter, 25 | }); 26 | }, [dispatch]); 27 | }; 28 | 29 | export const useToggleTodo = () => { 30 | const dispatch = useDispatch(); 31 | return useCallback((id: number) => { 32 | dispatch({ 33 | type: 'TOGGLE_TODO', 34 | id, 35 | }); 36 | }, [dispatch]); 37 | }; 38 | -------------------------------------------------------------------------------- /examples/11_todolist/src/components/AddTodo.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | 3 | import { useAddTodo } from '../actions'; 4 | 5 | const AddTodo = () => { 6 | const [text, setText] = useState(''); 7 | const addTodo = useAddTodo(); 8 | return ( 9 |
10 |
{ 12 | e.preventDefault(); 13 | if (!text.trim()) { 14 | return; 15 | } 16 | addTodo(text); 17 | setText(''); 18 | }} 19 | > 20 | setText(e.target.value)} /> 21 | 22 |
23 |
24 | ); 25 | }; 26 | 27 | export default AddTodo; 28 | -------------------------------------------------------------------------------- /examples/11_todolist/src/components/App.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import Footer from './Footer'; 4 | import AddTodo from './AddTodo'; 5 | import VisibleTodoList from './VisibleTodoList'; 6 | 7 | const App: React.FC = () => ( 8 |
9 | 10 | 11 |
12 |
13 | ); 14 | 15 | export default App; 16 | -------------------------------------------------------------------------------- /examples/11_todolist/src/components/FilterLink.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { useTrackedState } from '../context'; 4 | import { useSetVisibilityFilter } from '../actions'; 5 | import { VisibilityFilterType } from '../types'; 6 | 7 | type Props = { 8 | filter: VisibilityFilterType; 9 | }; 10 | 11 | const FilterLink: React.FC = ({ filter, children }) => { 12 | const state = useTrackedState(); 13 | const active = filter === state.visibilityFilter; 14 | const setVisibilityFilter = useSetVisibilityFilter(); 15 | return ( 16 | 26 | ); 27 | }; 28 | 29 | export default FilterLink; 30 | -------------------------------------------------------------------------------- /examples/11_todolist/src/components/Footer.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import FilterLink from './FilterLink'; 4 | 5 | const Footer: React.FC = () => ( 6 |
7 | Show: 8 | All 9 | Active 10 | Completed 11 |
12 | ); 13 | 14 | export default Footer; 15 | -------------------------------------------------------------------------------- /examples/11_todolist/src/components/Todo.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | type Props = { 4 | onClick: (e: React.MouseEvent) => void; 5 | completed: boolean; 6 | text: string; 7 | }; 8 | 9 | const Todo: React.FC = ({ onClick, completed, text }) => ( 10 |
  • 18 | {text} 19 |
  • 20 | ); 21 | 22 | export default Todo; 23 | -------------------------------------------------------------------------------- /examples/11_todolist/src/components/VisibleTodoList.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/jsx-props-no-spreading */ 2 | 3 | import React from 'react'; 4 | 5 | import { useTrackedState } from '../context'; 6 | import { TodoType, VisibilityFilterType } from '../types'; 7 | import { useToggleTodo } from '../actions'; 8 | import Todo from './Todo'; 9 | 10 | const getVisibleTodos = (todos: TodoType[], filter: VisibilityFilterType) => { 11 | switch (filter) { 12 | case 'SHOW_ALL': 13 | return todos; 14 | case 'SHOW_COMPLETED': 15 | return todos.filter((t) => t.completed); 16 | case 'SHOW_ACTIVE': 17 | return todos.filter((t) => !t.completed); 18 | default: 19 | throw new Error(`Unknown filter: ${filter}`); 20 | } 21 | }; 22 | 23 | const VisibleTodoList: React.FC = () => { 24 | const state = useTrackedState(); 25 | const visibleTodos = getVisibleTodos(state.todos, state.visibilityFilter); 26 | const toggleTodo = useToggleTodo(); 27 | return ( 28 |
      29 | {visibleTodos.map((todo) => ( 30 | toggleTodo(todo.id)} /> 31 | ))} 32 |
    33 | ); 34 | }; 35 | 36 | export default VisibleTodoList; 37 | -------------------------------------------------------------------------------- /examples/11_todolist/src/context.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createContext, 3 | createElement, 4 | useContext, 5 | useMemo, 6 | } from 'react'; 7 | import { Store } from 'redux'; 8 | import { 9 | PatchedStore, 10 | patchStore, 11 | useSelector as useSelectorOrig, 12 | useTrackedState as useTrackedStateOrig, 13 | } from 'reactive-react-redux'; 14 | 15 | import { State, Action } from './types'; 16 | 17 | // Context based APIs 18 | 19 | const Context = createContext(new Proxy({}, { 20 | get() { throw new Error('use Provider'); }, 21 | }) as PatchedStore); 22 | 23 | export const Provider: React.FC<{ store: Store }> = ({ 24 | store, 25 | children, 26 | }) => { 27 | const value = useMemo(() => patchStore(store), [store]); 28 | return createElement(Context.Provider, { value }, children); 29 | }; 30 | 31 | export const useDispatch = () => useContext(Context).dispatch; 32 | 33 | export const useSelector = ( 34 | selector: (state: State) => Selected, 35 | ) => useSelectorOrig(useContext(Context), selector); 36 | 37 | export const useTrackedState = () => useTrackedStateOrig(useContext(Context)); 38 | -------------------------------------------------------------------------------- /examples/11_todolist/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from 'react-dom'; 3 | import { createStore } from 'redux'; 4 | 5 | import { Provider } from './context'; 6 | import rootReducer from './reducers'; 7 | import App from './components/App'; 8 | 9 | const store = createStore(rootReducer); 10 | 11 | render( 12 | 13 | 14 | , 15 | document.getElementById('app'), 16 | ); 17 | -------------------------------------------------------------------------------- /examples/11_todolist/src/reducers/index.ts: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux'; 2 | 3 | import todos from './todos'; 4 | import visibilityFilter from './visibilityFilter'; 5 | 6 | export default combineReducers({ 7 | todos, 8 | visibilityFilter, 9 | }); 10 | -------------------------------------------------------------------------------- /examples/11_todolist/src/reducers/todos.ts: -------------------------------------------------------------------------------- 1 | import { TodoType, Action } from '../types'; 2 | 3 | const todos = (state: TodoType[] = [], action: Action): TodoType[] => { 4 | switch (action.type) { 5 | case 'ADD_TODO': 6 | return [ 7 | ...state, 8 | { 9 | id: action.id, 10 | text: action.text, 11 | completed: false, 12 | }, 13 | ]; 14 | case 'TOGGLE_TODO': 15 | return state.map((todo: TodoType) => ( 16 | todo.id === action.id ? { ...todo, completed: !todo.completed } : todo 17 | )); 18 | default: 19 | return state; 20 | } 21 | }; 22 | 23 | export default todos; 24 | -------------------------------------------------------------------------------- /examples/11_todolist/src/reducers/visibilityFilter.ts: -------------------------------------------------------------------------------- 1 | import { Action, VisibilityFilterType } from '../types'; 2 | 3 | const visibilityFilter = ( 4 | state: VisibilityFilterType = 'SHOW_ALL', 5 | action: Action, 6 | ): VisibilityFilterType => { 7 | switch (action.type) { 8 | case 'SET_VISIBILITY_FILTER': 9 | return action.filter; 10 | default: 11 | return state; 12 | } 13 | }; 14 | 15 | export default visibilityFilter; 16 | -------------------------------------------------------------------------------- /examples/11_todolist/src/types/index.ts: -------------------------------------------------------------------------------- 1 | export type VisibilityFilterType = 2 | | 'SHOW_ALL' 3 | | 'SHOW_COMPLETED' 4 | | 'SHOW_ACTIVE'; 5 | 6 | export type TodoType = { 7 | id: number; 8 | text: string; 9 | completed: boolean; 10 | }; 11 | 12 | export type State = { 13 | todos: TodoType[]; 14 | visibilityFilter: VisibilityFilterType; 15 | }; 16 | 17 | export type Action = 18 | | { type: 'ADD_TODO'; id: number; text: string } 19 | | { type: 'SET_VISIBILITY_FILTER'; filter: VisibilityFilterType } 20 | | { type: 'TOGGLE_TODO'; id: number }; 21 | -------------------------------------------------------------------------------- /examples/12_async/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "reactive-react-redux-example", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@types/react": "latest", 7 | "@types/react-dom": "latest", 8 | "react": "experimental", 9 | "react-dom": "experimental", 10 | "reactive-react-redux": "latest", 11 | "react-scripts": "latest", 12 | "redux": "latest", 13 | "redux-thunk": "latest", 14 | "typescript": "latest" 15 | }, 16 | "scripts": { 17 | "start": "react-scripts start", 18 | "build": "react-scripts build", 19 | "test": "react-scripts test", 20 | "eject": "react-scripts eject" 21 | }, 22 | "browserslist": [ 23 | ">0.2%", 24 | "not dead", 25 | "not ie <= 11", 26 | "not op_mini all" 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /examples/12_async/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | reactive-react-redux example 4 | 5 | 6 |
    7 | 8 | 9 | -------------------------------------------------------------------------------- /examples/12_async/src/components/App.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useEffect } from 'react'; 2 | 3 | import { useSelector } from '../context'; 4 | import { State, SelectedSubreddit } from '../store/actions'; 5 | import useSelectSubreddit from '../hooks/useSelectSubreddit'; 6 | import useFetchPostsIfNeeded from '../hooks/useFetchPostsIfNeeded'; 7 | import useInvalidateSubreddit from '../hooks/useInvalidateSubreddit'; 8 | 9 | import Picker from './Picker'; 10 | import Posts from './Posts'; 11 | 12 | const App: React.FC = () => { 13 | const selectedSubreddit = useSelector((state: State) => state.selectedSubreddit); 14 | const postsBySubreddit = useSelector((state: State) => state.postsBySubreddit); 15 | const { 16 | isFetching, 17 | items: posts, 18 | lastUpdated, 19 | } = postsBySubreddit[selectedSubreddit] || { 20 | isFetching: true, 21 | items: [], 22 | lastUpdated: undefined, 23 | }; 24 | 25 | const fetchPostsIfNeeded = useFetchPostsIfNeeded(); 26 | useEffect(() => { 27 | fetchPostsIfNeeded(selectedSubreddit); 28 | }, [fetchPostsIfNeeded, selectedSubreddit]); 29 | 30 | const selectSubreddit = useSelectSubreddit(); 31 | const handleChange = useCallback((nextSubreddit: SelectedSubreddit) => { 32 | selectSubreddit(nextSubreddit); 33 | }, [selectSubreddit]); 34 | 35 | const invalidateSubreddit = useInvalidateSubreddit(); 36 | const handleRefreshClick = (e: React.MouseEvent) => { 37 | e.preventDefault(); 38 | invalidateSubreddit(selectedSubreddit); 39 | fetchPostsIfNeeded(selectedSubreddit); 40 | }; 41 | 42 | const isEmpty = posts.length === 0; 43 | return ( 44 |
    45 | 50 |

    51 | {lastUpdated && ( 52 | 53 | Last updated at {new Date(lastUpdated).toLocaleTimeString()}. 54 | 55 | )} 56 | {!isFetching && ( 57 | 60 | )} 61 |

    62 | {isEmpty && isFetching &&

    Loading...

    } 63 | {isEmpty && !isFetching &&

    Empty.

    } 64 | {!isEmpty && ( 65 |
    66 | 67 |
    68 | )} 69 |
    70 | ); 71 | }; 72 | 73 | export default App; 74 | -------------------------------------------------------------------------------- /examples/12_async/src/components/Picker.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const Picker: React.FC<{ 4 | value: string; 5 | onChange: (value: string) => void; 6 | options: string[]; 7 | }> = ({ value, onChange, options }) => ( 8 | 9 |

    {value}

    10 | 20 |
    21 | ); 22 | 23 | export default Picker; 24 | -------------------------------------------------------------------------------- /examples/12_async/src/components/Posts.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const Posts: React.FC<{ 4 | posts: { 5 | id: string; 6 | title: string; 7 | }[]; 8 | }> = ({ posts }) => ( 9 |
      10 | {posts.map((post) => ( 11 |
    • {post.title}
    • 12 | ))} 13 |
    14 | ); 15 | 16 | export default Posts; 17 | -------------------------------------------------------------------------------- /examples/12_async/src/context.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createContext, 3 | createElement, 4 | useContext, 5 | useMemo, 6 | } from 'react'; 7 | import { Store } from 'redux'; 8 | import { 9 | PatchedStore, 10 | patchStore, 11 | useSelector as useSelectorOrig, 12 | useTrackedState as useTrackedStateOrig, 13 | } from 'reactive-react-redux'; 14 | 15 | import { State, Action } from './store/actions'; 16 | 17 | // Context based APIs 18 | 19 | const Context = createContext(new Proxy({}, { 20 | get() { throw new Error('use Provider'); }, 21 | }) as PatchedStore); 22 | 23 | export const Provider: React.FC<{ store: Store }> = ({ 24 | store, 25 | children, 26 | }) => { 27 | const value = useMemo(() => patchStore(store), [store]); 28 | return createElement(Context.Provider, { value }, children); 29 | }; 30 | 31 | export const useDispatch = () => useContext(Context).dispatch; 32 | 33 | export const useSelector = ( 34 | selector: (state: State) => Selected, 35 | ) => useSelectorOrig(useContext(Context), selector); 36 | 37 | export const useTrackedState = () => useTrackedStateOrig(useContext(Context)); 38 | 39 | export const useStore = () => useContext(Context); 40 | -------------------------------------------------------------------------------- /examples/12_async/src/hooks/useFetchPostsIfNeeded.ts: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react'; 2 | 3 | import { useDispatch, useStore } from '../context'; 4 | import { State, Post } from '../store/actions'; 5 | 6 | const shouldFetchPosts = (state: State, subreddit: string) => { 7 | const posts = state.postsBySubreddit[subreddit]; 8 | if (!posts) { 9 | return true; 10 | } 11 | if (posts.isFetching) { 12 | return false; 13 | } 14 | return posts.didInvalidate; 15 | }; 16 | 17 | const extractPosts = (json: unknown): Post[] | null => { 18 | try { 19 | const posts: Post[] = (json as { 20 | data: { 21 | children: { 22 | data: { 23 | id: string; 24 | title: string; 25 | }; 26 | }[]; 27 | }; 28 | }).data.children.map((child) => child.data); 29 | // type check 30 | if (posts.every((post) => ( 31 | typeof post.id === 'string' && typeof post.title === 'string' 32 | ))) { 33 | return posts; 34 | } 35 | return null; 36 | } catch (e) { 37 | return null; 38 | } 39 | }; 40 | 41 | const useFetchPostsIfNeeded = () => { 42 | const dispatch = useDispatch(); 43 | const store = useStore(); 44 | const fetchPostsIfNeeded = useCallback(async (subreddit: string) => { 45 | if (!shouldFetchPosts(store.getState(), subreddit)) { 46 | return; 47 | } 48 | dispatch({ 49 | type: 'REQUEST_POSTS', 50 | subreddit, 51 | }); 52 | const response = await fetch(`https://www.reddit.com/r/${subreddit}.json`); 53 | const json = await response.json(); 54 | const posts = extractPosts(json); 55 | if (!posts) throw new Error('unexpected json format'); 56 | dispatch({ 57 | type: 'RECEIVE_POSTS', 58 | subreddit, 59 | posts, 60 | receivedAt: Date.now(), 61 | }); 62 | }, [dispatch, store]); 63 | return fetchPostsIfNeeded; 64 | }; 65 | 66 | export default useFetchPostsIfNeeded; 67 | -------------------------------------------------------------------------------- /examples/12_async/src/hooks/useInvalidateSubreddit.ts: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react'; 2 | 3 | import { useDispatch } from '../context'; 4 | 5 | const useInvalidateSubreddit = () => { 6 | const dispatch = useDispatch(); 7 | const invalidateSubreddit = useCallback((subreddit: string) => { 8 | dispatch({ 9 | type: 'INVALIDATE_SUBREDDIT', 10 | subreddit, 11 | }); 12 | }, [dispatch]); 13 | return invalidateSubreddit; 14 | }; 15 | 16 | export default useInvalidateSubreddit; 17 | -------------------------------------------------------------------------------- /examples/12_async/src/hooks/useSelectSubreddit.ts: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react'; 2 | 3 | import { useDispatch } from '../context'; 4 | 5 | const useSelectSubreddit = () => { 6 | const dispatch = useDispatch(); 7 | const selectSubreddit = useCallback((subreddit: string) => { 8 | dispatch({ 9 | type: 'SELECT_SUBREDDIT', 10 | subreddit, 11 | }); 12 | }, [dispatch]); 13 | return selectSubreddit; 14 | }; 15 | 16 | export default useSelectSubreddit; 17 | -------------------------------------------------------------------------------- /examples/12_async/src/index.tsx: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line spaced-comment 2 | /// 3 | 4 | import React from 'react'; 5 | import { unstable_createRoot as createRoot } from 'react-dom'; 6 | import { createStore } from 'redux'; 7 | 8 | import { Provider } from './context'; 9 | import rootReducer from './store/reducers'; 10 | import App from './components/App'; 11 | 12 | const store = createStore(rootReducer); 13 | 14 | const ele = document.getElementById('app'); 15 | if (!ele) throw new Error('no app'); 16 | createRoot(ele).render( 17 | 18 | 19 | , 20 | ); 21 | -------------------------------------------------------------------------------- /examples/12_async/src/store/actions.ts: -------------------------------------------------------------------------------- 1 | export type Post = { 2 | id: string; 3 | title: string; 4 | }; 5 | 6 | export type SubredditPosts = { 7 | isFetching: boolean; 8 | didInvalidate: boolean; 9 | items: Post[]; 10 | lastUpdated?: number; 11 | }; 12 | 13 | export type PostsBySubreddit = { 14 | [subreddit: string]: SubredditPosts; 15 | }; 16 | 17 | export type SelectedSubreddit = string; 18 | 19 | export type State = { 20 | selectedSubreddit: SelectedSubreddit; 21 | postsBySubreddit: PostsBySubreddit; 22 | }; 23 | 24 | type SelectSubredditAction = { 25 | type: 'SELECT_SUBREDDIT'; 26 | subreddit: string; 27 | }; 28 | 29 | type InvalidateSubredditAction = { 30 | type: 'INVALIDATE_SUBREDDIT'; 31 | subreddit: string; 32 | }; 33 | 34 | type RequestPostsAction = { 35 | type: 'REQUEST_POSTS'; 36 | subreddit: string; 37 | }; 38 | 39 | type ReceivePostsAction = { 40 | type: 'RECEIVE_POSTS'; 41 | subreddit: string; 42 | posts: Post[]; 43 | receivedAt: number; 44 | }; 45 | 46 | export type Action = 47 | | SelectSubredditAction 48 | | InvalidateSubredditAction 49 | | RequestPostsAction 50 | | ReceivePostsAction; 51 | -------------------------------------------------------------------------------- /examples/12_async/src/store/reducers.ts: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux'; 2 | import { 3 | SubredditPosts, 4 | SelectedSubreddit, 5 | PostsBySubreddit, 6 | State, 7 | Action, 8 | } from './actions'; 9 | 10 | const selectedSubreddit = ( 11 | state: SelectedSubreddit = 'reactjs', 12 | action: Action, 13 | ): SelectedSubreddit => { 14 | switch (action.type) { 15 | case 'SELECT_SUBREDDIT': 16 | return action.subreddit; 17 | default: 18 | return state; 19 | } 20 | }; 21 | 22 | const posts = (state: SubredditPosts = { 23 | isFetching: false, 24 | didInvalidate: false, 25 | items: [], 26 | }, action: Action): SubredditPosts => { 27 | switch (action.type) { 28 | case 'INVALIDATE_SUBREDDIT': 29 | return { 30 | ...state, 31 | didInvalidate: true, 32 | }; 33 | case 'REQUEST_POSTS': 34 | return { 35 | ...state, 36 | isFetching: true, 37 | didInvalidate: false, 38 | }; 39 | case 'RECEIVE_POSTS': 40 | return { 41 | ...state, 42 | isFetching: false, 43 | didInvalidate: false, 44 | items: action.posts, 45 | lastUpdated: action.receivedAt, 46 | }; 47 | default: 48 | return state; 49 | } 50 | }; 51 | 52 | const postsBySubreddit = ( 53 | state: PostsBySubreddit = {}, 54 | action: Action, 55 | ): PostsBySubreddit => { 56 | switch (action.type) { 57 | case 'INVALIDATE_SUBREDDIT': 58 | case 'RECEIVE_POSTS': 59 | case 'REQUEST_POSTS': 60 | return { 61 | ...state, 62 | [action.subreddit]: posts(state[action.subreddit], action), 63 | }; 64 | default: 65 | return state; 66 | } 67 | }; 68 | 69 | const rootReducer = combineReducers({ 70 | postsBySubreddit, 71 | selectedSubreddit, 72 | }); 73 | 74 | export default rootReducer; 75 | -------------------------------------------------------------------------------- /examples/13_memo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "reactive-react-redux-example", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@types/react": "latest", 7 | "@types/react-dom": "latest", 8 | "react": "experimental", 9 | "react-dom": "experimental", 10 | "reactive-react-redux": "latest", 11 | "react-scripts": "latest", 12 | "redux": "latest", 13 | "typescript": "latest" 14 | }, 15 | "scripts": { 16 | "start": "react-scripts start", 17 | "build": "react-scripts build", 18 | "test": "react-scripts test", 19 | "eject": "react-scripts eject" 20 | }, 21 | "browserslist": [ 22 | ">0.2%", 23 | "not dead", 24 | "not ie <= 11", 25 | "not op_mini all" 26 | ] 27 | } 28 | -------------------------------------------------------------------------------- /examples/13_memo/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | reactive-react-redux example 4 | 5 | 6 |
    7 | 8 | 9 | -------------------------------------------------------------------------------- /examples/13_memo/src/App.tsx: -------------------------------------------------------------------------------- 1 | import React, { StrictMode } from 'react'; 2 | import { createStore } from 'redux'; 3 | 4 | import { reducer } from './state'; 5 | import { Provider } from './context'; 6 | 7 | import TodoList from './TodoList'; 8 | 9 | const store = createStore(reducer); 10 | 11 | const App = () => ( 12 | 13 | 14 | 15 | 16 | 17 | ); 18 | 19 | export default App; 20 | -------------------------------------------------------------------------------- /examples/13_memo/src/TodoItem.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { memo } from 'reactive-react-redux'; 4 | import { useDispatch } from './context'; 5 | import { TodoType } from './state'; 6 | 7 | type Props = { 8 | todo: TodoType; 9 | }; 10 | 11 | let numRendered = 0; 12 | 13 | const TodoItem: React.FC = ({ todo }) => { 14 | const dispatch = useDispatch(); 15 | return ( 16 |
  • 17 | numRendered: {++numRendered} 18 | dispatch({ type: 'TOGGLE_TODO', id: todo.id })} 22 | /> 23 | 28 | {todo.title} 29 | 30 |
  • 31 | ); 32 | }; 33 | 34 | // export default React.memo(TodoItem); // Instead of React.memo 35 | export default memo(TodoItem); // Use custom memo 36 | -------------------------------------------------------------------------------- /examples/13_memo/src/TodoList.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { useTrackedState } from './context'; 4 | import TodoItem from './TodoItem'; 5 | 6 | const TodoList: React.FC = () => { 7 | const state = useTrackedState(); 8 | const { todos } = state; 9 | return ( 10 |
      11 | {todos.map((todo) => ( 12 | 13 | ))} 14 |
    15 | ); 16 | }; 17 | 18 | export default TodoList; 19 | -------------------------------------------------------------------------------- /examples/13_memo/src/context.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createContext, 3 | createElement, 4 | useContext, 5 | useMemo, 6 | } from 'react'; 7 | import { Store } from 'redux'; 8 | import { 9 | PatchedStore, 10 | patchStore, 11 | useSelector as useSelectorOrig, 12 | useTrackedState as useTrackedStateOrig, 13 | } from 'reactive-react-redux'; 14 | 15 | import { State, Action } from './state'; 16 | 17 | // Context based APIs 18 | 19 | const Context = createContext(new Proxy({}, { 20 | get() { throw new Error('use Provider'); }, 21 | }) as PatchedStore); 22 | 23 | export const Provider: React.FC<{ store: Store }> = ({ 24 | store, 25 | children, 26 | }) => { 27 | const value = useMemo(() => patchStore(store), [store]); 28 | return createElement(Context.Provider, { value }, children); 29 | }; 30 | 31 | export const useDispatch = () => useContext(Context).dispatch; 32 | 33 | export const useSelector = ( 34 | selector: (state: State) => Selected, 35 | ) => useSelectorOrig(useContext(Context), selector); 36 | 37 | export const useTrackedState = () => useTrackedStateOrig(useContext(Context)); 38 | -------------------------------------------------------------------------------- /examples/13_memo/src/index.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line spaced-comment 2 | /// 3 | 4 | import React from 'react'; 5 | import { unstable_createRoot as createRoot } from 'react-dom'; 6 | 7 | import App from './App'; 8 | 9 | const ele = document.getElementById('app'); 10 | if (!ele) throw new Error('no app'); 11 | createRoot(ele).render(React.createElement(App)); 12 | -------------------------------------------------------------------------------- /examples/13_memo/src/state.ts: -------------------------------------------------------------------------------- 1 | export type TodoType = { 2 | id: number; 3 | title: string; 4 | completed: boolean; 5 | }; 6 | 7 | export type State = { 8 | todos: TodoType[]; 9 | }; 10 | 11 | export type Action = 12 | | { type: 'ADD_TODO'; title: string } 13 | | { type: 'DELETE_TODO'; id: number } 14 | | { type: 'CHANGE_TODO'; id: number; title: string } 15 | | { type: 'TOGGLE_TODO'; id: number }; 16 | 17 | const initialState: State = { 18 | todos: [ 19 | { id: 1, title: 'Wash dishes', completed: false }, 20 | { id: 2, title: 'Study JS', completed: false }, 21 | { id: 3, title: 'Buy ticket', completed: false }, 22 | ], 23 | }; 24 | 25 | let nextId = 4; 26 | 27 | export const reducer = (state = initialState, action: Action) => { 28 | switch (action.type) { 29 | case 'ADD_TODO': 30 | return { 31 | ...state, 32 | todos: [ 33 | ...state.todos, 34 | { id: nextId++, title: action.title, completed: false }, 35 | ], 36 | }; 37 | case 'DELETE_TODO': 38 | return { 39 | ...state, 40 | todos: state.todos.filter((todo) => todo.id !== action.id), 41 | }; 42 | case 'CHANGE_TODO': 43 | return { 44 | ...state, 45 | todos: state.todos.map((todo) => ( 46 | todo.id === action.id ? { ...todo, title: action.title } : todo 47 | )), 48 | }; 49 | case 'TOGGLE_TODO': 50 | return { 51 | ...state, 52 | todos: state.todos.map((todo) => ( 53 | todo.id === action.id ? { ...todo, completed: !todo.completed } : todo 54 | )), 55 | }; 56 | default: 57 | return state; 58 | } 59 | }; 60 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "reactive-react-redux", 3 | "description": "React Redux binding with React Hooks and Proxy", 4 | "version": "5.0.0-alpha.7", 5 | "publishConfig": { 6 | "tag": "next" 7 | }, 8 | "author": "Daishi Kato", 9 | "repository": { 10 | "type": "git", 11 | "url": "https://github.com/dai-shi/reactive-react-redux.git" 12 | }, 13 | "source": "./src/index.ts", 14 | "main": "./dist/index.umd.js", 15 | "module": "./dist/index.modern.js", 16 | "types": "./dist/src/index.d.ts", 17 | "sideEffects": false, 18 | "files": [ 19 | "src", 20 | "dist" 21 | ], 22 | "scripts": { 23 | "compile": "microbundle build -f modern,umd", 24 | "test": "run-s eslint tsc-test jest", 25 | "eslint": "eslint --ext .js,.ts,.tsx --ignore-pattern dist .", 26 | "jest": "jest --preset ts-jest/presets/js-with-ts", 27 | "tsc-test": "tsc --project . --noEmit", 28 | "apidoc": "documentation readme --section API --markdown-toc false --parse-extension ts src/*.ts", 29 | "examples:01_minimal": "DIR=01_minimal EXT=js webpack-dev-server", 30 | "examples:02_typescript": "DIR=02_typescript webpack-dev-server", 31 | "examples:03_deep": "DIR=03_deep webpack-dev-server", 32 | "examples:04_immer": "DIR=04_immer webpack-dev-server", 33 | "examples:05_localstate": "DIR=05_localstate webpack-dev-server", 34 | "examples:06_memoization": "DIR=06_memoization webpack-dev-server", 35 | "examples:07_multistore": "DIR=07_multistore webpack-dev-server", 36 | "examples:08_dynamic": "DIR=08_dynamic webpack-dev-server", 37 | "examples:09_thunk": "DIR=09_thunk webpack-dev-server", 38 | "examples:11_todolist": "DIR=11_todolist EXT=tsx webpack-dev-server", 39 | "examples:12_async": "DIR=12_async EXT=tsx webpack-dev-server", 40 | "examples:13_memo": "DIR=13_memo webpack-dev-server" 41 | }, 42 | "keywords": [ 43 | "react", 44 | "redux", 45 | "state", 46 | "hooks", 47 | "stateless", 48 | "thisless", 49 | "pure" 50 | ], 51 | "license": "MIT", 52 | "dependencies": { 53 | "proxy-compare": "^1.1.3" 54 | }, 55 | "devDependencies": { 56 | "@testing-library/react": "^11.2.2", 57 | "@types/jest": "^26.0.19", 58 | "@types/react": "^17.0.0", 59 | "@types/react-dom": "^17.0.0", 60 | "@types/redux-logger": "^3.0.8", 61 | "@typescript-eslint/eslint-plugin": "^4.11.1", 62 | "@typescript-eslint/parser": "^4.11.1", 63 | "documentation": "^13.1.0", 64 | "eslint": "^7.16.0", 65 | "eslint-config-airbnb": "^18.2.1", 66 | "eslint-plugin-import": "^2.22.1", 67 | "eslint-plugin-jsx-a11y": "^6.4.1", 68 | "eslint-plugin-react": "^7.21.5", 69 | "eslint-plugin-react-hooks": "^4.2.0", 70 | "html-webpack-plugin": "^4.5.0", 71 | "immer": "^8.0.0", 72 | "jest": "^26.6.3", 73 | "microbundle": "^0.13.0", 74 | "npm-run-all": "^4.1.5", 75 | "react": "experimental", 76 | "react-dom": "experimental", 77 | "redux": "^4.0.5", 78 | "redux-thunk": "^2.3.0", 79 | "ts-jest": "^26.4.4", 80 | "ts-loader": "^8.0.12", 81 | "typescript": "^4.1.3", 82 | "webpack": "^4.44.2", 83 | "webpack-cli": "^3.3.12", 84 | "webpack-dev-server": "^3.11.1" 85 | }, 86 | "peerDependencies": { 87 | "react": ">=18.0.0", 88 | "redux": ">=4.0.0" 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export type { PatchedStore } from './patchStore'; 2 | export { patchStore } from './patchStore'; 3 | export { useSelector } from './useSelector'; 4 | export { useTrackedState } from './useTrackedState'; 5 | export { memo } from './memo'; 6 | export { getUntrackedObject } from 'proxy-compare'; 7 | -------------------------------------------------------------------------------- /src/memo.ts: -------------------------------------------------------------------------------- 1 | import { ComponentProps, createElement, memo as reactMemo } from 'react'; 2 | import { trackMemo } from 'proxy-compare'; 3 | 4 | /** 5 | * memo 6 | * 7 | * Using `React.memo` with tracked state is not compatible, 8 | * because `React.memo` stops state access, thus no tracking occurs. 9 | * This is a special memo to be used instead of `React.memo` with tracking support. 10 | * 11 | * @example 12 | * import { memo } from 'reactive-react-redux'; 13 | * 14 | * const ChildComponent = memo(({ obj1, obj2 }) => { 15 | * // ... 16 | * }); 17 | */ 18 | export const memo = ( 19 | Component: Parameters[0], 20 | areEqual?: Parameters[1], 21 | ) => { 22 | const WrappedComponent = (props: ComponentProps) => { 23 | Object.values(props).forEach(trackMemo); 24 | return createElement(Component, props); 25 | }; 26 | return reactMemo(WrappedComponent, areEqual); 27 | }; 28 | -------------------------------------------------------------------------------- /src/patchStore.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/ban-ts-comment */ 2 | 3 | import { 4 | // @ts-ignore 5 | unstable_createMutableSource as createMutableSource, 6 | } from 'react'; 7 | import { 8 | Action as ReduxAction, 9 | Store, 10 | } from 'redux'; 11 | 12 | export type PatchedStore> = { 13 | mutableSource: any; 14 | } & Store; 15 | 16 | /** 17 | * patch Redux store for React 18 | * 19 | * @example 20 | * import { createStore } from 'redux'; 21 | * import { patchStore } from 'reactive-react-redux'; 22 | * 23 | * const reducer = ...; 24 | * const store = patchStore(createStore(reducer)); 25 | */ 26 | export const patchStore = >( 27 | store: Store, 28 | ) => { 29 | const mutableSource = createMutableSource(store, () => store.getState()); 30 | (store as PatchedStore).mutableSource = mutableSource; 31 | return store as PatchedStore; 32 | }; 33 | -------------------------------------------------------------------------------- /src/useSelector.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/ban-ts-comment */ 2 | 3 | import { 4 | useCallback, 5 | // @ts-ignore 6 | unstable_useMutableSource as useMutableSource, 7 | } from 'react'; 8 | import { 9 | Action as ReduxAction, 10 | Store, 11 | } from 'redux'; 12 | 13 | import { PatchedStore } from './patchStore'; 14 | 15 | const subscribe = >( 16 | store: Store, 17 | callback: () => void, 18 | ) => store.subscribe(callback); 19 | 20 | /** 21 | * useSelector hook 22 | * 23 | * selector has to be stable. Either define it outside render 24 | * or use useCallback if selector uses props. 25 | * 26 | * @example 27 | * import { useCallback } from 'react'; 28 | * import { useSelector } from 'reactive-react-redux'; 29 | * 30 | * const Component = ({ count }) => { 31 | * const isBigger = useSelector(store, useCallack(state => state.count > count, [count])); 32 | * ... 33 | * }; 34 | */ 35 | export const useSelector = , Selected>( 36 | patchedStore: PatchedStore, 37 | selector: (state: State) => Selected, 38 | ) => { 39 | const { mutableSource } = patchedStore; 40 | const getSnapshot = useCallback((store: Store) => ( 41 | selector(store.getState()) 42 | ), [selector]); 43 | const selected: Selected = useMutableSource(mutableSource, getSnapshot, subscribe); 44 | return selected; 45 | }; 46 | -------------------------------------------------------------------------------- /src/useTrackedState.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/ban-ts-comment */ 2 | 3 | import { 4 | useCallback, 5 | useEffect, 6 | useLayoutEffect, 7 | useMemo, 8 | useReducer, 9 | useRef, 10 | // @ts-ignore 11 | unstable_useMutableSource as useMutableSource, 12 | } from 'react'; 13 | import { Action as ReduxAction, Store } from 'redux'; 14 | import { createDeepProxy, isDeepChanged } from 'proxy-compare'; 15 | 16 | import { PatchedStore } from './patchStore'; 17 | import { useAffectedDebugValue } from './utils'; 18 | 19 | const isSSR = typeof window === 'undefined' 20 | || /ServerSideRendering/.test(window.navigator && window.navigator.userAgent); 21 | 22 | const useIsomorphicLayoutEffect = isSSR ? useEffect : useLayoutEffect; 23 | 24 | const getSnapshot = >( 25 | store: Store, 26 | ) => store.getState(); 27 | 28 | /** 29 | * useTrackedState hook 30 | * 31 | * It return the Redux state wrapped by Proxy, 32 | * and the state prperty access is tracked. 33 | * It will only re-render if accessed properties are changed. 34 | * 35 | * @example 36 | * import { useTrackedState } from 'reactive-react-redux'; 37 | * 38 | * const Component = () => { 39 | * const state = useTrackedState(store); 40 | * ... 41 | * }; 42 | */ 43 | export const useTrackedState = >( 44 | patchedStore: PatchedStore, 45 | ) => { 46 | const { mutableSource } = patchedStore; 47 | const [version, forceUpdate] = useReducer((c) => c + 1, 0); 48 | const affected = new WeakMap(); 49 | const lastAffected = useRef, unknown>>(); 50 | const prevState = useRef(); 51 | const lastState = useRef(); 52 | useIsomorphicLayoutEffect(() => { 53 | prevState.current = patchedStore.getState(); 54 | lastState.current = patchedStore.getState(); 55 | }, [patchedStore]); 56 | useIsomorphicLayoutEffect(() => { 57 | lastAffected.current = affected; 58 | if (prevState.current !== lastState.current 59 | && isDeepChanged( 60 | prevState.current, 61 | lastState.current, 62 | affected, 63 | new WeakMap(), 64 | )) { 65 | prevState.current = lastState.current; 66 | forceUpdate(); 67 | } 68 | }); 69 | const sub = useCallback((store: Store, cb: () => void) => store.subscribe(() => { 70 | const nextState = store.getState(); 71 | lastState.current = nextState; 72 | if (prevState.current 73 | && lastAffected.current 74 | && !isDeepChanged( 75 | prevState.current, 76 | nextState, 77 | lastAffected.current, 78 | new WeakMap(), 79 | ) 80 | ) { 81 | // not changed 82 | return; 83 | } 84 | prevState.current = nextState; 85 | cb(); 86 | }), [version]); // eslint-disable-line react-hooks/exhaustive-deps 87 | const state: State = useMutableSource(mutableSource, getSnapshot, sub); 88 | if (process.env.NODE_ENV !== 'production') { 89 | // eslint-disable-next-line react-hooks/rules-of-hooks 90 | useAffectedDebugValue(state, affected); 91 | } 92 | const proxyCache = useMemo(() => new WeakMap(), []); // per-hook proxyCache 93 | return createDeepProxy(state, affected, proxyCache); 94 | }; 95 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { 2 | useEffect, 3 | useRef, 4 | useDebugValue, 5 | } from 'react'; 6 | import { affectedToPathList } from 'proxy-compare'; 7 | 8 | type Obj = Record; 9 | 10 | export const useAffectedDebugValue = ( 11 | state: State, 12 | affected: WeakMap, 13 | ) => { 14 | const pathList = useRef<(string | number | symbol)[][]>(); 15 | useEffect(() => { 16 | pathList.current = affectedToPathList(state, affected); 17 | }); 18 | useDebugValue(pathList.current); 19 | }; 20 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "target": "es5", 5 | "esModuleInterop": true, 6 | "module": "es2015", 7 | "moduleResolution": "node", 8 | "jsx": "react", 9 | "allowJs": true, 10 | "noUnusedLocals": true, 11 | "noUnusedParameters": true, 12 | "sourceMap": true, 13 | "baseUrl": ".", 14 | "paths": { 15 | "reactive-react-redux": ["./src"] 16 | }, 17 | "outDir": "./dist" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/no-var-requires 2 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 3 | 4 | const { DIR, EXT = 'ts' } = process.env; 5 | 6 | module.exports = { 7 | mode: 'development', 8 | devtool: 'cheap-module-eval-source-map', 9 | entry: `./examples/${DIR}/src/index.${EXT}`, 10 | output: { 11 | publicPath: '/', 12 | }, 13 | plugins: [ 14 | new HtmlWebpackPlugin({ 15 | template: `./examples/${DIR}/public/index.html`, 16 | }), 17 | ], 18 | module: { 19 | rules: [{ 20 | test: /\.[jt]sx?$/, 21 | exclude: /node_modules/, 22 | loader: 'ts-loader', 23 | options: { 24 | transpileOnly: true, 25 | }, 26 | }], 27 | }, 28 | resolve: { 29 | extensions: ['.js', '.jsx', '.ts', '.tsx'], 30 | alias: { 31 | 'reactive-react-redux': `${__dirname}/src`, 32 | }, 33 | }, 34 | devServer: { 35 | port: process.env.PORT || '8080', 36 | contentBase: `./examples/${DIR}/public`, 37 | historyApiFallback: true, 38 | }, 39 | }; 40 | --------------------------------------------------------------------------------