├── .github └── workflows │ ├── publish.yml │ └── test.yml ├── .gitignore ├── .prettierignore ├── .prettierrc ├── LICENSE ├── README-ja.md ├── README.md ├── example ├── .env ├── .gitignore ├── README.md ├── package.json ├── public │ ├── favicon.ico │ ├── index.html │ ├── logo192.png │ ├── logo512.png │ ├── manifest.json │ └── robots.txt ├── src │ ├── App.module.css │ ├── App.tsx │ ├── Counter │ │ ├── container.ts │ │ ├── index.tsx │ │ └── styles.module.css │ ├── ReduxLike │ │ ├── container.ts │ │ ├── country.ts │ │ ├── index.tsx │ │ └── styles.module.css │ ├── Todo │ │ ├── container.ts │ │ ├── index.tsx │ │ └── styles.module.css │ ├── index.css │ ├── index.tsx │ ├── logo.svg │ ├── react-app-env.d.ts │ ├── reportWebVitals.ts │ ├── setupTests.ts │ └── utils │ │ └── randomlyGetColor.ts └── tsconfig.json ├── jest.config.js ├── package-lock.json ├── package.json ├── rollup.config.js ├── src ├── Notifier.ts ├── __tests__ │ ├── Notifier.test.ts │ ├── createContainerProvider.test.tsx │ ├── createUseContainer.test.ts │ ├── unreduxed.test.tsx │ └── useForceUpdate.test.ts ├── createContainerProvider.tsx ├── createUseContainer.ts ├── empty.ts ├── index.ts ├── unreduxed.tsx ├── useForceUpdate.ts └── useIsomorphicLayoutEffect.ts └── tsconfig.json /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish to npm 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | publish: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v1 12 | - uses: actions/setup-node@v1 13 | with: 14 | node-version: 12 15 | - run: npm ci 16 | - run: npm run build 17 | - run: npm test 18 | - uses: JS-DevTools/npm-publish@v1 19 | with: 20 | token: ${{ secrets.NPM_TOKEN }} -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Run Test 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | branches: [master] 8 | 9 | jobs: 10 | build-and-test: 11 | runs-on: ubuntu-latest 12 | 13 | strategy: 14 | matrix: 15 | node-version: [12.x, 14.x] 16 | 17 | steps: 18 | - uses: actions/checkout@v2 19 | - name: Use Node.js ${{ matrix.node-version }} 20 | uses: actions/setup-node@v1 21 | with: 22 | node-version: ${{ matrix.node-version }} 23 | - run: npm ci 24 | - run: npm run build 25 | - run: npm test 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | LICENSE 4 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 90, 3 | "tabWidth": 2, 4 | "useTabs": false, 5 | "semi": true, 6 | "singleQuote": false, 7 | "quoteProps": "as-needed", 8 | "jsxSingleQuote": false, 9 | "trailingComma": "all", 10 | "bracketSpacing": true, 11 | "jsxBracketSameLine": true, 12 | "arrowParens": "avoid", 13 | "requirePragma": false, 14 | "insertPragma": false, 15 | "proseWrap": "preserve" 16 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Yosuke Hiraoka 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README-ja.md: -------------------------------------------------------------------------------- 1 | # unreduxed 2 | 3 | > コンポーネントの再レンダリングに悩まされないためのライブラリ 4 | 5 | `unreduxed` は React 用のステート管理ライブラリです。 6 | このライブラリは次の 2 つの影響を受けています。 7 | 8 | - `react-redux` https://react-redux.js.org/ 9 | - `unstated-next` https://github.com/jamiebuilds/unstated-next 10 | 11 | ## 特徴 12 | 13 | - **シンプルな API** 14 | 15 | - **余分な再レンダリングの抑制** 16 | 17 | - **複数コンテナによる責務分割** 18 | 19 | `unreduxed` は opinionated なライブラリではないので、`hooks` と `context` の使い方さえ知っていれば使用することができます。 20 | 21 | ## `unstated-next` の問題点 22 | 23 | `unstated-next` は非常にシンプルなライブラリです。`context` を使用して `state` を他のコンポーネントに配信するだけです。しかし `context` が変化すると、それを購読するすべてのコンポーネントは再レンダリングされます。これは `context` を購読するコンポーネントの数が増えるにつれてパフォーマンスの問題を引き起こします。 24 | 25 | `unreduxed` はこの問題を解決しました。`context` で配信している `state` が変化しても、 `useContainer` で取得する値に変化がなければ、そのコンポーネントは再レンダリングされません。 26 | この挙動は `react-redux` の `useSelector` を参考にしています。 27 | 28 | ## Getting Started 29 | 30 | ### インストール 31 | 32 | ``` 33 | npm install unreduxed 34 | ``` 35 | 36 | ### コンテナ作成 37 | 38 | よくあるカスタムフックを定義して(コンテナフックと呼ぶことにしましょう)、 `unreduxed` に渡すことでコンテナを生成します。このコンテナフックが `return` する値がコンテナとして下位のコンポーネントに共有されます。 39 | `unreduxed` が返す値は、`ContainerProvider` と `useContainer` を含む固定長タプルです(`unstated-next` は `{ Provider, useContainer }` オブジェクトを返しますが、この違いは好みでしかありません)。 40 | 41 | ```tsx 42 | import React from "react"; 43 | import unreduxed from "unreduxed"; 44 | 45 | const useCounter = () => { 46 | const [count, setCount] = React.useState(0); 47 | 48 | const increment = React.useCallback(() => setCount(prev => prev + 1), []); 49 | const decrement = React.useCallback(() => setCount(prev => prev - 1), []); 50 | 51 | return { count, increment, decrement }; 52 | }; 53 | 54 | export const [ContainerProvider, useContainer] = unreduxed(useCounter); 55 | ``` 56 | 57 | あくまでただのカスタムフックなので、[フックのルール](https://reactjs.org/docs/hooks-rules.html)さえ守られていれば何でもできます(`useEffect` を使用したり、サードパーティ製フックを使用したり、`unreduxed` の別のコンテナを使用するなど)。 58 | 59 | ### `ContainerProvider` を配置する 60 | 61 | `ContainerProvider` は `useCounter` を内部で実行してステートを保持するコンポーネントです。ステートを共有したいコンポーネントツリーの最上位に配置します。この `Provider` の外ではステートを使用することはできません(`context` の使い方を知っていれば理解できるはずです)。 62 | 63 | ```tsx 64 | const Counter: React.FC = () => { 65 | return ( 66 | 67 | 68 | 69 | 70 | ); 71 | }; 72 | ``` 73 | 74 | ### `useContainer` で値を取り出す 75 | 76 | `useContainer` フックを使用して、コンテナから値を取り出します。 `useContainer` は引数として `selector` 関数を受け取ります。`selector` 関数は引数がコンテナ、戻り値がそこで使用したい値になるように定義します(`react-redux` の `useSeletor` と同じ使い方です)。 77 | 78 | ```tsx 79 | const Count: React.FC = () => { 80 | const count = useContainer(container => container.count); 81 | 82 | return

count: {count}

