├── .gitignore ├── src ├── context.ts ├── index.tsx ├── provider.tsx ├── selector.ts └── store.tsx ├── .prettierrc ├── fixtures ├── index.html ├── package.json ├── tsconfig.json └── index.tsx ├── tsconfig.json ├── package.json ├── .github └── workflows │ └── npm-publish.yml ├── README.md └── __tests__ └── index.test.tsx /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | .DS_Store 3 | node_modules 4 | .cache 5 | dist 6 | coverage -------------------------------------------------------------------------------- /src/context.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from 'react' 2 | import { Store } from './store' 3 | 4 | export const MutableSourceContext = createContext>(null as any) 5 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import { Store, createStore } from './store' 2 | import { Provider } from './provider' 3 | import { useSelector } from './selector' 4 | 5 | export { Store, createStore, Provider, useSelector } 6 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "bracketSpacing": true, 3 | "jsxBracketSameLine": false, 4 | "printWidth": 100, 5 | "semi": false, 6 | "singleQuote": true, 7 | "tabWidth": 2, 8 | "arrowParens": "avoid", 9 | "trailingComma": "none" 10 | } 11 | -------------------------------------------------------------------------------- /fixtures/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Document 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /fixtures/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fixtures", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "license": "MIT", 6 | "scripts": { 7 | "start": "parcel index.html", 8 | "build": "parcel build index.html" 9 | }, 10 | "alias": { 11 | "react": "../node_modules/react", 12 | "react-dom": "../node_modules/react-dom" 13 | }, 14 | "devDependencies": { 15 | "@types/react": "^16.9.46", 16 | "@types/react-dom": "^16.9.8", 17 | "parcel": "^1.12.4", 18 | "typescript": "^3.9.7" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /fixtures/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": false, 4 | "target": "es5", 5 | "module": "commonjs", 6 | "jsx": "react", 7 | "moduleResolution": "node", 8 | "noImplicitAny": false, 9 | "noUnusedLocals": false, 10 | "noUnusedParameters": false, 11 | "removeComments": true, 12 | "strictNullChecks": true, 13 | "preserveConstEnums": true, 14 | "sourceMap": true, 15 | "lib": ["es2015", "es2016", "dom"], 16 | "baseUrl": ".", 17 | "types": ["node"] 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/provider.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { Store } from './store' 3 | import { MutableSourceContext } from './context' 4 | 5 | const { useMemo } = React 6 | 7 | interface ProviderProps { 8 | store: Store 9 | children?: React.ReactNode 10 | } 11 | 12 | export function Provider({ children, store }: ProviderProps) { 13 | const mutableSource = useMemo(() => { 14 | return store 15 | }, [store]) 16 | 17 | return ( 18 | {children} 19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /src/selector.ts: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react' 2 | import { useSyncExternalStoreExtra } from 'use-sync-external-store/extra' 3 | import { Store } from './store' 4 | import { MutableSourceContext } from './context' 5 | 6 | type EqualityFn = (a: T | undefined, b: T | undefined) => boolean 7 | 8 | export function useSelector( 9 | selector: (state: T) => K, 10 | equalityFn?: EqualityFn 11 | ): K { 12 | const store = useContext>(MutableSourceContext) 13 | 14 | return useSyncExternalStoreExtra( 15 | store.subscribe, 16 | store.getState, 17 | store.getState, 18 | selector, 19 | equalityFn 20 | ) 21 | } 22 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src", "types", "test"], 3 | "compilerOptions": { 4 | "target": "es5", 5 | "module": "esnext", 6 | "lib": ["dom", "esnext"], 7 | "importHelpers": true, 8 | "declaration": true, 9 | "sourceMap": true, 10 | "rootDir": "./src", 11 | "strict": true, 12 | "noImplicitAny": true, 13 | "strictNullChecks": true, 14 | "strictFunctionTypes": true, 15 | "strictPropertyInitialization": true, 16 | "noImplicitThis": true, 17 | "alwaysStrict": true, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "noImplicitReturns": true, 21 | "noFallthroughCasesInSwitch": true, 22 | "moduleResolution": "node", 23 | "baseUrl": "./", 24 | "paths": { 25 | "*": ["src/*", "node_modules/*"] 26 | }, 27 | "jsx": "react", 28 | "esModuleInterop": true 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@bete/mut", 3 | "version": "1.0.4", 4 | "main": "dist/index.js", 5 | "module": "dist/mut.esm.js", 6 | "files": [ 7 | "dist" 8 | ], 9 | "scripts": { 10 | "build": "tsdx build", 11 | "test": "tsdx test", 12 | "lint": "tsdx lint" 13 | }, 14 | "repository": "https://github.com/snakeUni/mut.git", 15 | "author": "fox <1025687605@qq.com>", 16 | "license": "MIT", 17 | "devDependencies": { 18 | "@testing-library/react": "^10.4.8", 19 | "@types/react": "^16.9.46", 20 | "@types/react-dom": "^16.9.8", 21 | "@types/use-subscription": "^1.0.0", 22 | "@types/use-sync-external-store": "^0.0.0", 23 | "husky": "^4.2.5", 24 | "lint-staged": "^10.2.11", 25 | "prettier": "^2.0.5", 26 | "react": "^16.13.1", 27 | "react-dom": "^16.13.1", 28 | "tsdx": "^0.13.2", 29 | "typescript": "^3.9.7" 30 | }, 31 | "dependencies": { 32 | "immer": "^7.0.7", 33 | "use-sync-external-store": "0.0.0-experimental-7d38e4fd8-20210930" 34 | }, 35 | "peerDependencies": { 36 | "react": ">=16" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /.github/workflows/npm-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will run tests using node and then publish a package to GitHub Packages when a release is created 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/publishing-nodejs-packages 3 | 4 | name: Node.js Package 5 | 6 | on: 7 | # Trigger the workflow on push or pull request, 8 | # but only for the master branch 9 | push: 10 | branches: 11 | - main 12 | pull_request: 13 | branches: 14 | - main 15 | # Also trigger on page_build, as well as release created events 16 | page_build: 17 | release: 18 | types: # This configuration does not affect the page_build event above 19 | - created 20 | 21 | jobs: 22 | publish-npm: 23 | if: github.ref == 'refs/heads/main' 24 | runs-on: ubuntu-latest 25 | steps: 26 | - uses: actions/checkout@v2 27 | - uses: actions/setup-node@v1 28 | with: 29 | node-version: 12 30 | registry-url: https://registry.npmjs.org/ 31 | - name: npm install, build 32 | run: | 33 | yarn 34 | yarn build 35 | npm publish 36 | env: 37 | NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} 38 | -------------------------------------------------------------------------------- /src/store.tsx: -------------------------------------------------------------------------------- 1 | import produce, { Draft } from 'immer' 2 | 3 | export type Listener = () => void 4 | export type UpdateFn = (preState: T) => T 5 | 6 | export interface Store { 7 | subscribe: (listener: Listener) => () => void 8 | unsubscribe: (listener: Listener) => void 9 | reset: () => void 10 | mutate: (updater: (draft: Draft) => void | T) => void 11 | setState: (nextState: T | UpdateFn) => void 12 | getState: () => T 13 | } 14 | 15 | export function createStore(initialState: T): Store { 16 | let listeners: Listener[] = [] 17 | let currentState = initialState 18 | 19 | return { 20 | subscribe(listener: Listener) { 21 | listeners.push(listener) 22 | return () => this.unsubscribe(listener) 23 | }, 24 | unsubscribe(listener: Listener) { 25 | listeners = listeners.filter(fn => fn !== listener) 26 | }, 27 | reset() { 28 | this.setState(initialState) 29 | }, 30 | mutate(updater: (draft: Draft) => void | T) { 31 | const cur = this.getState() 32 | const nextState = produce(cur, updater) 33 | if (nextState !== cur) { 34 | this.setState(nextState as T) 35 | } 36 | }, 37 | setState(nextState: T | UpdateFn) { 38 | currentState = 39 | typeof nextState === 'function' ? (nextState as UpdateFn)(currentState) : nextState 40 | listeners.forEach(l => l()) 41 | }, 42 | getState: () => currentState 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /fixtures/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import * as ReactDOM from 'react-dom' 3 | import { createStore, useSelector, Provider } from '../src' 4 | 5 | interface State { 6 | count: number 7 | value: number 8 | } 9 | 10 | const store = createStore({ 11 | count: 0, 12 | value: 0 13 | }) 14 | 15 | function App() { 16 | return ( 17 | 18 | 23 | ) 24 | } 25 | 26 | function Label() { 27 | const selector = React.useCallback(state => state.count, []) 28 | const count = useSelector(selector) 29 | console.log('render count:', count) 30 | return
count: {count}
31 | } 32 | 33 | function Label2() { 34 | const selector = React.useCallback(state => state.value, []) 35 | const value = useSelector(selector) 36 | console.log('render value:', value) 37 | return
value: {value}
38 | } 39 | 40 | function Label3() { 41 | const state = useSelector(s => s) 42 | console.log('render state:', state) 43 | return ( 44 |
45 |

Label3

46 |
value: {state.value}
47 |
count: {state.count}
48 |
49 | ) 50 | } 51 | 52 | function Buttons() { 53 | const handleIncrease = () => { 54 | store.mutate(state => { 55 | state.count++ 56 | }) 57 | } 58 | 59 | const handleDecrease = () => { 60 | store.mutate(state => { 61 | state.count-- 62 | }) 63 | } 64 | 65 | const handleIncreaseV = () => { 66 | store.mutate(state => { 67 | state.value++ 68 | }) 69 | } 70 | 71 | const handleDecreaseV = () => { 72 | store.mutate(state => { 73 | state.value-- 74 | }) 75 | } 76 | return ( 77 |
78 | 79 | 80 | 81 | 82 |
83 | ) 84 | } 85 | 86 | ReactDOM.render(, document.getElementById('root')) 87 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # mut 2 | 3 | A tiny state management library based on Immer and useSyncExternalStoreExtra 4 | 5 | ## Usage 6 | 7 | ### Install 8 | 9 | `yarn add @bete/mut` 或 `npm install @bete/mut` 10 | 11 | ### Example 12 | 13 | ```jsx 14 | import * as React from 'react' 15 | import * as ReactDOM from 'react-dom' 16 | import { createStore, useSelector, Provider } from '@bete/mut' 17 | 18 | interface State { 19 | count: number 20 | value: number 21 | } 22 | 23 | const store = createStore({ 24 | count: 0, 25 | value: 0 26 | }) 27 | 28 | function App() { 29 | return ( 30 | 31 | 36 | ) 37 | } 38 | 39 | function Label() { 40 | const selector = React.useCallback(state => state.count, []) 41 | const count = useSelector(selector) 42 | console.log('render count:', count) 43 | return
count: {count}
44 | } 45 | 46 | function Label2() { 47 | const selector = React.useCallback(state => state.value, []) 48 | const value = useSelector(selector) 49 | console.log('render value:', value) 50 | return
value: {value}
51 | } 52 | 53 | function Reset() { 54 | const reset = () => { 55 | store.reset() 56 | } 57 | 58 | return ( 59 | 60 | ) 61 | } 62 | 63 | function Buttons() { 64 | const handleIncrease = () => { 65 | store.mutate(state => { 66 | state.count++ 67 | }) 68 | } 69 | 70 | const handleDecrease = () => { 71 | store.mutate(state => { 72 | state.count-- 73 | }) 74 | } 75 | 76 | const handleIncreaseV = () => { 77 | store.mutate(state => { 78 | state.value++ 79 | }) 80 | } 81 | 82 | const handleDecreaseV = () => { 83 | store.mutate(state => { 84 | state.value-- 85 | }) 86 | } 87 | return ( 88 |
89 | 90 | 91 | 92 | 93 |
94 | ) 95 | } 96 | 97 | ReactDOM.render(, document.getElementById('root')) 98 | ``` 99 | 100 | ## Api 101 | 102 | ### createStore 103 | 104 | ```jsx 105 | function createStore(initialState: T): Store 106 | ``` 107 | 108 | ```jsx 109 | interface Store { 110 | subscribe: (listener: Listener) => () => void 111 | unsubscribe: (listener: Listener) => void 112 | reset: () => void 113 | mutate: (updater: (draft: Draft) => void | T) => void 114 | setState: (nextState: T | UpdateFn) => void 115 | getState: () => T 116 | } 117 | ``` 118 | 119 | ### Provider 120 | 121 | ``` 122 | const store = createStore() 123 | 124 | 125 | {children} 126 | 127 | ``` 128 | 129 | ### useSelector 130 | 131 | ```jsx 132 | function useSelector(selector: (state: T) => K, equalityFn?: (a: K | undefined, b: K | undefined) => boolean): K 133 | ``` 134 | 135 | Example 136 | 137 | ```jsx 138 | const selector = React.useCallback(state => state.value, []) 139 | const value = useSelector(selector) 140 | ``` 141 | 142 | [more about useSyncExternalStore](https://github.com/reactwg/react-18/discussions/86) 143 | -------------------------------------------------------------------------------- /__tests__/index.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { render, fireEvent, cleanup } from '@testing-library/react' 3 | import { createStore, useSelector, Provider, Store } from '../src' 4 | 5 | interface State { 6 | count: number 7 | value: number 8 | } 9 | 10 | interface Astore { 11 | store: Store 12 | renderValue?: () => void 13 | renderCount?: () => void 14 | } 15 | 16 | function Label({ renderCount }: { renderCount?: () => void }) { 17 | const selector = React.useCallback(state => state.count, []) 18 | const count = useSelector(selector) 19 | console.log('render count:', count) 20 | renderCount && renderCount() 21 | return
count: {count}
22 | } 23 | 24 | function Label2({ renderValue }: { renderValue?: () => void }) { 25 | const selector = React.useCallback(state => state.value, []) 26 | const value = useSelector(selector) 27 | console.log('render value:', value) 28 | renderValue && renderValue() 29 | return
value: {value}
30 | } 31 | 32 | function Reset({ store }: Astore) { 33 | const handleReset = () => { 34 | store.reset() 35 | } 36 | return ( 37 |
38 | 39 |
40 | ) 41 | } 42 | 43 | function Buttons({ store }: Astore) { 44 | const handleIncrease = () => { 45 | store.mutate(state => { 46 | state.count++ 47 | }) 48 | } 49 | 50 | const handleDecrease = () => { 51 | store.mutate(state => { 52 | state.count-- 53 | }) 54 | } 55 | 56 | const handleIncreaseV = () => { 57 | store.mutate(state => { 58 | state.value++ 59 | }) 60 | } 61 | 62 | const handleDecreaseV = () => { 63 | store.mutate(state => { 64 | state.value-- 65 | }) 66 | } 67 | return ( 68 |
69 | 70 | 71 | 72 | 73 |
74 | ) 75 | } 76 | 77 | function App({ store, renderCount, renderValue }: Astore) { 78 | return ( 79 | 80 | 85 | ) 86 | } 87 | 88 | describe('test', () => { 89 | afterEach(cleanup) 90 | 91 | it('test store', () => { 92 | const store = createStore({ 93 | count: 0, 94 | value: 0 95 | }) 96 | 97 | const { getByText } = render() 98 | const increaseNode = getByText('increase') 99 | fireEvent.click(increaseNode) 100 | expect(getByText(/count: 1/i)).not.toBeNull() 101 | 102 | const decreaseNode = getByText('decrease') 103 | fireEvent.click(decreaseNode) 104 | expect(getByText(/count: 0/i)).not.toBeNull() 105 | 106 | // 点击 value node 107 | const increaseValueNode = getByText(/increaseValue/i) 108 | fireEvent.click(increaseValueNode) 109 | expect(getByText(/value: 1/i)).not.toBeNull() 110 | expect(getByText(/count: 0/i)).not.toBeNull() 111 | 112 | const decreaseValueNode = getByText(/decreaseValue/i) 113 | fireEvent.click(decreaseValueNode) 114 | expect(getByText(/value: 0/i)).not.toBeNull() 115 | 116 | // reset 117 | const resetNode = getByText(/reset/i) 118 | fireEvent.click(resetNode) 119 | expect(getByText(/value: 0/i)).not.toBeNull() 120 | expect(getByText(/count: 0/i)).not.toBeNull() 121 | }) 122 | 123 | it('test render', () => { 124 | const store = createStore({ 125 | count: 0, 126 | value: 0 127 | }) 128 | 129 | const fnCount = jest.fn() 130 | const fnValue = jest.fn() 131 | 132 | const { getByText } = render() 133 | expect(fnCount).toHaveBeenCalledTimes(1) 134 | expect(fnValue).toHaveBeenCalledTimes(1) 135 | 136 | const increaseNode = getByText('increase') 137 | fireEvent.click(increaseNode) 138 | expect(fnCount).toHaveBeenCalledTimes(2) 139 | expect(fnValue).toHaveBeenCalledTimes(1) 140 | 141 | const decreaseNode = getByText('decrease') 142 | fireEvent.click(decreaseNode) 143 | expect(fnCount).toHaveBeenCalledTimes(3) 144 | expect(fnValue).toHaveBeenCalledTimes(1) 145 | 146 | // value 147 | const increaseValueNode = getByText('increaseValue') 148 | fireEvent.click(increaseValueNode) 149 | expect(fnCount).toHaveBeenCalledTimes(4) 150 | expect(fnValue).toHaveBeenCalledTimes(2) 151 | 152 | fireEvent.click(increaseValueNode) 153 | expect(fnCount).toHaveBeenCalledTimes(4) 154 | expect(fnValue).toHaveBeenCalledTimes(3) 155 | }) 156 | }) 157 | --------------------------------------------------------------------------------