├── .gitignore ├── README.md ├── examples ├── example-js │ ├── .gitignore │ ├── README.md │ ├── package.json │ ├── public │ │ ├── favicon.ico │ │ ├── index.html │ │ ├── logo192.png │ │ ├── logo512.png │ │ ├── manifest.json │ │ └── robots.txt │ ├── src │ │ ├── App.js │ │ ├── components │ │ │ ├── Counter.js │ │ │ └── DemoRefresh.js │ │ ├── index.css │ │ ├── index.js │ │ ├── store.js │ │ └── views │ │ │ ├── DemoPage.js │ │ │ ├── ModulesPage.js │ │ │ └── PostsPage.js │ └── yarn.lock └── example-ts │ ├── README.md │ ├── package.json │ ├── public │ ├── favicon.ico │ ├── index.html │ ├── logo192.png │ ├── logo512.png │ ├── manifest.json │ └── robots.txt │ ├── src │ ├── App.tsx │ ├── components │ │ ├── Post.tsx │ │ └── PostDelete.tsx │ ├── index.css │ ├── index.tsx │ ├── react-app-env.d.ts │ ├── store.ts │ ├── types │ │ └── types.ts │ └── views │ │ ├── PostPage.tsx │ │ └── PostsPage.tsx │ ├── tsconfig.json │ └── yarn.lock ├── package.json ├── public ├── favicon.ico ├── index.html ├── logo192.png ├── logo512.png ├── manifest.json ├── robots.txt └── tree.png ├── src └── lib │ ├── externalStore.ts │ ├── getters.tsx │ ├── helpers.ts │ ├── hooks.ts │ ├── index.ts │ ├── storeContext.ts │ ├── types.ts │ └── withStore.tsx ├── tsconfig.json └── yarn.lock /.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 | /lib 14 | 15 | # misc 16 | .DS_Store 17 | .env.local 18 | .env.development.local 19 | .env.test.local 20 | .env.production.local 21 | .idea 22 | 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # examples 28 | /examples/example-ts/node_modules 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Vuex - But for React! ⚛ 2 | 3 | Enjoy the vuex API in your React applications with `vuex-but-for-react`, which uses only React Context and React use-sync-external-store under the hood. 4 | 5 | `vuex-but-for-react` was engineered with developer experience in mind, making it very easy to use. Invoke your getter or action by using a one-line hook and don't worry about unnecessary renders - **without** using `memo`. 6 | 7 | Your component will render only when its getter changes - and it doesn't care about the rest of the store! 8 | 9 | Are you on board? Read more 👇 10 | 11 | ## Installation 12 | 13 | `npm install vuex-but-for-react --save` 14 | 15 | `yarn add vuex-but-for-react` 16 | 17 | TS support included ✨ 18 | 19 | --- 20 | 21 | 🚨 React versions 22 | 23 | **`vuex-but-for-react` >= `3.0.0` works with React `18.0.0`+** 24 | 25 | To use with older react versions, please install `vuex-but-for-react 2.0.6` 26 | 27 | ## Working example 28 | 29 | `store.js` 30 | ```javascript 31 | const store = { 32 | state: { 33 | posts: [] 34 | }, 35 | mutations: { 36 | POSTS_SET(state, data) { 37 | state.posts = data 38 | } 39 | }, 40 | actions: { 41 | async POSTS_FETCH(context) { 42 | const response = await fetch('https://jsonplaceholder.typicode.com/posts') 43 | const data = await response.json() 44 | context.mutations.POSTS_SET(data) 45 | } 46 | }, 47 | getters: { 48 | posts (state) { 49 | return state.posts 50 | } 51 | } 52 | } 53 | ``` 54 | 55 | `index.js` 56 | ```javascript 57 | import React from 'react'; 58 | import ReactDOM from 'react-dom'; 59 | import { withStore } from 'vuex-but-for-react'; 60 | 61 | import App from './App'; 62 | import store from './store'; 63 | 64 | const AppWithStore = withStore(App, store); 65 | 66 | ReactDOM.render( 67 | , 68 | document.getElementById('root') 69 | ); 70 | ``` 71 | 72 | `Posts.js` 73 | ```javascript 74 | import React, { useEffect } from 'react'; 75 | import { useAction, useGetter } from 'vuex-but-for-react'; 76 | 77 | const Posts = () => { 78 | const handleAction = useAction('POSTS_FETCH'); 79 | const posts = useGetter('posts'); 80 | 81 | useEffect(() => { 82 | handleAction(); 83 | }, [handleAction]) // don't worry, it doesn't re-create! 84 | 85 | return ( 86 | 89 | ); 90 | } 91 | 92 | export default Posts 93 | ``` 94 | 95 | Check the examples section to see JavaScript and TypeScript working apps! 96 | 97 | ## API 98 | 99 | ### useAction(`actionName`) 100 | 101 | An action is used for async data logic, especially API calls. You can dispatch mutations and other actions from within an action. 102 | 103 | The function returned by the `useAction()` hook is *never* re-created. 104 | 105 | ```javascript 106 | import { useAction } from 'vuex-but-for-react'; 107 | 108 | const PostsPage = () => { 109 | const handleFetch = useAction('POSTS_FETCH'); 110 | 111 | useEffect(() => { 112 | handleFetch(); 113 | }, [handleFetch]) 114 | 115 | return ( 116 | ... 117 | ) 118 | } 119 | ``` 120 | 121 | ### useMutation(`actionName`) 122 | 123 | A mutation is used for sync data operations. It has access to the current state in order to alter it. 124 | 125 | The function returned by the `useMutation()` hook is *never* re-created. 126 | 127 | ```javascript 128 | import { useMutation } from 'vuex-but-for-react'; 129 | 130 | const Counter = () => { 131 | const handleIncrement = useMutation('COUNTER_INCREMENT'); 132 | const handleDecrement = useMutation('COUNTER_DECREMENT'); 133 | 134 | return ( 135 | <> 136 | 137 | 138 | 139 | ) 140 | } 141 | ``` 142 | 143 | ### useGetter(`actionName`) 144 | A getter gives you the current stored value based on your config. It has access to the current state. 145 | 146 | The data returned by the `useGetter()` hook is updated *only in case the shallow value changes*. 147 | An update of one getter value won't trigger the update of another getter value. 148 | 149 | ```javascript 150 | import { useGetter } from 'vuex-but-for-react'; 151 | 152 | const PostsPage = () => { 153 | const posts = useGetter('posts'); 154 | 155 | return ( 156 | 161 | ) 162 | } 163 | ``` 164 | 165 | ### withStore(`Component`, `config`) 166 | 167 | In order to initialize the global store, wrap your (chosen) root component in your store config. 168 | 169 | ```javascript 170 | import { withStore } from 'vuex-but-for-react'; 171 | 172 | const AppWithStore = withStore(App, store); 173 | ``` 174 | 175 | And more amazing stuff! 176 | 177 | #### useActionOnMount(`actionName`) 178 | 179 | Avoid calling useEffect manually. Just pass the action name and it will be executed on component mount automatically. 180 | 181 | ```javascript 182 | import { useActionOnMount, useGetter } from 'vuex-but-for-react'; 183 | 184 | const PostsPage = () => { 185 | useActionOnMount('POSTS_FETCH'); 186 | const posts = useGetter('posts'); 187 | 188 | return ( 189 | ... 190 | ) 191 | } 192 | ``` 193 | 194 | -------------------------------------------------------------------------------- /examples/example-js/.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 | -------------------------------------------------------------------------------- /examples/example-js/README.md: -------------------------------------------------------------------------------- 1 | # Example - JS 2 | 3 | A JavaScript example using `vuex-but-for-react` with a blog-like global state logic 4 | 5 | * Check the views/DemoPage for use of a **mutation** and monitoring component re-renders 6 | * Check the views/PostsPage for use of an **action** and a **getter** 7 | 8 | The store config is as follows: 9 | ```javascript 10 | const store = { 11 | state: { 12 | demoRefreshValue: 0, 13 | counter: 0, 14 | posts: [] 15 | }, 16 | mutations: { 17 | POSTS_SET(state, data) { 18 | state.posts = data 19 | }, 20 | COUNTER_INCREMENT(state) { 21 | state.counter++ 22 | }, 23 | COUNTER_DECREMENT(state) { 24 | state.counter-- 25 | }, 26 | DEMO_VALUE_SET(state, value) { 27 | state.demoRefreshValue = value 28 | } 29 | }, 30 | actions: { 31 | async POSTS_FETCH(context) { 32 | const response = await fetch('https://jsonplaceholder.typicode.com/posts') 33 | const data = await response.json() 34 | context.mutations.POSTS_SET(data) 35 | } 36 | }, 37 | getters: { 38 | posts (state) { 39 | return state.posts 40 | } 41 | } 42 | } 43 | ``` 44 | 45 | ## Available Scripts 46 | 47 | In the project directory, you can run: 48 | 49 | ### `yarn start` 50 | 51 | Runs the app in the development mode.\ 52 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 53 | 54 | The page will reload if you make edits.\ 55 | You will also see any lint errors in the console. 56 | -------------------------------------------------------------------------------- /examples/example-js/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example-js", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "react-dom": "^18.2.0", 7 | "react-router-dom": "^6.4.0", 8 | "react-scripts": "^5.0.1", 9 | "vuex-but-for-react": "link:../..", 10 | "web-vitals": "^1.0.1" 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 | "eslintConfig": { 19 | "extends": [ 20 | "react-app", 21 | "react-app/jest" 22 | ] 23 | }, 24 | "browserslist": { 25 | "production": [ 26 | ">0.2%", 27 | "not dead", 28 | "not op_mini all" 29 | ], 30 | "development": [ 31 | "last 1 chrome version", 32 | "last 1 firefox version", 33 | "last 1 safari version" 34 | ] 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /examples/example-js/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DJanoskova/vuex-but-for-react/c958008d3edc83fc1f8ead7389f95b9c68092135/examples/example-js/public/favicon.ico -------------------------------------------------------------------------------- /examples/example-js/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 | -------------------------------------------------------------------------------- /examples/example-js/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DJanoskova/vuex-but-for-react/c958008d3edc83fc1f8ead7389f95b9c68092135/examples/example-js/public/logo192.png -------------------------------------------------------------------------------- /examples/example-js/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DJanoskova/vuex-but-for-react/c958008d3edc83fc1f8ead7389f95b9c68092135/examples/example-js/public/logo512.png -------------------------------------------------------------------------------- /examples/example-js/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 | -------------------------------------------------------------------------------- /examples/example-js/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /examples/example-js/src/App.js: -------------------------------------------------------------------------------- 1 | import { HashRouter, Routes, Route, Link } from 'react-router-dom'; 2 | 3 | import DemoPage from './views/DemoPage'; 4 | import PostsPage from './views/PostsPage'; 5 | import ModulesPage from './views/ModulesPage'; 6 | 7 | function App() { 8 | return ( 9 |
10 | 11 |
12 | Demo 13 | Posts 14 | Modules 15 |
16 | 17 | 18 | } /> 21 | } /> 24 | } /> 27 | {/*}/>*/} 28 | 29 |
30 |
31 | ); 32 | } 33 | 34 | export default App; 35 | -------------------------------------------------------------------------------- /examples/example-js/src/components/Counter.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useGetter, useMutation } from 'vuex-but-for-react'; 3 | 4 | const Counter = () => { 5 | const handleIncrement = useMutation('COUNTER_INCREMENT'); 6 | const handleDecrement = useMutation('COUNTER_DECREMENT'); 7 | const counter = useGetter('counter'); 8 | 9 | console.log('Counter re-render', counter); 10 | 11 | return ( 12 |
13 | Counter 14 |
15 | 16 |   17 | {counter} 18 |   19 | 20 |
21 | ) 22 | } 23 | 24 | export default Counter; 25 | -------------------------------------------------------------------------------- /examples/example-js/src/components/DemoRefresh.js: -------------------------------------------------------------------------------- 1 | import { useGetter, useMutation } from 'vuex-but-for-react'; 2 | 3 | const DemoRefresh = () => { 4 | const handleClick = useMutation('DEMO_VALUE_SET'); 5 | const value = useGetter('demoRefreshValue'); 6 | 7 | const onClick = () => { 8 | handleClick(1); 9 | } 10 | 11 | console.log('Demo refresh re-render: ', value); 12 | 13 | return ( 14 |
15 | Demo refresh 16 |
17 | Current value: {value} 18 |
19 | 20 |
21 | ) 22 | } 23 | 24 | export default DemoRefresh; 25 | -------------------------------------------------------------------------------- /examples/example-js/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 12 | monospace; 13 | } 14 | 15 | .app { 16 | max-width: 800px; 17 | margin: 30px auto; 18 | } 19 | 20 | .nav a + a { 21 | margin-left: 10px; 22 | } 23 | 24 | .list-none { 25 | list-style-type: none; 26 | padding: 0; 27 | } 28 | 29 | .blog-item { 30 | margin-bottom: 1px; 31 | } 32 | 33 | .button-x { 34 | margin-right: 10px; 35 | } -------------------------------------------------------------------------------- /examples/example-js/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | import { withStore } from 'vuex-but-for-react'; 4 | 5 | import './index.css'; 6 | 7 | import App from './App'; 8 | import store from './store'; 9 | 10 | const AppWithStore = withStore(App, store); 11 | 12 | const root = ReactDOM.createRoot(document.getElementById("root")); 13 | root.render(); -------------------------------------------------------------------------------- /examples/example-js/src/store.js: -------------------------------------------------------------------------------- 1 | const projectsModule = { 2 | state: { 3 | data: [{ id: 1, title: 'Dummy project' }] 4 | }, 5 | mutations: { 6 | PROJECTS_SET(state, data) { 7 | state.data = data 8 | } 9 | }, 10 | actions: { 11 | async PROJECTS_FETCH(context) { 12 | const response = await fetch('https://jsonplaceholder.typicode.com/posts') 13 | const data = await response.json() 14 | context.mutations.PROJECTS_SET(data.splice(20, 20)) 15 | } 16 | }, 17 | getters: { 18 | projects (state) { 19 | return state.data 20 | } 21 | } 22 | } 23 | 24 | const store = { 25 | state: { 26 | demoRefreshValue: 0, 27 | counter: 0, 28 | // use nested structure to showcase deep reactivity 29 | blog: { 30 | name: 'My Blog!', 31 | posts: [] 32 | }, 33 | }, 34 | mutations: { 35 | POSTS_SET(state, data) { 36 | state.blog.posts = data 37 | }, 38 | POST_REMOVE(state, id) { 39 | state.blog.posts = state.blog.posts.filter(p => p.id !== id) 40 | }, 41 | COUNTER_INCREMENT(state) { 42 | state.counter++ 43 | }, 44 | COUNTER_DECREMENT(state) { 45 | state.counter-- 46 | }, 47 | DEMO_VALUE_SET(state, value) { 48 | state.demoRefreshValue = value 49 | } 50 | }, 51 | actions: { 52 | async POSTS_FETCH(context) { 53 | const response = await fetch('https://jsonplaceholder.typicode.com/posts') 54 | const data = await response.json() 55 | context.mutations.POSTS_SET(data) 56 | } 57 | }, 58 | getters: { 59 | demoRefreshValue(state) { 60 | return state.demoRefreshValue 61 | }, 62 | counter (state) { 63 | return state.counter 64 | }, 65 | blog (state) { 66 | return state.blog 67 | } 68 | }, 69 | modules: { 70 | projects: projectsModule 71 | } 72 | } 73 | 74 | export default store -------------------------------------------------------------------------------- /examples/example-js/src/views/DemoPage.js: -------------------------------------------------------------------------------- 1 | import Counter from '../components/Counter'; 2 | import DemoRefresh from '../components/DemoRefresh'; 3 | 4 | const DemoPage = () => { 5 | return ( 6 |
7 |

Check the console logs for re-renders!

8 |

9 | Neither Counter nor DemoRefresh component is wrapped in memo, yet they update only when their value changes. 10 |

11 |

12 | Demo refresh updates only once, even though we fire the mutation on each 13 | click. vuex-but-for-react won't update the context when the value is the same. 14 |

15 | 16 |
17 | 18 |
19 | ) 20 | } 21 | 22 | export default DemoPage; 23 | -------------------------------------------------------------------------------- /examples/example-js/src/views/ModulesPage.js: -------------------------------------------------------------------------------- 1 | import { useActionOnMount, useGetter } from 'vuex-but-for-react'; 2 | 3 | const ModulesPage = () => { 4 | const projects = useGetter('projects/projects'); 5 | useActionOnMount('projects/PROJECTS_FETCH'); 6 | 7 | return ( 8 | 13 | ) 14 | } 15 | 16 | export default ModulesPage; 17 | -------------------------------------------------------------------------------- /examples/example-js/src/views/PostsPage.js: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import { useAction, useGetter, useMutation } from 'vuex-but-for-react'; 3 | 4 | const PostsPage = () => { 5 | const handleFetch = useAction('POSTS_FETCH'); 6 | const handleRemove = useMutation('POST_REMOVE'); 7 | const blog = useGetter('blog'); 8 | 9 | useEffect(() => { 10 | handleFetch(); 11 | }, [handleFetch]) 12 | 13 | // You can use useActionOnMount() instead of useAction and useEffect! 14 | // useActionOnMount('POSTS_FETCH'); 15 | 16 | return ( 17 | 27 | ) 28 | } 29 | 30 | export default PostsPage; 31 | -------------------------------------------------------------------------------- /examples/example-ts/README.md: -------------------------------------------------------------------------------- 1 | # Example - TS 2 | 3 | A TypeScript example using `vuex-but-for-react` with a blog-like global state logic 4 | 5 | * Check the views/PostsPage for use of an **action** and a **getter** 6 | * Check the views/PostPage for use of an async **action** and storing the data locally. 7 | * Check the components/PostDelete page for use of **action** which dispatches a mutation from the inside. 8 | 9 | The store config is as follows: 10 | ```typescript 11 | const store: StoreType<{ posts: PostType[] }> = { 12 | state: { 13 | posts: [] 14 | }, 15 | mutations: { 16 | POSTS_SET(state, data) { 17 | state.posts = data 18 | }, 19 | POST_REMOVE(state, id) { 20 | state.posts = state.posts.filter((p: PostType) => p.id !== id) 21 | } 22 | }, 23 | actions: { 24 | async POSTS_FETCH(context) { 25 | const response = await fetch('https://jsonplaceholder.typicode.com/posts') 26 | const data = await response.json() 27 | context.mutations.POSTS_SET(data) 28 | }, 29 | async POST_FETCH(_, id) { 30 | const response = await fetch(`https://jsonplaceholder.typicode.com/posts/${id}`) 31 | const data = await response.json() 32 | return data 33 | }, 34 | async POST_DELETE(context, id) { 35 | await fetch(`https://jsonplaceholder.typicode.com/posts/${id}`, { 36 | method: 'DELETE', 37 | }) 38 | context.mutations.POST_REMOVE(id) 39 | } 40 | }, 41 | getters: { 42 | posts (state) { 43 | return state.posts 44 | } 45 | } 46 | } 47 | ``` 48 | 49 | ## Available Scripts 50 | 51 | In the project directory, you can run: 52 | 53 | ### `yarn start` 54 | 55 | Runs the app in the development mode.\ 56 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 57 | 58 | The page will reload if you make edits.\ 59 | You will also see any lint errors in the console. 60 | -------------------------------------------------------------------------------- /examples/example-ts/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example-ts", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@types/node": "^12.0.0", 7 | "@types/react": "^18.0.2", 8 | "@types/react-dom": "^18.0.2", 9 | "react-dom": "^18.2.0", 10 | "react-router-dom": "^6.4.0", 11 | "react-scripts": "^5.0.1", 12 | "typescript": "^4.1.2", 13 | "vuex-but-for-react": "link:../.." 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 | "eslintConfig": { 22 | "extends": [ 23 | "react-app", 24 | "react-app/jest" 25 | ] 26 | }, 27 | "browserslist": { 28 | "production": [ 29 | ">0.2%", 30 | "not dead", 31 | "not op_mini all" 32 | ], 33 | "development": [ 34 | "last 1 chrome version", 35 | "last 1 firefox version", 36 | "last 1 safari version" 37 | ] 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /examples/example-ts/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DJanoskova/vuex-but-for-react/c958008d3edc83fc1f8ead7389f95b9c68092135/examples/example-ts/public/favicon.ico -------------------------------------------------------------------------------- /examples/example-ts/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 | -------------------------------------------------------------------------------- /examples/example-ts/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DJanoskova/vuex-but-for-react/c958008d3edc83fc1f8ead7389f95b9c68092135/examples/example-ts/public/logo192.png -------------------------------------------------------------------------------- /examples/example-ts/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DJanoskova/vuex-but-for-react/c958008d3edc83fc1f8ead7389f95b9c68092135/examples/example-ts/public/logo512.png -------------------------------------------------------------------------------- /examples/example-ts/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 | -------------------------------------------------------------------------------- /examples/example-ts/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /examples/example-ts/src/App.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { BrowserRouter, Routes, Route } from 'react-router-dom'; 3 | 4 | import PostsPage from "./views/PostsPage"; 5 | import PostPage from "./views/PostPage"; 6 | 7 | function App() { 8 | return ( 9 |
10 | 11 | 12 | } 15 | /> 16 | } 19 | /> 20 | 21 | 22 |
23 | ); 24 | } 25 | 26 | export default App; 27 | -------------------------------------------------------------------------------- /examples/example-ts/src/components/Post.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent, memo } from "react"; 2 | import { Link } from "react-router-dom"; 3 | 4 | import { PostType } from "../types/types"; 5 | 6 | import PostDelete from "./PostDelete"; 7 | 8 | interface IProps { 9 | post: PostType; 10 | } 11 | 12 | const Post: FunctionComponent = ({ post }) => { 13 | console.log('post rendered', post.id) 14 | return ( 15 |
16 |

{post.title}

17 | {post.body} 18 |
19 | Detail 20 |
21 | 22 |
23 | ) 24 | } 25 | 26 | // Wrapping the post in memo() will prevent re-renders when a post is removed 27 | // the other post objects aren't re-created, so a shallow comparison is enough 28 | export default memo(Post); 29 | -------------------------------------------------------------------------------- /examples/example-ts/src/components/PostDelete.tsx: -------------------------------------------------------------------------------- 1 | import React, { FormEvent, FunctionComponent } from 'react'; 2 | import { useAction } from "vuex-but-for-react"; 3 | 4 | interface IProps { 5 | id: number 6 | } 7 | 8 | const PostDelete: FunctionComponent = ({ id }) => { 9 | const onDelete = useAction('POST_DELETE'); 10 | 11 | const handleDelete = (e: FormEvent) => { 12 | e.stopPropagation(); 13 | onDelete(id); 14 | } 15 | 16 | return ( 17 | 20 | ) 21 | } 22 | 23 | export default PostDelete; 24 | -------------------------------------------------------------------------------- /examples/example-ts/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 12 | monospace; 13 | } 14 | 15 | .app { 16 | max-width: 800px; 17 | margin: 30px auto; 18 | } 19 | 20 | .post { 21 | padding: 20px; 22 | border-bottom: 1px #eaeaea solid; 23 | } 24 | 25 | button.link { 26 | border: none; 27 | padding: 0; 28 | font-size: inherit; 29 | background: none; 30 | color: -webkit-link; 31 | cursor: pointer; 32 | text-decoration: underline; 33 | } -------------------------------------------------------------------------------- /examples/example-ts/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from "react-dom/client"; 3 | import { withStore } from 'vuex-but-for-react'; 4 | 5 | import './index.css'; 6 | 7 | import App from './App'; 8 | import store from "./store"; 9 | 10 | const AppWithStore = withStore(App, store, { localStorageName: 'test-store' }); 11 | 12 | const root = ReactDOM.createRoot(document.getElementById("root")); 13 | root.render(); -------------------------------------------------------------------------------- /examples/example-ts/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /examples/example-ts/src/store.ts: -------------------------------------------------------------------------------- 1 | import { VuexStoreType } from "vuex-but-for-react"; 2 | 3 | import { PostType } from "./types/types"; 4 | 5 | export interface StoreType { posts: PostType[] } 6 | 7 | const store: VuexStoreType = { 8 | state: { 9 | posts: [] 10 | }, 11 | mutations: { 12 | POSTS_SET(state, data: PostType[]) { 13 | state.posts = data 14 | }, 15 | POST_REMOVE(state, id: number) { 16 | state.posts = state.posts.filter((p) => p.id !== id) 17 | } 18 | }, 19 | actions: { 20 | async POSTS_FETCH(context) { 21 | const response = await fetch('https://jsonplaceholder.typicode.com/posts') 22 | const data = await response.json() 23 | context.mutations.POSTS_SET(data.slice(0, 20)); 24 | }, 25 | async POST_FETCH(_, id) { 26 | const response = await fetch(`https://jsonplaceholder.typicode.com/posts/${id}`) 27 | const data = await response.json() 28 | return data; 29 | }, 30 | async POST_DELETE(context, id) { 31 | await fetch(`https://jsonplaceholder.typicode.com/posts/${id}`, { 32 | method: 'DELETE', 33 | }) 34 | context.mutations.POST_REMOVE(id) 35 | } 36 | }, 37 | getters: { 38 | posts (state) { 39 | return state.posts 40 | } 41 | }, 42 | } 43 | 44 | export default store -------------------------------------------------------------------------------- /examples/example-ts/src/types/types.ts: -------------------------------------------------------------------------------- 1 | export interface PostType { 2 | id: number; 3 | title: string; 4 | body: string; 5 | } -------------------------------------------------------------------------------- /examples/example-ts/src/views/PostPage.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent, useEffect, useState } from 'react'; 2 | import { useParams } from 'react-router-dom'; 3 | import { useAction } from "vuex-but-for-react"; 4 | 5 | import { PostType } from "../types/types"; 6 | 7 | const PostPage: FunctionComponent = () => { 8 | const { id } = useParams<{ id: string }>(); 9 | const [postData, setPostData] = useState(null); 10 | const handleFetch = useAction('POST_FETCH'); 11 | 12 | useEffect(() => { 13 | async function fetchFn() { 14 | const data = await handleFetch(id); 15 | setPostData(data); 16 | } 17 | fetchFn(); 18 | }, [handleFetch, id]); 19 | 20 | if (!postData) return <>Loading...; 21 | 22 | return ( 23 |
24 |