; 83 | }; 84 | 85 | const CountButtons: React.FC = () => { 86 | const increment = useContainer(container => container.increment); 87 | const decrement = useContainer(container => container.decrement); 88 | 89 | return ( 90 |
91 | 92 | 93 |
94 | ); 95 | }; 96 | ``` 97 | 98 | ここで `CountButtons` コンポーネントは `increment` と `decrement` をコンテナから取り出していますが、`count` は取り出していません。こうすることで関心のない `count` が変化しても再レンダリングされることを避けられます。これは通常の `context` の機能をそのまま使っている `unstated-next` では実現できないことです。 99 | 100 | ## API Reference 101 | 102 | ### **default exported function** 103 | 104 | #### 型定義 105 | 106 | ```ts 107 | function unreduxed(useHook: (initialState?: Init) => Container): readonly [ContainerProvider, useContainer]; 108 | ``` 109 | 110 | #### 使い方 111 | 112 | ```ts 113 | function useAwesomeHook(initialValue?: number) { 114 | const [value, setValue] = React.useState(initialValue ?? 0); 115 | return { value, setValue }; 116 | } 117 | 118 | const [ContainerProvider, useContainer] = unreduxed(useAwesomeHook); 119 | ``` 120 | 121 | #### 説明 122 | 123 | 値を返却するカスタムフック(コンテナフック)を定義して引数に渡すことでコンテナを生成します。コンテナフックには後述する `ContainerProvider` 経由で初期値を渡すことができます。ただし型定義上、`ContainerProvider` に初期値を渡すことを任意としているため、コンテナフックの引数は `undefined` である可能性を考慮に入れなければなりません。 124 | TypeScript を使用している場合は `undefined` を受け入れるようにしていないとコンパイルエラーとなります。 125 | 126 | ### **ContainerProvider** 127 | 128 | #### 型定義 129 | 130 | ```ts 131 | type ContainerProviderProps = ({ mock: C } | { initialState?: I }) & { 132 | children: React.ReactNode; 133 | }; 134 | 135 | const ContaierProvider: React.FC>; 136 | ``` 137 | 138 | #### 使い方 139 | 140 | ```tsx 141 | const App: React.FC = () => { 142 | return ( 143 | 144 | 145 | 146 | ); 147 | }; 148 | ``` 149 | 150 | #### 説明 151 | 152 | `props` のひとつである `initialState` に値を渡すと、コンテナフックの引数に初期値として渡されます。これは `unstated-next` の API を踏襲しています。 153 | 154 | また、`props` のひとつである `mock` に値を渡すと、コンテナフックは実行されなくなり、代わりに `mock` が `ContainerProvider` によって配信されることになります。 155 | 156 | ```tsx 157 | const MockProvider: React.FC = () => { 158 | const mock = { 159 | value: 10, 160 | setValue: () => { 161 | console.log("setValue() called."); 162 | }, 163 | }; 164 | 165 | return ( 166 | 167 | 168 | 169 | ); 170 | }; 171 | ``` 172 | 173 | これは Storybook のようなツールで見た目の確認を行う際に、任意のコンテナを注入できることを意味します。ただし、本番のアプリケーションで条件によって `mock` を渡したり `initialState` を渡すようなことは**絶対にしないで**ください。フックの実行順序が変わってしまうため、React がエラーを発生させます。TypeScript を使用している場合は、それらを同時に渡すとコンパイルエラーとなります。 174 | 175 | ### **useContainer** 176 | 177 | #### 型定義 178 | 179 | ```ts 180 | function useContainer(): Container; 181 | function useContainer(selector: (container: Container) => T, comparer?: (prev: T, next: T) => boolean): T; 182 | ``` 183 | 184 | #### 使い方 185 | 186 | ```tsx 187 | const ChildComponent: React.FC = () => { 188 | const value = useContainer(container => container.value); 189 | 190 | return {value} is awesome !; 191 | }; 192 | ``` 193 | 194 | #### 説明 195 | 196 | `react-redux` の `useSelector` の影響を受けたインターフェイスになっています。 197 | 198 | `useContainer` フックに引数を渡さずに使用すると、コンテナ全体を取得することができます。しかし、コンテナが単一の値だけを含んでいるとき以外は、使用したいコンテナの値の数だけ `useContainer` を使用するべきです。 199 | なぜなら、多くの場合コンテナフックの戻り値は毎回別のオブジェクトを返しているはずです([Getting Started](##Getting-Started) の例のように)。その場合、`useContainer` で毎回別のオブジェクトを取得してしまうことになるため、再レンダリングを避けられるという`unreduxed` の利点を活かすことができません。 200 | 201 | ```tsx 202 | const ChildComponent: React.FC = () => { 203 | const count = useContainer(container => container.count); 204 | const name = useContainer(container => container.name); 205 | 206 | return ( 207 |
208 |

Hello {name} !

209 |

Your count is {count} !

210 |
211 | ); 212 | }; 213 | ``` 214 | 215 | `useContainer` フックには第 2 引数として比較関数を渡すことができます。これによって直前の値と次の値の等価性の判定をカスタマイズすることができます(`react-redux` の `useSelector` と同じ API)。指定されなかった場合は `===` による比較が行われます。 216 | 217 | ```tsx 218 | const ChildComponent: React.FC = () => { 219 | const user = useContainer( 220 | container => container.user, 221 | (prev, next) => prev.userId === next.userId, 222 | ); 223 | 224 | return ( 225 |

226 | {user.userId}: {user.userName} 227 |

228 | ); 229 | }; 230 | ``` 231 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # unreduxed 2 | 3 | > a library to never think about re-rendering of React components ever again 4 | 5 | `unreduxed` is a state management library for React. 6 | This is inspired by 7 | 8 | - `react-redux` https://react-redux.js.org/ 9 | - `unstated-next` https://github.com/jamiebuilds/unstated-next 10 | 11 | ## features 12 | 13 | - **simpler API** 14 | 15 | - **suppress extra re-rendering** 16 | 17 | - **split responsibilities by multiple containers** 18 | 19 | `unreduxed` is **not** opinionated library, so You just need to know usage of `hooks` and `context`. 20 | 21 | ## a problem of `unstated-next` 22 | 23 | `unstated-next` is very simple library. It just uses `context` to deliver `state` to other components. But when `context` changes, all components that subscribe to it will be re-rendered. This causes perfomance issues as the number of components subscribing to `context` increases. 24 | 25 | `unreduxed` solves this problem. If `state` provided by `context` changes, but the value obtained by `useContainer` does not change, the component will not be re-rendered. This behavior is inspired by the `useSelector` of `react-redux`. 26 | 27 | ## Getting Started 28 | 29 | ### install 30 | 31 | ``` 32 | npm install unreduxed 33 | ``` 34 | 35 | ### create a container 36 | 37 | We create a container by defining a common custom hook (let's call it a container hook) and passing it to `unreduxed`. The value that this container hook returns will be shared as a container with child components. The value returned by `unreduxed` is a fixed-length tuple containing `ContainerProvider` and `useContainer` (`unstated-next` returns a `{ Provider, useContainer }` object, but this difference is just a preference). 38 | 39 | ```tsx 40 | import React from "react"; 41 | import unreduxed from "unreduxed"; 42 | 43 | const useCounter = () => { 44 | const [count, setCount] = React.useState(0); 45 | 46 | const increment = React.useCallback(() => setCount(prev => prev + 1), []); 47 | const decrement = React.useCallback(() => setCount(prev => prev - 1), []); 48 | 49 | return { count, increment, decrement }; 50 | }; 51 | 52 | export const [ContainerProvider, useContainer] = unreduxed(useCounter); 53 | ``` 54 | 55 | The container hook is just a custom hook, so we can do anything as long as [the hook rules](https://reactjs.org/docs/hooks-rules.html) are followed (use `useEffect`, use a third-party hook, use another container by `unreduxed`, etc). 56 | 57 | ### Place `ContainerProvider` 58 | 59 | `ContainerProvider` is a component that internally executes `useCounter` to hold its state. Place it at the top of the component tree where you want to share the state. We can't use states outside of this `Provider` (you should know how to use `context`). 60 | 61 | ```tsx 62 | const Counter: React.FC = () => { 63 | return ( 64 | 65 | 66 | 67 | 68 | ); 69 | }; 70 | ``` 71 | 72 | ### Retrieve a value with `useContainer` 73 | 74 | We can use `useContainer` hook to retrieve the value from the container. `useContainer` takes the `selector` function as an argument. The `selector` function is defined so that the argument is a container and the return value is what you want to use there (same usage as `useSelector` in `react-redux`). 75 | 76 | ```tsx 77 | const Count: React.FC = () => { 78 | const count = useContainer(container => container.count); 79 | 80 | return

count: {count}

; 81 | }; 82 | 83 | const CountButtons: React.FC = () => { 84 | const increment = useContainer(container => container.increment); 85 | const decrement = useContainer(container => container.decrement); 86 | 87 | return ( 88 |
89 | 90 | 91 |
92 | ); 93 | }; 94 | ``` 95 | 96 | Here the `CountButtons` component is retrieving `increment` and `decrement` from the container, but not `count`. This will prevent `CountButtons` from re-rendering when the uninsteresting `count` changes. This is not possible with `unstated-next`, which uses the normal functionality of `context` as is. 97 | 98 | ## API Reference 99 | 100 | ### **default exported function** 101 | 102 | #### type definition 103 | 104 | ```ts 105 | function unreduxed(useHook: (initialState?: Init) => Container): readonly [ContainerProvider, useContainer]; 106 | ``` 107 | 108 | #### usage 109 | 110 | ```ts 111 | function useAwesomeHook(initialValue?: number) { 112 | const [value, setValue] = React.useState(initialValue ?? 0); 113 | return { value, setValue }; 114 | } 115 | 116 | const [ContainerProvider, useContainer] = unreduxed(useAwesomeHook); 117 | ``` 118 | 119 | #### description 120 | 121 | You create a container by defining a custom hook (container hook) that returns a value and passing it as an argument. You can pass a initial value to the container hook via `ContainerProvider` described later. However, since the type definition makes it optional to pass the initial value to `ContainerProvider`, the argument of the container hook must take into account the possibility of `undefined`. 122 | If you are using TypeScript, you will get a compile error if you do not accept `undefined`. 123 | 124 | ### **ContainerProvider** 125 | 126 | #### type definition 127 | 128 | ```ts 129 | type ContainerProviderProps = ({ mock: C } | { initialState?: I }) & { 130 | children: React.ReactNode; 131 | }; 132 | 133 | const ContaierProvider: React.FC>; 134 | ``` 135 | 136 | #### usage 137 | 138 | ```tsx 139 | const App: React.FC = () => { 140 | return ( 141 | 142 | 143 | 144 | ); 145 | }; 146 | ``` 147 | 148 | #### description 149 | 150 | If you pass a value to `initialState`, which is one of `props`, it will be passed as an initial value to the argument of the container hook. It follows the `unstated-next` API. 151 | 152 | Also, if you pass a value to `mock`, which is one of `props`, the container hook will not be executed and instead `mock` will be provided by `ContainerProvider`. 153 | 154 | ```tsx 155 | const MockProvider: React.FC = () => { 156 | const mock = { 157 | value: 10, 158 | setValue: () => { 159 | console.log("setValue() called."); 160 | }, 161 | }; 162 | 163 | return ( 164 | 165 | 166 | 167 | ); 168 | }; 169 | ``` 170 | 171 | This means that you can inject any container when looking at it with a tool a tool like Storybook. However, **never** pass `mock` or `initialState` depending on the conditions in your production application. React raises an error because the hooks are executed in a different order. If you are using TypeScript, passing them at the same time will result in a compilation error. 172 | 173 | ### **useContainer** 174 | 175 | #### type definition 176 | 177 | ```ts 178 | function useContainer(): Container; 179 | function useContainer(selector: (container: Container) => T, comparer?: (prev: T, next: T) => boolean): T; 180 | ``` 181 | 182 | #### usage 183 | 184 | ```tsx 185 | const ChildComponent: React.FC = () => { 186 | const value = useContainer(container => container.value); 187 | 188 | return {value} is awesome !; 189 | }; 190 | ``` 191 | 192 | #### description 193 | 194 | This interface is inspired by the `useSelector` of` react-redux`. 195 | 196 | You can get the entire container by using the `useContainer` hook without any arguments. However, you should use it for as many container values as you want to use, except when the container is returning only a single value. 197 | Because in most cases the return value of a container hook should return a different object each time (as in the [Getting Started](##Getting-Started) example). In that case, you can't take advantage of `unreduxed`, which avoids re-rendering, because you end up getting another object with` useContainer` each time. 198 | 199 | ```tsx 200 | const ChildComponent: React.FC = () => { 201 | const count = useContainer(container => container.count); 202 | const name = useContainer(container => container.name); 203 | 204 | return ( 205 |
206 |

Hello {name} !

207 |

Your count is {count} !

208 |
209 | ); 210 | }; 211 | ``` 212 | 213 | You can pass a comparer function as the second argument to the `useContainer` hook. This allows you to customize the determination of equivalence between the previous and next values (same API as `useSelector` in` react-redux`). If not specified, a comparison is made by `===`. 214 | 215 | ```tsx 216 | const ChildComponent: React.FC = () => { 217 | const user = useContainer( 218 | container => container.user, 219 | (prev, next) => prev.userId === next.userId, 220 | ); 221 | 222 | return ( 223 |

224 | {user.userId}: {user.userName} 225 |

226 | ); 227 | }; 228 | ``` 229 | -------------------------------------------------------------------------------- /example/.env: -------------------------------------------------------------------------------- 1 | SKIP_PREFLIGHT_CHECK=true -------------------------------------------------------------------------------- /example/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | 25 | .eslintcache 26 | 27 | package-lock.json -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | # Getting Started with Create React App 2 | 3 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 4 | 5 | ## Available Scripts 6 | 7 | In the project directory, you can run: 8 | 9 | ### `npm start` 10 | 11 | Runs the app in the development mode.\ 12 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 13 | 14 | The page will reload if you make edits.\ 15 | You will also see any lint errors in the console. 16 | 17 | ### `npm test` 18 | 19 | Launches the test runner in the interactive watch mode.\ 20 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 21 | 22 | ### `npm run build` 23 | 24 | Builds the app for production to the `build` folder.\ 25 | It correctly bundles React in production mode and optimizes the build for the best performance. 26 | 27 | The build is minified and the filenames include the hashes.\ 28 | Your app is ready to be deployed! 29 | 30 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 31 | 32 | ### `npm run eject` 33 | 34 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!** 35 | 36 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. 37 | 38 | Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. 39 | 40 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. 41 | 42 | ## Learn More 43 | 44 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). 45 | 46 | To learn React, check out the [React documentation](https://reactjs.org/). 47 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@testing-library/jest-dom": "^5.11.5", 7 | "@testing-library/react": "^11.1.2", 8 | "@testing-library/user-event": "^12.2.2", 9 | "@types/classnames": "^2.2.11", 10 | "@types/jest": "^26.0.15", 11 | "@types/node": "^12.19.4", 12 | "@types/react": "^16.9.56", 13 | "@types/react-dom": "^16.9.9", 14 | "@types/react-router-dom": "^5.1.6", 15 | "@types/uuid": "^8.3.0", 16 | "classnames": "^2.2.6", 17 | "react": "^17.0.1", 18 | "react-dom": "^17.0.1", 19 | "react-router-dom": "^5.2.0", 20 | "react-scripts": "^4.0.1", 21 | "typescript": "^4.0.5", 22 | "unreduxed": "file:..", 23 | "uuid": "^8.3.1", 24 | "web-vitals": "^0.2.4" 25 | }, 26 | "scripts": { 27 | "start": "react-scripts start", 28 | "build": "react-scripts build", 29 | "test": "react-scripts test", 30 | "eject": "react-scripts eject" 31 | }, 32 | "eslintConfig": { 33 | "extends": [ 34 | "react-app", 35 | "react-app/jest" 36 | ] 37 | }, 38 | "browserslist": { 39 | "production": [ 40 | ">0.2%", 41 | "not dead", 42 | "not op_mini all" 43 | ], 44 | "development": [ 45 | "last 1 chrome version", 46 | "last 1 firefox version", 47 | "last 1 safari version" 48 | ] 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /example/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/y-hiraoka/unreduxed/c8740252ed1292087f91a503d07535293de7e99a/example/public/favicon.ico -------------------------------------------------------------------------------- /example/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | React App 28 | 29 | 30 | 31 |
32 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /example/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/y-hiraoka/unreduxed/c8740252ed1292087f91a503d07535293de7e99a/example/public/logo192.png -------------------------------------------------------------------------------- /example/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/y-hiraoka/unreduxed/c8740252ed1292087f91a503d07535293de7e99a/example/public/logo512.png -------------------------------------------------------------------------------- /example/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /example/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /example/src/App.module.css: -------------------------------------------------------------------------------- 1 | .root { 2 | display: grid; 3 | grid-template-rows: 75px 1fr; 4 | height: 100vh; 5 | } 6 | 7 | .header { 8 | position: sticky; 9 | top: 0; 10 | display: flex; 11 | align-items: center; 12 | gap: 20px; 13 | padding: 20px 40px; 14 | height: 75px; 15 | background-color: #111111; 16 | } 17 | 18 | .headerLink { 19 | color: #ffffff; 20 | font-size: 25px; 21 | } 22 | 23 | .main { 24 | flex: 1; 25 | height: 100%; 26 | overflow: scroll; 27 | background-color: #fefefefe; 28 | } -------------------------------------------------------------------------------- /example/src/App.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Link, Route, Switch } from "react-router-dom"; 3 | import { Counter } from "./Counter"; 4 | import { ReduxLike } from "./ReduxLike"; 5 | import { TodoApp } from "./Todo"; 6 | import styles from "./App.module.css"; 7 | 8 | function App() { 9 | return ( 10 |
11 |
12 | 13 | counter 14 | 15 | 16 | redux-like 17 | 18 | 19 | todo app 20 | 21 |
22 |
23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 |
35 |
36 | ); 37 | } 38 | 39 | export default App; 40 | -------------------------------------------------------------------------------- /example/src/Counter/container.ts: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import unreduxed from "unreduxed"; 3 | 4 | const useCounter = () => { 5 | const [count, setCount] = React.useState(0); 6 | 7 | const increment = React.useCallback(() => setCount(prev => prev + 1), []); 8 | const decrement = React.useCallback(() => setCount(prev => prev - 1), []); 9 | 10 | return { count, increment, decrement }; 11 | }; 12 | 13 | export const [ContainerProvider, useContainer] = unreduxed(useCounter); 14 | -------------------------------------------------------------------------------- /example/src/Counter/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { ContainerProvider, useContainer } from "./container"; 3 | import { randomlyGetColor } from "../utils/randomlyGetColor"; 4 | import styles from "./styles.module.css"; 5 | 6 | export const Counter: React.FC = () => { 7 | return ( 8 | 9 |
10 | 11 | 12 |
13 |
14 | ); 15 | }; 16 | 17 | const Count: React.FC = () => { 18 | const count = useContainer(container => container.count); 19 | 20 | // When this component is re-rendered, it generate a new color. 21 | const style = { color: randomlyGetColor() }; 22 | 23 | return ( 24 |

25 | count: {count} 26 |

27 | ); 28 | }; 29 | 30 | const CountButtons: React.FC = () => { 31 | const increment = useContainer(container => container.increment); 32 | const decrement = useContainer(container => container.decrement); 33 | 34 | // When this component is re-rendered, it generate a new color. 35 | const style = { color: randomlyGetColor() }; 36 | 37 | return ( 38 |
39 | 42 | 45 |
46 | ); 47 | }; 48 | -------------------------------------------------------------------------------- /example/src/Counter/styles.module.css: -------------------------------------------------------------------------------- 1 | .root { 2 | display: flex; 3 | justify-content: center; 4 | align-items: center; 5 | flex-direction: column; 6 | height: 100%; 7 | } 8 | 9 | .countText { 10 | font-size: 30px; 11 | } 12 | 13 | .countValue { 14 | font-size: 100px; 15 | margin-left: 20px; 16 | } 17 | 18 | .countButtons { 19 | display: flex; 20 | gap: 20px; 21 | } 22 | 23 | .countButton { 24 | background: none; 25 | border: 2px solid #111111; 26 | border-radius: 6px; 27 | padding: 15px 20px; 28 | font-size: 20px; 29 | } -------------------------------------------------------------------------------- /example/src/ReduxLike/container.ts: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import unreduxed from "unreduxed"; 3 | import { Country } from "./country"; 4 | 5 | type State = { 6 | text: string; 7 | countries: Country[]; 8 | filtered: Country[]; 9 | }; 10 | 11 | type Action = 12 | | { type: "textChanged"; payload: string } 13 | | { type: "countriesFetched"; payload: Country[] } 14 | | { type: "filter"; payload: Country[] }; 15 | 16 | const initialState: State = { 17 | text: "", 18 | countries: [], 19 | filtered: [], 20 | }; 21 | 22 | const reducer: React.Reducer = (state, action) => { 23 | switch (action.type) { 24 | case "textChanged": 25 | return { 26 | ...state, 27 | text: action.payload, 28 | }; 29 | 30 | case "countriesFetched": 31 | return { ...state, countries: action.payload }; 32 | 33 | case "filter": 34 | return { ...state, filtered: action.payload }; 35 | } 36 | }; 37 | 38 | const useReduxLike = () => { 39 | const [state, dispatch] = React.useReducer(reducer, initialState); 40 | 41 | React.useEffect(() => { 42 | (async () => { 43 | const fetched = await fetch("https://restcountries.eu/rest/v2/all"); 44 | const countries: Country[] = await fetched.json(); 45 | 46 | dispatch({ type: "countriesFetched", payload: countries }); 47 | })(); 48 | }, []); 49 | 50 | React.useEffect(() => { 51 | dispatch({ 52 | type: "filter", 53 | payload: state.countries.filter(country => 54 | country.name.toLowerCase().includes(state.text.toLowerCase() ?? ""), 55 | ), 56 | }); 57 | }, [state.countries, state.text]); 58 | 59 | return { state, dispatch }; 60 | }; 61 | 62 | const [ContainerProvider, useContainer] = unreduxed(useReduxLike); 63 | 64 | function useSelector(selector: (state: State) => T) { 65 | return useContainer(container => selector(container.state)); 66 | } 67 | 68 | function useDispatch() { 69 | return useContainer(container => container.dispatch); 70 | } 71 | 72 | export { ContainerProvider, useSelector, useDispatch }; 73 | -------------------------------------------------------------------------------- /example/src/ReduxLike/country.ts: -------------------------------------------------------------------------------- 1 | export interface Country { 2 | name: string; 3 | topLevelDomain: string[]; 4 | alpha2Code: string; 5 | alpha3Code: string; 6 | callingCodes: string[]; 7 | capital: string; 8 | altSpellings: string[]; 9 | region: string; 10 | subregion: string; 11 | population: number; 12 | latlng: number[]; 13 | demonym: string; 14 | area: number | null; 15 | gini: number | null; 16 | timezones: string[]; 17 | borders: string[]; 18 | nativeName: string; 19 | numericCode: string | null; 20 | currencies: Currency[]; 21 | languages: Language[]; 22 | translations: Translations; 23 | flag: string; 24 | regionalBlocs: RegionalBloc[]; 25 | cioc?: string | null; 26 | } 27 | 28 | interface RegionalBloc { 29 | acronym: string; 30 | name: string; 31 | otherAcronyms: string[]; 32 | otherNames: string[]; 33 | } 34 | 35 | interface Translations { 36 | de: string | null; 37 | es: string | null; 38 | fr: string | null; 39 | ja: string | null; 40 | it: string | null; 41 | br: string | null; 42 | pt: string | null; 43 | nl: string | null; 44 | hr: string | null; 45 | fa: string | null; 46 | } 47 | 48 | interface Language { 49 | iso639_1: string | null; 50 | iso639_2: string; 51 | name: string; 52 | nativeName: string; 53 | } 54 | 55 | interface Currency { 56 | code: string | null; 57 | name: string; 58 | symbol: string | null; 59 | } 60 | -------------------------------------------------------------------------------- /example/src/ReduxLike/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { ContainerProvider, useSelector, useDispatch } from "./container"; 3 | import { Country as ICountry } from "./country"; 4 | import styles from "./styles.module.css"; 5 | 6 | export const ReduxLike: React.FC = () => { 7 | return ( 8 | 9 |
10 | 11 | 12 |
13 |
14 | ); 15 | }; 16 | 17 | const SearchInput: React.FC = () => { 18 | const text = useSelector(state => state.text); 19 | const dispatch = useDispatch(); 20 | 21 | return ( 22 |
23 | dispatch({ type: "textChanged", payload: e.target.value })} 29 | /> 30 |
31 | ); 32 | }; 33 | 34 | const Countries: React.FC = () => { 35 | const countries = useSelector(state => state.filtered); 36 | 37 | return ( 38 |
39 | {countries.map(country => ( 40 | 41 | ))} 42 |
43 | ); 44 | }; 45 | 46 | const Country: React.FC<{ country: ICountry }> = ({ country }) => { 47 | return ( 48 |
49 | {country.name} 50 |

{country.name}

51 |
52 | ); 53 | }; 54 | -------------------------------------------------------------------------------- /example/src/ReduxLike/styles.module.css: -------------------------------------------------------------------------------- 1 | .root { 2 | position: relative; 3 | display: flex; 4 | align-items: center; 5 | flex-direction: column; 6 | padding: 0 20px; 7 | } 8 | 9 | .search { 10 | position: sticky; 11 | top: 0; 12 | width: 100%; 13 | display: flex; 14 | justify-content: center; 15 | align-items: center; 16 | padding: 20px; 17 | } 18 | 19 | .searchInput { 20 | width: 100%; 21 | max-width: 500px; 22 | height: 45px; 23 | padding: 0 10px; 24 | font-size: 16px; 25 | } 26 | 27 | .countries { 28 | display: flex; 29 | gap: 20px; 30 | flex-flow: row wrap; 31 | justify-content: center; 32 | } 33 | 34 | .country { 35 | width: 260px; 36 | box-shadow:0 1px 6px 0 rgba(0,0,0,0.4); 37 | display: flex; 38 | flex-direction: column; 39 | align-items: center; 40 | padding: 30px; 41 | border-radius: 6px; 42 | } 43 | 44 | .countryFlag{ 45 | width: 100px; 46 | } 47 | -------------------------------------------------------------------------------- /example/src/Todo/container.ts: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { v4 as uuid } from "uuid"; 3 | import unreduxed from "unreduxed"; 4 | 5 | export type Todo = { 6 | id: string; 7 | content: string; 8 | complete: boolean; 9 | }; 10 | 11 | const getId = () => uuid(); 12 | 13 | const useTodo = () => { 14 | const [todos, setTodos] = React.useState([]); 15 | 16 | const add = React.useCallback((content: string) => { 17 | const id = getId(); 18 | setTodos(prev => [...prev, { id, content, complete: false }]); 19 | }, []); 20 | 21 | const remove = React.useCallback((id: string) => { 22 | setTodos(prev => prev.filter(todo => todo.id !== id)); 23 | }, []); 24 | 25 | const changeComplete = React.useCallback((id: string, complete: boolean) => { 26 | setTodos(todos => 27 | todos.map(todo => { 28 | if (todo.id === id) return { ...todo, complete }; 29 | else return todo; 30 | }), 31 | ); 32 | }, []); 33 | 34 | React.useEffect(() => { 35 | const todosStr = localStorage.getItem("todos"); 36 | 37 | if (!todosStr) return; 38 | 39 | setTodos(JSON.parse(todosStr)); 40 | }, []); 41 | 42 | React.useEffect(() => { 43 | const todosStr = JSON.stringify(todos); 44 | localStorage.setItem("todos", todosStr); 45 | }, [todos]); 46 | 47 | return { todos, add, remove, changeComplete }; 48 | }; 49 | 50 | export const [TodoProvider, useTodoContainer] = unreduxed(useTodo); 51 | -------------------------------------------------------------------------------- /example/src/Todo/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import classnames from "classnames"; 3 | import { Todo as ITodo, TodoProvider, useTodoContainer } from "./container"; 4 | import styles from "./styles.module.css"; 5 | 6 | export const TodoApp: React.FC = () => { 7 | return ( 8 | 9 |
10 | 11 | 12 |
13 |
14 | ); 15 | }; 16 | 17 | const AddTodo: React.FC = () => { 18 | const add = useTodoContainer(container => container.add); 19 | const [content, setContent] = React.useState(""); 20 | 21 | const submit = (e: React.FormEvent) => { 22 | e.preventDefault(); 23 | 24 | if (content) { 25 | add(content); 26 | setContent(""); 27 | } 28 | }; 29 | 30 | return ( 31 |
32 | setContent(e.target.value)} 36 | placeholder="input your todo..." 37 | /> 38 | 41 |
42 | ); 43 | }; 44 | 45 | const Todos: React.FC = () => { 46 | const todos = useTodoContainer(container => container.todos); 47 | 48 | return ( 49 |
50 | {todos.map(todo => ( 51 | 52 | ))} 53 |
54 | ); 55 | }; 56 | 57 | const Todo: React.FC<{ todo: ITodo }> = React.memo(({ todo }) => { 58 | const changeComplete = useTodoContainer(container => container.changeComplete); 59 | const remove = useTodoContainer(container => container.remove); 60 | 61 | return ( 62 |
63 | changeComplete(todo.id, e.target.checked)} 68 | /> 69 |

{todo.content}

70 | 73 |
74 | ); 75 | }); 76 | -------------------------------------------------------------------------------- /example/src/Todo/styles.module.css: -------------------------------------------------------------------------------- 1 | .root { 2 | display: flex; 3 | flex-direction: column; 4 | align-items: center; 5 | max-width: 500px; 6 | width: 100%; 7 | margin: auto; 8 | padding: 10px; 9 | } 10 | 11 | .addForm { 12 | position: sticky; 13 | top: 10px; 14 | z-index: 1; 15 | height: 45px; 16 | width: 100%; 17 | display: grid; 18 | grid-template-columns: 1fr 50px; 19 | font-size: 20px; 20 | border-radius: 5px; 21 | border: 1px solid #afafaf; 22 | overflow: hidden; 23 | } 24 | 25 | .addInput { 26 | padding: 0 10px; 27 | font-size: 15px; 28 | border:none; 29 | } 30 | 31 | .addButton { 32 | background-color: #111111; 33 | color: #fff; 34 | border: none; 35 | } 36 | 37 | .todos { 38 | width: 100%; 39 | display: grid; 40 | grid-template-columns: 1fr; 41 | gap: 20px; 42 | margin-top: 10px; 43 | } 44 | 45 | .todo { 46 | width: 100%; 47 | display: grid; 48 | grid-template-columns: 20px 1fr 20px; 49 | gap: 6px; 50 | align-items: center; 51 | border-radius: 5px; 52 | padding: 10px 15px; 53 | box-shadow: 0 1px 6px 0 rgba(0,0,0,0.4); 54 | min-height: 60px; 55 | background-color: #efefef; 56 | } 57 | 58 | .todoDone { 59 | opacity: .5; 60 | } 61 | 62 | .todoContent { 63 | overflow-wrap: anywhere; 64 | } 65 | 66 | .todoComplete { 67 | cursor: pointer; 68 | } 69 | 70 | .todoRemove { 71 | cursor: pointer; 72 | background-color: transparent; 73 | border: none; 74 | font-size: 25px; 75 | color: rgb(208, 1, 71); 76 | } 77 | 78 | -------------------------------------------------------------------------------- /example/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", 4 | "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif; 5 | -webkit-font-smoothing: antialiased; 6 | -moz-osx-font-smoothing: grayscale; 7 | } 8 | 9 | code { 10 | font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", monospace; 11 | } 12 | 13 | *, 14 | *::before, 15 | *::after { 16 | box-sizing: border-box; 17 | margin: 0; 18 | padding: 0; 19 | } 20 | -------------------------------------------------------------------------------- /example/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import { BrowserRouter } from "react-router-dom"; 4 | import "./index.css"; 5 | import App from "./App"; 6 | import reportWebVitals from "./reportWebVitals"; 7 | 8 | ReactDOM.render( 9 | 10 | 11 | 12 | 13 | , 14 | document.getElementById("root"), 15 | ); 16 | 17 | // If you want to start measuring performance in your app, pass a function 18 | // to log results (for example: reportWebVitals(console.log)) 19 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals 20 | reportWebVitals(); 21 | -------------------------------------------------------------------------------- /example/src/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /example/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /example/src/reportWebVitals.ts: -------------------------------------------------------------------------------- 1 | import { ReportHandler } from "web-vitals"; 2 | 3 | const reportWebVitals = (onPerfEntry?: ReportHandler) => { 4 | if (onPerfEntry && onPerfEntry instanceof Function) { 5 | import("web-vitals").then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { 6 | getCLS(onPerfEntry); 7 | getFID(onPerfEntry); 8 | getFCP(onPerfEntry); 9 | getLCP(onPerfEntry); 10 | getTTFB(onPerfEntry); 11 | }); 12 | } 13 | }; 14 | 15 | export default reportWebVitals; 16 | -------------------------------------------------------------------------------- /example/src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import "@testing-library/jest-dom"; 6 | -------------------------------------------------------------------------------- /example/src/utils/randomlyGetColor.ts: -------------------------------------------------------------------------------- 1 | const getRandom = () => Math.floor(Math.random() * 255); 2 | 3 | export function randomlyGetColor() { 4 | return `rgb(${getRandom()},${getRandom()},${getRandom()})`; 5 | } 6 | -------------------------------------------------------------------------------- /example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "module": "esnext", 17 | "moduleResolution": "node", 18 | "resolveJsonModule": true, 19 | "isolatedModules": true, 20 | "noEmit": true, 21 | "jsx": "react-jsx" 22 | }, 23 | "include": [ 24 | "src" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | roots: ["/src"], 3 | testMatch: ["**/__tests__/**/*.+(ts|tsx|js)", "**/?(*.)+(spec|test).+(ts|tsx|js)"], 4 | transform: { 5 | "^.+\\.(ts|tsx)$": "ts-jest", 6 | }, 7 | }; 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "unreduxed", 3 | "version": "1.0.1", 4 | "main": "dist/index.cjs.js", 5 | "module": "dist/index.js", 6 | "types": "dist/index.d.ts", 7 | "files": [ 8 | "dist" 9 | ], 10 | "description": "a lightweight and simplest state management library for React.", 11 | "repository": { 12 | "url": "https://github.com/y-hiraoka/unreduxed" 13 | }, 14 | "sideEffects": false, 15 | "scripts": { 16 | "format": "prettier --write '**/*.{js,ts,tsx}'", 17 | "build": "rollup -c", 18 | "test": "jest" 19 | }, 20 | "keywords": [ 21 | "react", 22 | "state", 23 | "management", 24 | "redux", 25 | "unstated-next" 26 | ], 27 | "author": "https://github.com/y-hiraoka", 28 | "license": "MIT", 29 | "devDependencies": { 30 | "@rollup/plugin-node-resolve": "^10.0.0", 31 | "@testing-library/react": "^11.2.2", 32 | "@testing-library/react-hooks": "^3.4.2", 33 | "@types/jest": "^26.0.15", 34 | "@types/react": "^16.9.56", 35 | "@types/react-dom": "^16.9.9", 36 | "husky": "^4.3.0", 37 | "jest": "^26.6.3", 38 | "lint-staged": "^10.5.1", 39 | "prettier": "^2.1.2", 40 | "react": "^17.0.1", 41 | "react-dom": "^17.0.1", 42 | "react-test-renderer": "^17.0.1", 43 | "rollup": "^2.33.1", 44 | "rollup-plugin-dts": "^1.4.13", 45 | "rollup-plugin-typescript2": "^0.29.0", 46 | "ts-jest": "^26.4.4", 47 | "typescript": "^4.0.5" 48 | }, 49 | "peerDependencies": { 50 | "react": ">=16.8.0", 51 | "@types/react": ">=16.8.6" 52 | }, 53 | "peerDependenciesMeta": { 54 | "@types/react": { 55 | "optional": true 56 | } 57 | }, 58 | "husky": { 59 | "hooks": { 60 | "pre-commit": "lint-staged" 61 | } 62 | }, 63 | "lint-staged": { 64 | "*.{js,ts,tsx}": "prettier --write" 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import resolve from "@rollup/plugin-node-resolve"; 2 | import typescript from "rollup-plugin-typescript2"; 3 | import dts from "rollup-plugin-dts"; 4 | 5 | export default [ 6 | { 7 | input: "src/index.ts", 8 | output: { 9 | dir: "dist", 10 | }, 11 | plugins: [typescript({ useTsconfigDeclarationDir: true, clean: true }), resolve()], 12 | // indicate which modules should be treated as external 13 | external: ["react"], 14 | }, 15 | { 16 | input: "src/index.ts", 17 | output: { 18 | file: "dist/index.cjs.js", 19 | format: "cjs", 20 | exports: "default", 21 | }, 22 | plugins: [typescript({ useTsconfigDeclarationDir: true, clean: true }), resolve()], 23 | // indicate which modules should be treated as external 24 | external: ["react"], 25 | }, 26 | { 27 | input: "./src/index.ts", 28 | output: [{ file: "dist/index.d.ts", format: "es" }], 29 | plugins: [dts()], 30 | }, 31 | ]; 32 | -------------------------------------------------------------------------------- /src/Notifier.ts: -------------------------------------------------------------------------------- 1 | type Listener = (container: C) => void; 2 | 3 | export class Notifier { 4 | private listeners: Listener[] = []; 5 | 6 | constructor(public container: C) {} 7 | 8 | notify() { 9 | for (let i = 0; i < this.listeners.length; i++) { 10 | this.listeners[i](this.container); 11 | } 12 | } 13 | 14 | register(listener: Listener): boolean { 15 | if (!this.listeners.includes(listener)) { 16 | this.listeners.push(listener); 17 | return true; 18 | } else { 19 | return false; 20 | } 21 | } 22 | 23 | unregister(listener: Listener) { 24 | this.listeners = this.listeners.filter(item => item !== listener); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/__tests__/Notifier.test.ts: -------------------------------------------------------------------------------- 1 | import { Notifier } from "../Notifier"; 2 | 3 | test("Only one function with the same reference", () => { 4 | let counter = 0; 5 | 6 | const listener = (container: number) => (counter = counter + container); 7 | 8 | const notifier = new Notifier(100); 9 | 10 | notifier.register(listener); 11 | notifier.register(listener); 12 | 13 | // 0 + 100 14 | notifier.notify(); 15 | 16 | expect(counter).toBe(100); 17 | }); 18 | 19 | test("can unregister the listener", () => { 20 | let counter = 0; 21 | 22 | const listener = (container: number) => (counter = counter + container); 23 | 24 | const notifier = new Notifier(100); 25 | 26 | notifier.register(listener); 27 | notifier.unregister(listener); 28 | 29 | notifier.notify(); 30 | 31 | expect(counter).toBe(0); 32 | }); 33 | -------------------------------------------------------------------------------- /src/__tests__/createContainerProvider.test.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { createContainerProvider } from "../createContainerProvider"; 3 | import { EMPTY } from "../empty"; 4 | import { Notifier } from "../Notifier"; 5 | import { renderHook, act } from "@testing-library/react-hooks"; 6 | 7 | const useTestHook = ({ init }: { init: number }) => { 8 | const [count, setCount] = React.useState(init ?? 0); 9 | 10 | const increment = React.useCallback(() => setCount(prev => prev + 1), []); 11 | const decrement = React.useCallback(() => setCount(prev => prev - 1), []); 12 | 13 | return { count, increment, decrement }; 14 | }; 15 | 16 | type ContainerType = ReturnType; 17 | type TestNotifier = Notifier; 18 | 19 | const context = React.createContext(EMPTY); 20 | 21 | const ContainerProvider = createContainerProvider(useTestHook, context); 22 | 23 | const useConatinerMocked = () => React.useContext(context); 24 | 25 | test("ContainerProvider provides Notifier instance", () => { 26 | const { result } = renderHook( 27 | () => { 28 | const notifier = useConatinerMocked(); 29 | return { notifier }; 30 | }, 31 | { wrapper: ContainerProvider }, 32 | ); 33 | 34 | const notifier = result.current.notifier; 35 | expect(notifier).toBeInstanceOf(Notifier); 36 | 37 | act(() => { 38 | (notifier as TestNotifier).container.increment(); 39 | }); 40 | 41 | expect((result.current.notifier as TestNotifier).container.count).toBe(1); 42 | expect(result.current.notifier).toBe(notifier); 43 | }); 44 | 45 | test("Pass a initial state to ContainerProvider", () => { 46 | const PassingInitStateProvder = (props: { children: React.ReactNode }) => ( 47 | 48 | ); 49 | 50 | const { result } = renderHook( 51 | () => { 52 | const notifier = useConatinerMocked(); 53 | return { notifier }; 54 | }, 55 | { wrapper: PassingInitStateProvder }, 56 | ); 57 | 58 | expect((result.current.notifier as TestNotifier).container.count).toBe(100); 59 | }); 60 | 61 | test("Pass a mocked container to ContainerProvider", () => { 62 | const mock: ContainerType = { 63 | count: 200, 64 | increment: () => {}, 65 | decrement: () => {}, 66 | }; 67 | 68 | const PassingMockedProvider = (props: { children: React.ReactNode }) => ( 69 | 70 | ); 71 | 72 | const { result } = renderHook( 73 | () => { 74 | const notifier = useConatinerMocked(); 75 | return { notifier }; 76 | }, 77 | { wrapper: PassingMockedProvider }, 78 | ); 79 | 80 | expect((result.current.notifier as TestNotifier).container.count).toBe(200); 81 | }); 82 | -------------------------------------------------------------------------------- /src/__tests__/createUseContainer.test.ts: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { act, renderHook } from "@testing-library/react-hooks"; 3 | import { createUseContainer } from "../createUseContainer"; 4 | import { EMPTY } from "../empty"; 5 | import { Notifier } from "../Notifier"; 6 | 7 | const initialValue = [0, 1, 2, 3, 4, 5]; 8 | const notifier = new Notifier(initialValue); 9 | const context = React.createContext(notifier); 10 | 11 | const useContainer = createUseContainer(context); 12 | 13 | test("get whole container.", () => { 14 | const { result } = renderHook(() => { 15 | const value = useContainer(); 16 | return { value }; 17 | }); 18 | 19 | expect(result.current.value).toBe(initialValue); 20 | }); 21 | 22 | test("select a value from container", () => { 23 | const { result } = renderHook(() => { 24 | const value = useContainer(c => c[2]); 25 | return { value }; 26 | }); 27 | 28 | expect(result.current.value).toBe(2); 29 | }); 30 | 31 | test("selector depends on a local state.", () => { 32 | const { result } = renderHook(() => { 33 | const [index, setIndex] = React.useState(0); 34 | const selected = useContainer(c => c[index]); 35 | 36 | return { selected, index, setIndex }; 37 | }); 38 | 39 | expect(result.current.selected).toBe(0); 40 | 41 | act(() => result.current.setIndex(3)); 42 | 43 | expect(result.current.selected).toBe(3); 44 | }); 45 | 46 | test("pass a comparer function", () => { 47 | let renderCount = 0; 48 | 49 | renderHook(() => { 50 | renderCount++; 51 | 52 | useContainer( 53 | c => c.slice(), // create a copied instance. 54 | (prev, next) => prev.every((item, index) => next[index] === item), 55 | ); 56 | }); 57 | 58 | expect(renderCount).toBe(1); 59 | act(() => notifier.notify()); 60 | expect(renderCount).toBe(1); 61 | }); 62 | 63 | test("Error occurs when unwrapped with Provider.", () => { 64 | const emptyContext = React.createContext>(EMPTY); 65 | const useContainer = createUseContainer(emptyContext); 66 | const { result } = renderHook(() => useContainer()); 67 | 68 | expect(result.error.message).toBe("Component must be wrapped with "); 69 | }); 70 | -------------------------------------------------------------------------------- /src/__tests__/unreduxed.test.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { fireEvent, render } from "@testing-library/react"; 3 | import { unreduxed } from "../unreduxed"; 4 | 5 | const useTestHook = ({ init }: { init?: number }) => { 6 | const [count, setCount] = React.useState(init ?? 0); 7 | 8 | const increment = React.useCallback(() => setCount(prev => prev + 1), []); 9 | const decrement = React.useCallback(() => setCount(prev => prev - 1), []); 10 | 11 | return { count, increment, decrement }; 12 | }; 13 | 14 | const [ContainerProvider, useContainer] = unreduxed(useTestHook); 15 | 16 | let CountRenderingCount = 0; 17 | const Count = () => { 18 | const count = useContainer(c => c.count); 19 | 20 | CountRenderingCount++; 21 | 22 | return {count}; 23 | }; 24 | 25 | let CounterRenderingCount = 0; 26 | const Counter = () => { 27 | const increment = useContainer(c => c.increment); 28 | const decrement = useContainer(c => c.decrement); 29 | 30 | CounterRenderingCount++; 31 | 32 | return ( 33 |
34 | 35 | 36 |
37 | ); 38 | }; 39 | 40 | const App = () => { 41 | return ( 42 | 43 | 44 | 45 | 46 | ); 47 | }; 48 | 49 | test("unreduxed basic usage", () => { 50 | const { getByText } = render(); 51 | 52 | expect(CountRenderingCount).toBe(1); 53 | expect(CounterRenderingCount).toBe(1); 54 | 55 | fireEvent.click(getByText("increment")); 56 | 57 | expect(CountRenderingCount).toBe(2); 58 | expect(CounterRenderingCount).toBe(1); 59 | 60 | fireEvent.click(getByText("decrement")); 61 | 62 | expect(CountRenderingCount).toBe(3); 63 | expect(CounterRenderingCount).toBe(1); 64 | }); 65 | -------------------------------------------------------------------------------- /src/__tests__/useForceUpdate.test.ts: -------------------------------------------------------------------------------- 1 | import { act, renderHook } from "@testing-library/react-hooks"; 2 | import { useForceUpdate } from "../useForceUpdate"; 3 | 4 | test("useForceUpdate", () => { 5 | let renderCount = 0; 6 | 7 | const { result } = renderHook(() => { 8 | renderCount++; 9 | return useForceUpdate(); 10 | }); 11 | 12 | act(() => result.current()); 13 | 14 | expect(renderCount).toBe(2); 15 | }); 16 | -------------------------------------------------------------------------------- /src/createContainerProvider.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { EMPTY } from "./empty"; 3 | import { Notifier } from "./Notifier"; 4 | import { useIsomorphicLayoutEffect } from "./useIsomorphicLayoutEffect"; 5 | 6 | type ContainerProviderProps, Container> = ( 7 | | (HookArgs & { __mock?: undefined }) 8 | | ({ __mock: Container } & { [K in keyof HookArgs]?: undefined }) 9 | ) & { children: React.ReactNode }; 10 | 11 | export function createContainerProvider, Container>( 12 | useHook: (args: HookArgs) => Container, 13 | notifierContext: React.Context | typeof EMPTY>, 14 | ) { 15 | const ContainerProvider: React.FC> = props => { 19 | const { children, ...others } = props; 20 | 21 | // @ts-expect-error 22 | // eslint-disable-next-line react-hooks/rules-of-hooks 23 | const container: Container = "__mock" in others ? others.__mock : useHook(others); 24 | 25 | const notifierRef = React.useRef(new Notifier(container)); 26 | 27 | useIsomorphicLayoutEffect(() => { 28 | notifierRef.current.container = container; 29 | notifierRef.current.notify(); 30 | }, [container]); 31 | 32 | return ( 33 | 34 | {children} 35 | 36 | ); 37 | }; 38 | 39 | return ContainerProvider; 40 | } 41 | -------------------------------------------------------------------------------- /src/createUseContainer.ts: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { EMPTY } from "./empty"; 3 | import { Notifier } from "./Notifier"; 4 | import { useForceUpdate } from "./useForceUpdate"; 5 | import { useIsomorphicLayoutEffect } from "./useIsomorphicLayoutEffect"; 6 | 7 | export function createUseContainer( 8 | notifierContext: React.Context | typeof EMPTY>, 9 | ) { 10 | function useContainer(): Container; 11 | function useContainer( 12 | selector: (container: Container) => T, 13 | comparer?: (prev: T, next: T) => boolean, 14 | ): T; 15 | function useContainer( 16 | selector: (container: Container) => T = defaultSelector, 17 | comparer: (prev: T, next: T) => boolean = defaultComparer, 18 | ): T { 19 | const notifier = React.useContext(notifierContext); 20 | if (notifier === EMPTY) { 21 | throw new Error("Component must be wrapped with "); 22 | } 23 | 24 | const selectorRef = React.useRef(selector); 25 | selectorRef.current = selector; 26 | 27 | const forceUpdate = useForceUpdate(); 28 | const prevSelectedValueRef = React.useRef(selector(notifier.container)); 29 | prevSelectedValueRef.current = selectorRef.current(notifier.container); 30 | 31 | const listenerRef = React.useRef((value: Container) => { 32 | const selector = selectorRef.current; 33 | const nextSelectedValue = selector(value); 34 | 35 | if (!comparer(prevSelectedValueRef.current, nextSelectedValue)) { 36 | prevSelectedValueRef.current = nextSelectedValue; 37 | 38 | forceUpdate(); 39 | } 40 | }); 41 | 42 | useIsomorphicLayoutEffect(() => { 43 | const listener = listenerRef.current; 44 | 45 | notifier.register(listener); 46 | 47 | return () => notifier.unregister(listener); 48 | }, [notifier]); 49 | 50 | return prevSelectedValueRef.current; 51 | } 52 | 53 | return useContainer; 54 | } 55 | 56 | const defaultSelector = (container: any) => container; 57 | const defaultComparer = (a: unknown, b: unknown) => a === b; 58 | -------------------------------------------------------------------------------- /src/empty.ts: -------------------------------------------------------------------------------- 1 | export const EMPTY: unique symbol = Symbol(); 2 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { unreduxed as default } from "./unreduxed"; 2 | -------------------------------------------------------------------------------- /src/unreduxed.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { createContainerProvider } from "./createContainerProvider"; 3 | import { createUseContainer } from "./createUseContainer"; 4 | import { EMPTY } from "./empty"; 5 | import { Notifier } from "./Notifier"; 6 | 7 | export function unreduxed = {}>( 8 | useHook: (arg: HookArgs) => Container, 9 | ) { 10 | const notifierContext = React.createContext | typeof EMPTY>(EMPTY); 11 | 12 | const Provider = createContainerProvider(useHook, notifierContext); 13 | 14 | const useContainer = createUseContainer(notifierContext); 15 | 16 | return [Provider, useContainer] as const; 17 | } 18 | -------------------------------------------------------------------------------- /src/useForceUpdate.ts: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export function useForceUpdate() { 4 | const [, setState] = React.useState(Number.MIN_SAFE_INTEGER); 5 | 6 | return () => setState(prev => prev + 1); 7 | } 8 | -------------------------------------------------------------------------------- /src/useIsomorphicLayoutEffect.ts: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export const useIsomorphicLayoutEffect = 4 | typeof window !== "undefined" && 5 | typeof window.document !== "undefined" && 6 | typeof window.document.createElement !== "undefined" 7 | ? React.useLayoutEffect 8 | : React.useEffect; 9 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 4 | /* Basic Options */ 5 | // "incremental": true, /* Enable incremental compilation */ 6 | "target": "es2015", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */ 7 | "module": "ES6", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */ 8 | // "lib": [], /* Specify library files to be included in the compilation. */ 9 | // "allowJs": true, /* Allow javascript files to be compiled. */ 10 | // "checkJs": true, /* Report errors in .js files. */ 11 | "jsx": "react", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 12 | //"declaration": true, /* Generates corresponding '.d.ts' file. */ 13 | //"declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 14 | //"declarationDir": "dist", 15 | // "sourceMap": true, /* Generates corresponding '.map' file. */ 16 | // "outFile": "./", /* Concatenate and emit output to single file. */ 17 | // "outDir": "./dist", /* Redirect output structure to the directory. */ 18 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 19 | // "composite": true, /* Enable project compilation */ 20 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 21 | // "removeComments": true, /* Do not emit comments to output. */ 22 | // "noEmit": true, /* Do not emit outputs. */ 23 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 24 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 25 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 26 | 27 | /* Strict Type-Checking Options */ 28 | "strict": true, /* Enable all strict type-checking options. */ 29 | "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 30 | // "strictNullChecks": true, /* Enable strict null checks. */ 31 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 32 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 33 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 34 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 35 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 36 | 37 | /* Additional Checks */ 38 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 39 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 40 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 41 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 42 | 43 | /* Module Resolution Options */ 44 | "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 45 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 46 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 47 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 48 | // "typeRoots": [], /* List of folders to include type definitions from. */ 49 | // "types": [], /* Type declaration files to be included in compilation. */ 50 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 51 | "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 52 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 53 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 54 | 55 | /* Source Map Options */ 56 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 57 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 58 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 59 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 60 | 61 | /* Experimental Options */ 62 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 63 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 64 | 65 | /* Advanced Options */ 66 | "skipLibCheck": true, /* Skip type checking of declaration files. */ 67 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ 68 | }, 69 | "include": ["src"], 70 | } 71 | --------------------------------------------------------------------------------