{postData.title}

25 | {postData.body} 26 |
27 | ) 28 | } 29 | 30 | export default PostPage; 31 | -------------------------------------------------------------------------------- /examples/example-ts/src/views/PostsPage.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from "react"; 2 | import { useAction, useGetter } from "vuex-but-for-react"; 3 | 4 | import { PostType } from '../types/types'; 5 | 6 | import Post from "../components/Post"; 7 | 8 | const PostsPage = () => { 9 | const handleFetch = useAction('POSTS_FETCH'); 10 | const posts = useGetter('posts'); 11 | 12 | useEffect(() => { 13 | handleFetch(); 14 | }, [handleFetch]); 15 | 16 | return ( 17 |
18 | {posts.map(post => ( 19 | 20 | ))} 21 |
22 | ) 23 | } 24 | 25 | export default PostsPage; 26 | -------------------------------------------------------------------------------- /examples/example-ts/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 | "strictNullChecks": false, 22 | "jsx": "react-jsx" 23 | }, 24 | "include": [ 25 | "src" 26 | ] 27 | } 28 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vuex-but-for-react", 3 | "version": "3.0.2", 4 | "private": false, 5 | "main": "./lib/cjs/index.js", 6 | "module": "./lib/esm/index.js", 7 | "types": "./lib/esm/index.d.ts", 8 | "repository": { 9 | "type": "git", 10 | "url": "https://github.com/DJanoskova/vuex-but-for-react" 11 | }, 12 | "homepage": "https://vuex-but-for-react.netlify.app/", 13 | "keywords": [ 14 | "react", 15 | "reducer", 16 | "state", 17 | "store", 18 | "functional", 19 | "flux" 20 | ], 21 | "peerDependencies": { 22 | "react": ">=18" 23 | }, 24 | "devDependencies": { 25 | "react": "^18.2.0", 26 | "typescript": "^4.1.2" 27 | }, 28 | "scripts": { 29 | "start": "react-scripts start", 30 | "build": "rm -rf ./lib && yarn build:esm && yarn build:cjs", 31 | "build:esm": "tsc", 32 | "build:cjs": "tsc --module commonjs --outDir lib/cjs" 33 | }, 34 | "eslintConfig": { 35 | "extends": [ 36 | "react-app", 37 | "react-app/jest" 38 | ] 39 | }, 40 | "browserslist": { 41 | "production": [ 42 | ">0.2%", 43 | "not dead", 44 | "not op_mini all" 45 | ], 46 | "development": [ 47 | "last 1 chrome version", 48 | "last 1 firefox version", 49 | "last 1 safari version" 50 | ] 51 | }, 52 | "files": [ 53 | "/lib" 54 | ], 55 | "dependencies": { 56 | "object-deep-recreate": "^1.0.1" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DJanoskova/vuex-but-for-react/c958008d3edc83fc1f8ead7389f95b9c68092135/public/favicon.ico -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DJanoskova/vuex-but-for-react/c958008d3edc83fc1f8ead7389f95b9c68092135/public/logo192.png -------------------------------------------------------------------------------- /public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DJanoskova/vuex-but-for-react/c958008d3edc83fc1f8ead7389f95b9c68092135/public/logo512.png -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /public/tree.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DJanoskova/vuex-but-for-react/c958008d3edc83fc1f8ead7389f95b9c68092135/public/tree.png -------------------------------------------------------------------------------- /src/lib/externalStore.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useSyncExternalStore } from 'react'; 2 | 3 | export type ExternalStoreListenerType = () => void; 4 | export type StateType> = T; 5 | export type SetStateType = (previous: T) => T; 6 | 7 | export type ExternalStoreType = { 8 | getState: () => T; 9 | setState: (fn: SetStateType) => void; 10 | subscribe: (listener: ExternalStoreListenerType) => () => void; 11 | }; 12 | 13 | export const useStore = (store: ExternalStoreType, propertyName: string): T => { 14 | const getSnapshot = useCallback(() => { 15 | return store.getState()[propertyName]; 16 | }, [store, propertyName]); 17 | 18 | return useSyncExternalStore(store.subscribe, getSnapshot); 19 | }; 20 | 21 | /** 22 | * https://blog.saeloun.com/2021/12/30/react-18-usesyncexternalstore-api 23 | */ 24 | export const createStore = (initialState: T): ExternalStoreType => { 25 | let state: T = initialState; 26 | const getState = () => state; 27 | 28 | const listeners: Set = new Set(); 29 | 30 | const setState = (fn: SetStateType) => { 31 | state = fn(state); 32 | listeners.forEach((l) => l()); 33 | }; 34 | 35 | const subscribe = (listener: ExternalStoreListenerType) => { 36 | listeners.add(listener); 37 | 38 | return () => { 39 | listeners.delete(listener); 40 | }; 41 | }; 42 | 43 | return { getState, setState, subscribe }; 44 | }; 45 | -------------------------------------------------------------------------------- /src/lib/getters.tsx: -------------------------------------------------------------------------------- 1 | import { MutableRefObject } from 'react'; 2 | import { deepRecreate } from "object-deep-recreate"; 3 | 4 | import { GetterType, VuexStoreType } from "./types"; 5 | import { getStoreKeyModuleValues, getStoreModule } from "./helpers"; 6 | import { ExternalStoreType, StateType } from './externalStore'; 7 | 8 | /** 9 | * gets the current state 10 | * runs it through all the getters 11 | * updates the gettersValues where needed 12 | * @param store 13 | * @param state 14 | * @param globalGetters 15 | * @param prevValuesRef 16 | */ 17 | export const calcAndSetGettersValues = ( 18 | store: VuexStoreType, 19 | state: T, 20 | globalGetters: ExternalStoreType>, 21 | prevValuesRef: MutableRefObject>, 22 | ) => { 23 | const getters = getStoreKeyModuleValues(store, 'getters'); 24 | const getterNames = Object.keys(getters); 25 | if (!getterNames.length) return; 26 | 27 | const setter = (values) => { 28 | const prevValues = prevValuesRef.current; 29 | 30 | getterNames.forEach(getterPath => { 31 | const moduleNames = getterPath.split('/'); 32 | let originalFn: GetterType; 33 | 34 | let value; 35 | 36 | // alter the state with the logic given in the store config 37 | if (moduleNames.length === 1) { 38 | originalFn = store.getters?.[getterPath] as GetterType; 39 | 40 | value = originalFn(state); 41 | } else { 42 | const moduleStore = getStoreModule(store, getterPath) as StateType; 43 | const moduleState = getStoreModule(state, getterPath) as T; 44 | 45 | const getterName = moduleNames[moduleNames.length - 1]; 46 | originalFn = moduleStore.getters?.[getterName] as GetterType; 47 | 48 | value = originalFn(moduleState); 49 | } 50 | 51 | const prevValueStringified = JSON.stringify(prevValues[getterPath]); 52 | const isChanged = JSON.stringify(value) !== prevValueStringified; 53 | if (isChanged) { 54 | values[getterPath] = deepRecreate(value, JSON.parse(prevValueStringified)); 55 | } 56 | }); 57 | 58 | prevValuesRef.current = JSON.parse(JSON.stringify(values)); 59 | return values; 60 | } 61 | 62 | globalGetters.setState(setter) 63 | } 64 | 65 | export const getGetterInitialValue = (getterName: string, gettersFns: Record, store: VuexStoreType) => { 66 | const originalFn = gettersFns[getterName] as GetterType; 67 | const moduleNames = getterName.split('/'); 68 | let value; 69 | 70 | // alter the state with the logic given in the store config 71 | if (moduleNames.length === 1) { 72 | value = originalFn(store.state as StateType); 73 | } else { 74 | const moduleStore = getStoreModule(store, getterName) as StateType; 75 | value = originalFn(moduleStore.state); 76 | } 77 | 78 | return value; 79 | } 80 | 81 | export const getGettersInitialValues = (store: VuexStoreType) => { 82 | const gettersFns = getStoreKeyModuleValues(store, 'getters'); 83 | const getterNames = Object.keys(gettersFns); 84 | 85 | const result: Record = {}; 86 | 87 | if (!getterNames.length) return result; 88 | 89 | getterNames.forEach(getterName => { 90 | const value = getGetterInitialValue(getterName, gettersFns, store); 91 | result[getterName] = value; 92 | }); 93 | 94 | return result; 95 | } 96 | -------------------------------------------------------------------------------- /src/lib/helpers.ts: -------------------------------------------------------------------------------- 1 | import { VuexStoreType } from "./types"; 2 | 3 | /** 4 | * Returns an object with keys and fn values 5 | * for mutations, actions and getters 6 | * Accounts for infinite levels of children modules 7 | * @param store 8 | * @param storeType 9 | * @param result 10 | * @param prefix 11 | */ 12 | export const getStoreKeyModuleValues = ( 13 | store: VuexStoreType, 14 | storeType: 'mutations' | 'actions' | 'getters', 15 | result: Record = {}, 16 | prefix = '' 17 | ) => { 18 | // get the current key names with added prefix 19 | if (store[storeType]) { 20 | let keyNames = Object.keys(store[storeType] ?? {}); 21 | 22 | keyNames.forEach(keyName => { 23 | const keyNameWithPrefix = prefix ? `${prefix}/${keyName}` : keyName; 24 | Object.assign(result, { [keyNameWithPrefix]: store[storeType]?.[keyName] }) 25 | }) 26 | } 27 | 28 | // check for child modules 29 | const childModules = Object.keys(store.modules ?? {}); 30 | if (childModules.length) { 31 | childModules.forEach(moduleName => { 32 | const childPrefix = prefix ? `${prefix}/${moduleName}` : moduleName; 33 | if (store.modules) getStoreKeyModuleValues(store.modules[moduleName], storeType, result, childPrefix); 34 | }) 35 | } 36 | 37 | return result; 38 | } 39 | 40 | /** 41 | * from projects/chemistry/POSTS_FETCH -> to projects/chemistry 42 | * @param path 43 | */ 44 | export const getStoreModuleName = (path: string) => { 45 | const moduleNames = path.split('/') 46 | // remove the last action/mutation name, keep module levels only 47 | moduleNames.splice(moduleNames.length - 1, 1) 48 | 49 | return moduleNames.join('/') + '/' 50 | } 51 | 52 | export const filterObjectModuleKeys = (data: Record, keyName) => { 53 | const modulePath = getStoreModuleName(keyName) 54 | 55 | const clonedData = { ...data } 56 | 57 | Object.keys(clonedData).forEach(key => { 58 | if (key.includes(modulePath)) { 59 | // store the module data in the root 60 | const data = clonedData[key] 61 | clonedData[key.replace(modulePath, '')] = data 62 | } 63 | delete clonedData[key] 64 | }) 65 | 66 | return clonedData; 67 | } 68 | 69 | export function getStoreModule(obj: Record, propString: string) { 70 | if (!propString) 71 | return obj; 72 | 73 | let clonedOriginal = { ...obj } 74 | 75 | const props = propString.split('/'); 76 | let prop: string 77 | 78 | for (let i = 0, iLen = props.length - 1; i < iLen; i++) { 79 | prop = props[i]; 80 | 81 | const candidate = clonedOriginal.modules?.[prop]; 82 | if (candidate !== undefined) { 83 | clonedOriginal = candidate; 84 | } else { 85 | break; 86 | } 87 | } 88 | 89 | return clonedOriginal; 90 | } 91 | 92 | export const getStoreStateWithModules = (store: VuexStoreType, result: Record = {}): InheritedStateType => { 93 | Object.assign(result, store?.state || {}); 94 | 95 | const childModules = Object.keys(store.modules ?? {}); 96 | if (childModules.length) { 97 | Object.assign(result, { modules: {} }); 98 | 99 | childModules.forEach(moduleName => { 100 | Object.assign(result.modules, { [moduleName]: {} }); 101 | if (store.modules) getStoreStateWithModules(store.modules[moduleName], result.modules[moduleName]); 102 | }) 103 | } 104 | 105 | return result as InheritedStateType; 106 | } 107 | 108 | export const handleStateFillWithLocalValues = ,>(state: T, storageName: string) => { 109 | const storedState = localStorage.getItem(storageName); 110 | if (storedState) { 111 | const storedStateValues: Record = JSON.parse(storedState); 112 | Object.keys(storedStateValues).forEach((key) => { 113 | state[key] = storedStateValues[key]; 114 | }) 115 | } 116 | } -------------------------------------------------------------------------------- /src/lib/hooks.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useContext, useEffect } from "react"; 2 | 3 | import { actionsContext, globalGettersContext, globalStoreContext, mutationsContext } from "./storeContext"; 4 | import { filterObjectModuleKeys } from "./helpers"; 5 | import { ExternalStoreType, useStore } from './externalStore'; 6 | 7 | export const useAction = (actionName: string) => { 8 | const globalStore = useContext(globalStoreContext); 9 | const actions = useContext(actionsContext); 10 | const mutations = useContext(mutationsContext); 11 | const action = actions[actionName]; 12 | 13 | if (!action) { 14 | throw new Error(`Cannot find action: ${actionName}`) 15 | } 16 | 17 | const actionWithStoreParams = useCallback<(...args: any) => Promise>((...args: any[]) => { 18 | if (!globalStore) throw new Error('No store found'); 19 | 20 | const moduleNames = actionName.split('/'); 21 | let filteredActions = actions; 22 | let filteredMutations = mutations; 23 | 24 | if (moduleNames.length > 1) { 25 | filteredActions = filterObjectModuleKeys(actions, actionName); 26 | filteredMutations = filterObjectModuleKeys(mutations, actionName); 27 | } 28 | 29 | return action({ actions: filteredActions, mutations: filteredMutations, state: globalStore.getState() }, ...args); 30 | }, [actions, mutations, actionName]) 31 | 32 | return actionWithStoreParams; 33 | } 34 | 35 | export const useActions = (values: string[]) => { 36 | const actions = useContext(actionsContext); 37 | 38 | const result: Array<(args?: any) => Promise> = []; 39 | 40 | values.forEach((actionName) => { 41 | if (!actions[actionName]) { 42 | throw new Error(`Cannot find action: ${actionName}`); 43 | } else { 44 | result.push(actions[actionName]); 45 | } 46 | }); 47 | 48 | return result; 49 | } 50 | 51 | export const useMutation = (mutationName: string) => { 52 | const mutations = useContext(mutationsContext); 53 | 54 | if (!mutations[mutationName]) { 55 | throw new Error(`Cannot find mutation: ${mutationName}`) 56 | } 57 | 58 | return mutations[mutationName]; 59 | } 60 | 61 | export const useMutations = (values: string[]) => { 62 | const mutations = useContext(mutationsContext); 63 | 64 | const result: Array<(...args: any) => void> = []; 65 | 66 | values.forEach((mutationName) => { 67 | if (!mutations[mutationName]) { 68 | throw new Error(`Cannot find mutation: ${mutationName}`); 69 | } else { 70 | result.push(mutations[mutationName]); 71 | } 72 | }); 73 | 74 | return result; 75 | } 76 | 77 | export const useGetter = (getterName: string): T => { 78 | const gettersStore = useContext(globalGettersContext); 79 | const value = useStore(gettersStore.current as ExternalStoreType, getterName); 80 | 81 | return value; 82 | } 83 | 84 | export const useActionOnMount = (actionName: string, ...params) => { 85 | const action = useAction(actionName); 86 | 87 | useEffect(() => { 88 | action(...params); 89 | }, [action, ...params]); 90 | } 91 | -------------------------------------------------------------------------------- /src/lib/index.ts: -------------------------------------------------------------------------------- 1 | export { default as withStore } from './withStore'; 2 | 3 | export { useAction, useActions, useActionOnMount, useMutation, useMutations, useGetter } from './hooks'; 4 | 5 | export type { VuexStoreType } from './types'; 6 | -------------------------------------------------------------------------------- /src/lib/storeContext.ts: -------------------------------------------------------------------------------- 1 | import { createContext, memo, MutableRefObject } from 'react'; 2 | 3 | import { ActionParamsType } from './types'; 4 | import { createStore, ExternalStoreType } from './externalStore'; 5 | 6 | export const mutationsContext = createContext void>>({}); 7 | export const MutationsProvider = memo(mutationsContext.Provider); 8 | 9 | export const actionsContext = createContext Promise>>({}); 10 | export const ActionsProvider = memo(actionsContext.Provider); 11 | 12 | export const globalStoreContext = createContext(createStore({})); 13 | export const GlobalStoreProvider = memo(globalStoreContext.Provider); 14 | 15 | type GettersContextType = MutableRefObject> 16 | export const globalGettersContext = createContext({ 17 | current: createStore({}) 18 | }); 19 | export const GlobalGettersProvider = globalGettersContext.Provider; 20 | -------------------------------------------------------------------------------- /src/lib/types.ts: -------------------------------------------------------------------------------- 1 | import { StateType } from './externalStore'; 2 | 3 | export interface VuexStoreType { 4 | state: T; 5 | mutations?: Record>; 6 | actions?: Record>; 7 | getters?: Record>; 8 | modules?: Record; 9 | } 10 | 11 | export interface ActionParamsType { 12 | mutations: Record>; 13 | actions: Record>; 14 | state: StateType; 15 | } 16 | 17 | export interface MutationType { 18 | (state: T, ...args: any): void; 19 | } 20 | export interface ActionType { 21 | (context: ActionParamsType, ...args: any): Promise 22 | } 23 | export interface GetterType { 24 | (state: T): any 25 | } 26 | 27 | export interface StoreOptionsType { 28 | localStorageName?: string; // if you wish to have synchronized state with local storage, provide the name 29 | } -------------------------------------------------------------------------------- /src/lib/withStore.tsx: -------------------------------------------------------------------------------- 1 | import React, { memo, useCallback, useEffect, useMemo, useRef } from 'react'; 2 | import { deepRecreate } from 'object-deep-recreate'; 3 | 4 | import { ActionsProvider, MutationsProvider, GlobalStoreProvider, GlobalGettersProvider } from './storeContext'; 5 | import { 6 | ActionType, 7 | MutationType, 8 | StoreOptionsType, 9 | VuexStoreType 10 | } from './types'; 11 | import { 12 | getStoreKeyModuleValues, 13 | getStoreModule, 14 | getStoreModuleName, getStoreStateWithModules, handleStateFillWithLocalValues, 15 | } from './helpers'; 16 | import { calcAndSetGettersValues, getGettersInitialValues } from './getters'; 17 | import { createStore, ExternalStoreType, StateType } from './externalStore'; 18 | 19 | const withStore = (Component: (props: any) => JSX.Element, store: VuexStoreType, options: StoreOptionsType = {}) => (props: any) => { 20 | const initialValues = useRef(getGettersInitialValues(store)); 21 | const globalStoreRef = useRef(createStore(store.state)); 22 | const globalGettersRef = useRef(createStore(initialValues.current)); 23 | const prevGettersRef = useRef>(JSON.parse(JSON.stringify(initialValues.current))); 24 | 25 | const handleGettersValuesSet = useCallback((newValues: InheritedStateType) => { 26 | calcAndSetGettersValues(store, newValues, globalGettersRef.current, prevGettersRef); 27 | }, [options.localStorageName]); 28 | 29 | useEffect(() => { 30 | const stateInitialValues = getStoreStateWithModules(store); 31 | 32 | if (options.localStorageName && stateInitialValues) { 33 | handleStateFillWithLocalValues(stateInitialValues, options.localStorageName); 34 | } 35 | 36 | globalStoreRef.current.setState(() => stateInitialValues); 37 | }, []); 38 | 39 | const mutations = useMemo(() => { 40 | return getMutations(store, globalStoreRef.current, handleGettersValuesSet, options.localStorageName); 41 | }, [handleGettersValuesSet]); 42 | 43 | const actions = useMemo(() => { 44 | const actionsFns = getStoreKeyModuleValues(store, 'actions'); 45 | return actionsFns ?? {}; 46 | }, []); 47 | 48 | const MemoizedComponent = useMemo(() => memo(Component), []); 49 | 50 | return ( 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | ); 61 | }; 62 | 63 | const getMutations = ( 64 | storeConfig: VuexStoreType, 65 | globalStore: ExternalStoreType, 66 | handleGettersValuesSet: (newValues: T) => void, 67 | storageName?: string, 68 | ) => { 69 | const mutations = getStoreKeyModuleValues(storeConfig, 'mutations'); 70 | const mutationNames = Object.keys(mutations); 71 | if (!mutationNames.length) return {}; 72 | 73 | const values: Record void> = {}; 74 | 75 | mutationNames.forEach(mutationName => { 76 | const originalFn = mutations[mutationName] as MutationType; 77 | values[mutationName] = (...args) => { 78 | if (!globalStore) { 79 | throw new Error('No store found') 80 | } 81 | 82 | const setter = (state: StateType) => { 83 | const prevStateCloned: T = JSON.parse(JSON.stringify(state)); 84 | const moduleNames = mutationName.split('/'); 85 | 86 | // alter the state with the logic given in the store config 87 | if (moduleNames.length === 1) { 88 | originalFn(state, ...args) 89 | } else { 90 | const moduleName = getStoreModuleName(mutationName); 91 | const moduleState = getStoreModule(state, moduleName); 92 | originalFn(moduleState as T, ...args) 93 | } 94 | 95 | const newValues: T = deepRecreate(state, prevStateCloned) as T 96 | 97 | handleGettersValuesSet(newValues); 98 | if (storageName) { 99 | localStorage.setItem(storageName, JSON.stringify(newValues)) 100 | } 101 | return newValues; 102 | } 103 | 104 | globalStore.setState(setter); 105 | } 106 | }) 107 | 108 | return values; 109 | } 110 | 111 | export default withStore; 112 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "lib/esm", 4 | "module": "esnext", 5 | "target": "es5", 6 | "lib": ["es6", "dom", "es2016", "es2017"], 7 | "jsx": "react", 8 | "declaration": true, 9 | "moduleResolution": "node", 10 | "noUnusedLocals": true, 11 | "noUnusedParameters": true, 12 | "esModuleInterop": true, 13 | "noImplicitReturns": true, 14 | "noImplicitThis": true, 15 | "strictNullChecks": true, 16 | "suppressImplicitAnyIndexErrors": true, 17 | "allowSyntheticDefaultImports": true 18 | }, 19 | "include": ["src"], 20 | "exclude": ["node_modules", "lib"] 21 | } 22 | -------------------------------------------------------------------------------- /yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | "js-tokens@^3.0.0 || ^4.0.0": 6 | version "4.0.0" 7 | resolved "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz" 8 | integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== 9 | 10 | loose-envify@^1.1.0: 11 | version "1.4.0" 12 | resolved "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz" 13 | integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q== 14 | dependencies: 15 | js-tokens "^3.0.0 || ^4.0.0" 16 | 17 | object-deep-recreate@^1.0.1: 18 | version "1.0.1" 19 | resolved "https://registry.yarnpkg.com/object-deep-recreate/-/object-deep-recreate-1.0.1.tgz#93f548f4dceb94cbf5fe26a9b9426d4d8a321d80" 20 | integrity sha512-CS0znAjOutpuqGWCFizxFoFcqpa7FH3mxqXNa7Tn7w/7SvWjDFJX2JxB7cvI2JATEr8iCKmmLIEipM3fIE/E2w== 21 | 22 | react@^18.2.0: 23 | version "18.2.0" 24 | resolved "https://registry.npmjs.org/react/-/react-18.2.0.tgz" 25 | integrity sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ== 26 | dependencies: 27 | loose-envify "^1.1.0" 28 | 29 | typescript@^4.1.2: 30 | version "4.3.5" 31 | resolved "https://registry.npmjs.org/typescript/-/typescript-4.3.5.tgz" 32 | integrity sha512-DqQgihaQ9cUrskJo9kIyW/+g0Vxsk8cDtZ52a3NGh0YNTfpUSArXSohyUGnvbPazEPLu398C0UxmKSOrPumUzA== 33 | --------------------------------------------------------------------------------