├── .gitignore ├── .prettierignore ├── packages ├── optimistic-state │ ├── .npmignore │ ├── jest.config.js │ ├── package.json │ ├── tsconfig.json │ ├── README.md │ ├── src │ │ └── index.ts │ └── test │ │ └── index.test.ts └── use-optimistic-state │ ├── .npmignore │ ├── jest.config.js │ ├── tsconfig.json │ ├── package.json │ ├── src │ └── index.ts │ ├── README.md │ └── test │ └── index.test.ts ├── .eslintrc ├── lerna.json ├── .prettierrc ├── package.json ├── LICENSE └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | lib 3 | dist -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | flow-typed 3 | lib 4 | node_modules 5 | github -------------------------------------------------------------------------------- /packages/optimistic-state/.npmignore: -------------------------------------------------------------------------------- 1 | # Source and dev files 2 | src 3 | test 4 | jest.config.js 5 | tsconfig.js -------------------------------------------------------------------------------- /packages/use-optimistic-state/.npmignore: -------------------------------------------------------------------------------- 1 | # Source and dev files 2 | src 3 | test 4 | jest.config.js 5 | tsconfig.js -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "plugins": ["import"], 4 | "extends": ["prettier", "standard"] 5 | } 6 | -------------------------------------------------------------------------------- /lerna.json: -------------------------------------------------------------------------------- 1 | { 2 | "packages": [ 3 | "packages/*" 4 | ], 5 | "version": "1.0.0", 6 | "npmClient": "yarn", 7 | "useWorkspaces": true 8 | } 9 | -------------------------------------------------------------------------------- /packages/optimistic-state/jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('@ts-jest/dist/types').InitialOptionsTsJest} */ 2 | module.exports = { 3 | preset: 'ts-jest', 4 | testEnvironment: 'node', 5 | }; -------------------------------------------------------------------------------- /packages/use-optimistic-state/jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('@ts-jest/dist/types').InitialOptionsTsJest} */ 2 | module.exports = { 3 | preset: 'ts-jest', 4 | testEnvironment: 'node', 5 | }; -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "arrowParens": "always", 4 | "singleQuote": true, 5 | "tabWidth": 2, 6 | "useTabs": false, 7 | "semi": true, 8 | "bracketSpacing": true, 9 | "jsxBracketSameLine": false, 10 | "requirePragma": false, 11 | "proseWrap": "preserve", 12 | "trailingComma": "all" 13 | } -------------------------------------------------------------------------------- /packages/optimistic-state/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@perceived/optimistic-state", 3 | "version": "1.0.0", 4 | "main": "lib/index.js", 5 | "types": "lib/index.d.ts", 6 | "license": "MIT", 7 | "scripts": { 8 | "build": "tsc", 9 | "test": "jest" 10 | }, 11 | "publishConfig": { 12 | "access": "public" 13 | }, 14 | "devDependencies": { 15 | "jest": "^27.0.6" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /packages/optimistic-state/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "esnext"], 5 | "outDir": "lib", 6 | "declaration": true, 7 | "skipLibCheck": true, 8 | "esModuleInterop": true, 9 | "allowSyntheticDefaultImports": true, 10 | "strict": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "noFallthroughCasesInSwitch": true, 13 | "module": "commonjs", 14 | "moduleResolution": "node", 15 | "isolatedModules": true 16 | }, 17 | "include": ["src"] 18 | } 19 | -------------------------------------------------------------------------------- /packages/use-optimistic-state/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "esnext"], 5 | "outDir": "lib", 6 | "declaration": true, 7 | "skipLibCheck": true, 8 | "esModuleInterop": true, 9 | "allowSyntheticDefaultImports": true, 10 | "strict": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "noFallthroughCasesInSwitch": true, 13 | "module": "commonjs", 14 | "moduleResolution": "node", 15 | "isolatedModules": true 16 | }, 17 | "include": ["src"] 18 | } 19 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "root", 3 | "license": "MIT", 4 | "private": true, 5 | "scripts": { 6 | "prepublishOnly": "lerna run test && lerna run build" 7 | }, 8 | "devDependencies": { 9 | "lerna": "^4.0.0", 10 | "typescript": "^4.3.5", 11 | "prettier": "^2.3.2", 12 | "eslint": "^7.31.0", 13 | "jest": "^27.0.6", 14 | "ts-jest": "^27.0.4", 15 | "eslint-config-prettier": "^8.3.0", 16 | "eslint-plugin-import": "^2.23.4", 17 | "eslint-config-standard": "^16.0.3", 18 | "@typescript-eslint/parser": "^4.28.4", 19 | "@types/jest": "^27.0.1" 20 | }, 21 | "workspaces": [ 22 | "packages/*" 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /packages/use-optimistic-state/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@perceived/use-optimistic-state", 3 | "version": "1.0.0", 4 | "license": "MIT", 5 | "main": "lib/index.js", 6 | "types": "lib/index.d.ts", 7 | "scripts": { 8 | "build": "tsc", 9 | "test": "jest" 10 | }, 11 | "devDependencies": { 12 | "@testing-library/react-hooks": "^7.0.1", 13 | "@types/react": "^17.0.14", 14 | "jest": "^27.0.6", 15 | "react": "^17.0.2", 16 | "react-test-renderer": "17.0.2" 17 | }, 18 | "dependencies": { 19 | "@perceived/optimistic-state": "^1.0.0" 20 | }, 21 | "peerDependencies": { 22 | "react": ">= 16.8.0" 23 | }, 24 | "publishConfig": { 25 | "access": "public" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 perceived-dev 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 | -------------------------------------------------------------------------------- /packages/use-optimistic-state/src/index.ts: -------------------------------------------------------------------------------- 1 | import { useState, useRef, useMemo } from 'react'; 2 | import optimisticState from '@perceived/optimistic-state'; 3 | 4 | export type HookResult = { 5 | state: T; 6 | updateState: (state: T, ...args: any[]) => void; 7 | loading: boolean; 8 | result?: R; 9 | error?: E; 10 | }; 11 | 12 | export default function useOptimisticState( 13 | initialState: T, 14 | routine: (state: T, ...args: any[]) => Promise, 15 | ): HookResult { 16 | const [state, setState] = useState(initialState); 17 | const [loading, setLoading] = useState(false); 18 | const [result, setResult] = useState(); 19 | const [error, setError] = useState(); 20 | const routineRef = useRef(routine); 21 | 22 | // keep the routine ref upto date 23 | routineRef.current = routine; 24 | 25 | const updateState = useMemo(() => { 26 | return optimisticState({ 27 | initialState, 28 | routine: (state: T, ...args: any[]) => { 29 | setLoading(true); 30 | // on routine start we should clear the last error 31 | setError(undefined); 32 | return routineRef.current(state, ...args); 33 | }, 34 | handleState: setState, 35 | handleResult: (res) => { 36 | setLoading(false); 37 | setResult(res); 38 | }, 39 | handleError: (err) => { 40 | setLoading(false); 41 | setError(err); 42 | }, 43 | }); 44 | }, []); 45 | 46 | return { state, updateState, loading, error, result }; 47 | } 48 | -------------------------------------------------------------------------------- /packages/use-optimistic-state/README.md: -------------------------------------------------------------------------------- 1 | # use-optimistic-state 2 | 3 | React hooks for optimistic state with rollbacks and race condition handling. 4 | 5 | ## What and why an optimistic state? 6 | 7 | [Optimistic UI and Optimistic States](https://github.com/perceived-dev/optimistic-state#what-is-an-optimistic-state) 8 | 9 | ## Installation 10 | 11 | ``` 12 | npm install @perceived/use-optimistic-state 13 | ``` 14 | 15 | ## Usage 16 | 17 | ```js 18 | import useOptimisticState from '@perceived/use-optimistic-state'; 19 | ``` 20 | 21 | ```js 22 | function routine(state) { 23 | // async routine which should return promise 24 | return syncCounterToServer(state); 25 | } 26 | 27 | function App() { 28 | const { state, updateState, result, error, loading } = useOptimisticState(0, routine); 29 | 30 | return ( 31 |
32 | ... 33 | Likes: ${state} 34 | 35 | ... 36 |
37 | ); 38 | } 39 | ``` 40 | 41 | ## API 42 | 43 | ### Types 44 | 45 | - TState : Type of the optimistic state 46 | - TResult: Type of api result (defaults to any) 47 | - TError: Type of error (defaults to any) 48 | 49 | ### Arguments 50 | 51 | #### initialState : `TState` 52 | 53 | The initialState of optimistic state, before any action is fired. 54 | 55 | #### routine : `(state: TState, ...args[]) => Promise` 56 | 57 | Async routine (mostly an api call which sync client state to server). This routine must return a promise. 58 | 59 | ### Return properties 60 | 61 | #### state: `TState;` 62 | 63 | This represents up to date optimistic states. Its updated before the routine start and on rollbacks. 64 | 65 | #### result: `TResult` 66 | 67 | This represent to the up to date last resolved data from series of actions. It is set when the last action is resolved. In case of error on last action, it is set with the last resolved data of action where it is rolled back. 68 | 69 | For example if if there is series of action X, Y, Z. If Z is resolved (irrespective of X, Y failed or passed), handleResult will be called with Z data. But in case if Y, Z fails, handleResult will be called with X data. 70 | 71 | #### error: `TError` 72 | 73 | The error is set when the last action (in series of actions) fails. If last action is passed and there is failure on previous actions it will not set the error. 74 | 75 | Note: It resets when ever we trigger the routine again. 76 | 77 | #### loading: `boolean` 78 | 79 | It represents the loading state while action is triggered, it resets back when the optimistic state is resolved from backend. 80 | 81 | Note: If there is series of simultaneous actions, it will maintain the loading state until the final state is resolved/rejected. 82 | 83 | ## Demo 84 | 85 | Try simulating all the cases in this example. [https://codesandbox.io/s/use-optimistic-state-g5hds](https://codesandbox.io/s/use-optimistic-state-g5hds) 86 | -------------------------------------------------------------------------------- /packages/optimistic-state/README.md: -------------------------------------------------------------------------------- 1 | # optimistic-state 2 | 3 | Micro library for optimistic state with rollbacks and race condition handling. 4 | 5 | ## What and why an optimistic state? 6 | 7 | [Optimistic UI and Optimistic States](https://github.com/perceived-dev/optimistic-state#what-is-an-optimistic-state) 8 | 9 | ## Installation 10 | 11 | ``` 12 | npm install @perceived/optimistic-state 13 | ``` 14 | 15 | ## Usage 16 | 17 | ```js 18 | import optimisticState from '@perceived/optimistic-state'; 19 | ``` 20 | 21 | ```js 22 | function routine(state) { 23 | // async routine which should return promise 24 | return syncCounterToServer(state); 25 | } 26 | 27 | let count; 28 | 29 | const updateState = optimisticState({ 30 | initialState: 0, 31 | routine, 32 | handleState: (state) => { 33 | count = state; 34 | // handle optimistic state 35 | document.querySelector('.current-count').innerHTML = state; 36 | }, 37 | handleResult: (result) => { 38 | document.querySelector('.result').innerHTML = result; 39 | }, 40 | handleError: (err) => { 41 | // handle error, may be display error as toast/notification message 42 | message.error(err); 43 | }, 44 | }); 45 | 46 | document.querySelector('#increment-btn').addEventListener('click', () => { 47 | updateState(count + 1); 48 | }); 49 | ``` 50 | 51 | ## API 52 | 53 | ### Types 54 | 55 | - TState : Type of the optimistic state 56 | - TResult: Type of api result (defaults to any) 57 | - TError: Type of error (defaults to any) 58 | 59 | ### Options 60 | 61 | #### initialState : `TState` 62 | 63 | The initialState of optimistic state, before any action is fired. 64 | 65 | #### routine : `(state: TState, ...args[]) => Promise` 66 | 67 | Async routine (mostly an api call which sync client state to server). This routine must return a promise. 68 | 69 | #### handleState: `(state: TState) => void;` 70 | 71 | A callback to handle the optimistic state, this is fired when a routine is called, and also on rollbacks with the state. 72 | 73 | #### handleResult: `(result: TResult) => void;` 74 | 75 | The handleResult is called when the last action is resolved with the resolved data. In case of error on last action it is called with the last resolved data of action where it is rolled back. 76 | 77 | For example if if there is series of action X, Y, Z. If Z is resolved (irrespective of X, Y failed or passed), handleResult will be called with Z data. But in case if Y, Z fails, handleResult will be called with X data. 78 | 79 | #### handleError: `(err: TError) => void;` 80 | 81 | The handleError will be called with reject reason, if the last action in series of action fails. 82 | 83 | Note: If last action is passed and there is failure on previous actions, it will not call handleError 84 | 85 | ### Return 86 | 87 | #### updateState: `(state: TState, ...args[] ) => void` 88 | 89 | optimisticState return a updater function, which accepts new state as first argument, followed by any number of arguments. All of the arguments are passed to routine function. 90 | 91 | ## Demo 92 | 93 | Try simulating all the cases in this example. [https://codesandbox.io/s/optimistic-state-rc9m5](https://codesandbox.io/s/optimistic-state-rc9m5) 94 | -------------------------------------------------------------------------------- /packages/optimistic-state/src/index.ts: -------------------------------------------------------------------------------- 1 | type OptimisticStateOptions = { 2 | initialState: TState; 3 | routine: (state: TState, ...args: any[]) => Promise; 4 | handleState: (state: TState) => void; 5 | handleResult?: (data: TResult) => void; 6 | handleError?: (error: TError) => void; 7 | }; 8 | 9 | const noop = () => {}; 10 | 11 | export default function optimisticState({ 12 | initialState, 13 | routine, // async routine 14 | handleState, // optimistic state 15 | handleResult = noop, // last successful result 16 | handleError = noop, // last error 17 | }: OptimisticStateOptions) { 18 | let resolvedState = initialState; 19 | let promises: Promise[] = []; 20 | let states: TState[] = []; 21 | 22 | const reset = () => { 23 | //reset the promises and states 24 | promises = []; 25 | states = []; 26 | }; 27 | 28 | return (state: TState, ...args: any[]) => { 29 | const promise = routine(state, ...args); 30 | 31 | // optimistically update state 32 | handleState(state); 33 | 34 | // keep the reference of state for rollback purpose 35 | states.push(state); 36 | 37 | // create a new list of promises with existing ones and the new one 38 | const curPromises = promises.concat([promise]); 39 | 40 | /** 41 | * store the promiseList on promises. 42 | * Note, promises will always point to the latest list, 43 | * while curPromises will be the list when the handler was called. 44 | */ 45 | promises = curPromises; 46 | 47 | promise.then( 48 | (value) => { 49 | // handle only in case its the last promise 50 | if (promise === promises[promises.length - 1]) { 51 | /** 52 | * if the final one is successful we ignore all the 53 | * previous promises and treat the resolution to be successful 54 | */ 55 | resolvedState = states[states.length - 1]; 56 | handleResult(value); 57 | reset(); 58 | } 59 | }, 60 | (err) => { 61 | // ignore error here 62 | }, 63 | ); 64 | 65 | // handle rollbacks 66 | Promise.allSettled(curPromises).then((values) => { 67 | // when it is resolved if the promises is updated ignore the previous allSettled value 68 | if (promises !== curPromises) return; 69 | 70 | const lastValue = values[values.length - 1]; 71 | 72 | // if the resolution is successful we don't need to rollback 73 | if (lastValue.status === 'fulfilled') return; 74 | 75 | if (lastValue.status === 'rejected') { 76 | // if the last one is error treat the final resolution as error and rollback to closest success 77 | handleError(lastValue.reason); 78 | 79 | for (let i = values.length - 1; i >= 0; i--) { 80 | // if we find anything resolved previously rollback to it 81 | const curValue = values[i]; 82 | if (curValue.status === 'fulfilled') { 83 | resolvedState = states[i]; 84 | handleState(resolvedState); 85 | handleResult(curValue.value); 86 | break; 87 | } 88 | 89 | // if we have reached the first promise and if that is also a failure reset to last resolved state 90 | if (i === 0 && curValue.status === 'rejected') { 91 | handleState(resolvedState); 92 | } 93 | } 94 | } 95 | 96 | reset(); 97 | }); 98 | }; 99 | } 100 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # optimistic-state 2 | 3 | Optimistic state with rollbacks and race condition handling. 4 | 5 | ## What is an optimistic state? 6 | 7 | When a state is derived from client and synced to the server, you can take benefit of that state and optimistically update UI (even before you receive response from the server) with a hope that server request will pass. If it fails you revert the UI. 8 | 9 | As 99% chance the API will pass, it gives user a snappier feeling even the things are happening in background. Its called perceived experience. The key is to respond as quick as possible on user interaction. In case of error its fine to show error (may be as a toast/notification) and revert the change. But still you are optimizing UI for 99% of user. 10 | 11 | Such UIs are called Optimistic UI. There are multiple place where Optimistic UI are helpful. 12 | 13 | - Like button 14 | - Adding items into a favorite list / Cart 15 | - Bookmarking 16 | - Filters with checkboxes (or any other elements) 17 | - Or any place where you don't want to show loading state on interacting element. 18 | 19 | But managing states for Optimistic UI is tricky. As the interactions and API response and timing is non deterministic, it becomes hard to provide final correct state. **optimistic-state** is a micro library to manage the state of an Optimistic UI with rollbacks in case of failures and race conditions handling in case of multiple simultaneous interactions. 20 | 21 | ## Different use cases an optimistic-state library should handle 22 | 23 | - **It should optimistically update state as soon as user interact and give the result when resolved.** 24 | 25 | For example as soon as user clicks like button on a post you show that the user has liked it, increase the liked count by 1. But when the response of API comes you set the correct count (may be other users have liked it at the same time). 26 | 27 | - **It should handle race conditions when multiple interactions and API calls are involved.** 28 | 29 | As we are optimistically updating the UI we don't disable button on which user interacts. This can lead to user clicking button multiple time. As API timings are non-deterministic we should handle race conditions and only apply the result of last interaction. 30 | 31 | For example: On click of like button multiple times simultaneously, we should handle the total count from the last API response only. 32 | 33 | - **It should rollback to last resolved interaction in case of error** 34 | 35 | Even with a optimistic update we want user to be notified about error, and revert the changes so user can perform the action again. When there is series of actions lets say X, Y, Z, if Y and Z both gives error. It should notify about the last error (Z) and the state should rollback to X. In case all (X, Y, Z) fails it should rollback initial value. 36 | 37 | - **It should ignore previous actions if the last one succeeds** 38 | 39 | In case of optimistic UI we always care about the final state, intermediate failure can be ignored if the last one passed. 40 | Let's say there is series of action X, Y, Z. If X and Y fails but Z passes thats the final state so we don't need to inform the user about failure of X and Y. 41 | 42 | ## Demo 43 | 44 | Try simulating all the cases in this example. [https://codesandbox.io/s/use-optimistic-state-g5hds](https://codesandbox.io/s/use-optimistic-state-g5hds) 45 | 46 | ## Usage 47 | 48 | There is two flavour of optimistic-state right now. Check the API and usage in respective docs. 49 | 50 | - Vanilla JS [optimistic-state](https://github.com/perceived-dev/optimistic-state/tree/main/packages/optimistic-state) 51 | - React hook [use-optimistic-state](https://github.com/perceived-dev/optimistic-state/tree/main/packages/use-optimistic-state) 52 | 53 | React hook `use-optimistic-state` is just a wrapper on top of `optimistic-state`. If you are looking for other lib/framework you can write one using `optimistic-state` and probably contribute back here. 🙂 54 | -------------------------------------------------------------------------------- /packages/use-optimistic-state/test/index.test.ts: -------------------------------------------------------------------------------- 1 | import { renderHook, act } from '@testing-library/react-hooks'; 2 | import useOptimisticState, { HookResult } from '../src/index'; 3 | 4 | type Action = { 5 | state: number; 6 | type: 'success' | 'error'; 7 | delay: number; 8 | result?: string; 9 | error?: any; 10 | }; 11 | 12 | type StateData = HookResult; 13 | 14 | function wait(delay: number) { 15 | return new Promise((resolve, reject) => { 16 | setTimeout(() => { 17 | resolve(null); 18 | }, delay); 19 | }); 20 | } 21 | 22 | function withSuccess(data: any, delay: number) { 23 | return new Promise((resolve, reject) => { 24 | setTimeout(() => { 25 | resolve(data); 26 | }, delay); 27 | }); 28 | } 29 | 30 | function withError(err: any, delay: number) { 31 | return new Promise((resolve, reject) => { 32 | setTimeout(() => { 33 | reject(err); 34 | }, delay); 35 | }); 36 | } 37 | 38 | function processActions(actions: Action[], optimisticHandler: (...args: any[]) => void) { 39 | actions.forEach((action) => { 40 | optimisticHandler(action.state, action); 41 | }); 42 | } 43 | 44 | function routine(state: number, action: Action) { 45 | if (action.type === 'success') { 46 | return withSuccess(action.result, action.delay); 47 | } else { 48 | return withError(action.error, action.delay); 49 | } 50 | } 51 | 52 | describe('Test optimistic-state', () => { 53 | it('should optimistically update state and give result when resolved', async () => { 54 | const actions: Action[] = [{ state: 1, type: 'success', delay: 300, result: 'Result for 1' }]; 55 | 56 | const { result } = renderHook(() => useOptimisticState(0, routine)); 57 | 58 | act(() => { 59 | processActions(actions, result.current.updateState); 60 | }); 61 | 62 | expect(result.current.state).toEqual(1); 63 | expect(result.current.loading).toEqual(true); 64 | 65 | await act(async () => { 66 | await wait(400); 67 | }); 68 | 69 | expect(result.current.state).toEqual(1); 70 | expect(result.current.loading).toEqual(false); 71 | expect(result.current.result).toEqual('Result for 1'); 72 | }); 73 | 74 | it('should rollback and give error when last promise is rejected', async () => { 75 | const actions: Action[] = [ 76 | { state: 1, type: 'success', delay: 500, result: 'Result for 1' }, 77 | { state: 2, type: 'error', delay: 300, error: 'Error for 2' }, 78 | ]; 79 | 80 | const { result } = renderHook(() => useOptimisticState(0, routine)); 81 | 82 | act(() => { 83 | processActions(actions, result.current.updateState); 84 | }); 85 | 86 | expect(result.current.state).toEqual(2); 87 | expect(result.current.loading).toEqual(true); 88 | 89 | await act(async () => { 90 | await wait(500); 91 | }); 92 | 93 | expect(result.current.state).toEqual(1); 94 | expect(result.current.loading).toEqual(false); 95 | expect(result.current.result).toEqual('Result for 1'); 96 | expect(result.current.error).toEqual('Error for 2'); 97 | }); 98 | 99 | it('should reset error when new routine starts but should maintain the last result', async () => { 100 | const { result } = renderHook(() => useOptimisticState(0, routine)); 101 | 102 | act(() => { 103 | processActions( 104 | [{ state: 1, type: 'success', delay: 300, result: 'Result for 1' }], 105 | result.current.updateState, 106 | ); 107 | }); 108 | 109 | await act(async () => { 110 | await wait(400); 111 | }); 112 | 113 | act(() => { 114 | processActions( 115 | [{ state: 2, type: 'error', delay: 300, error: 'Error for 2' }], 116 | result.current.updateState, 117 | ); 118 | }); 119 | 120 | await act(async () => { 121 | await wait(400); 122 | }); 123 | 124 | expect(result.current.state).toEqual(1); 125 | expect(result.current.error).toEqual('Error for 2'); 126 | expect(result.current.result).toEqual('Result for 1'); 127 | 128 | act(() => { 129 | processActions( 130 | [{ state: 3, type: 'success', delay: 300, result: 'Result for 3' }], 131 | result.current.updateState, 132 | ); 133 | }); 134 | 135 | expect(result.current.state).toEqual(3); 136 | expect(result.current.error).toEqual(undefined); 137 | expect(result.current.result).toEqual('Result for 1'); 138 | 139 | await act(async () => { 140 | await wait(400); 141 | }); 142 | 143 | expect(result.current.state).toEqual(3); 144 | expect(result.current.error).toEqual(undefined); 145 | expect(result.current.result).toEqual('Result for 3'); 146 | }); 147 | }); 148 | -------------------------------------------------------------------------------- /packages/optimistic-state/test/index.test.ts: -------------------------------------------------------------------------------- 1 | import optimisticState from '../src/index'; 2 | 3 | type Action = { 4 | state: number; 5 | type: 'success' | 'error'; 6 | delay: number; 7 | result?: string; 8 | error?: any; 9 | }; 10 | 11 | function wait(delay: number) { 12 | return new Promise((resolve, reject) => { 13 | setTimeout(() => { 14 | resolve(null); 15 | }, delay); 16 | }); 17 | } 18 | 19 | function withSuccess(data: any, delay: number) { 20 | return new Promise((resolve, reject) => { 21 | setTimeout(() => { 22 | resolve(data); 23 | }, delay); 24 | }); 25 | } 26 | 27 | function withError(err: any, delay: number) { 28 | return new Promise((resolve, reject) => { 29 | setTimeout(() => { 30 | reject(err); 31 | }, delay); 32 | }); 33 | } 34 | 35 | function processActions(actions: Action[], optimisticHandler: (...args: any[]) => void) { 36 | actions.forEach((action) => { 37 | optimisticHandler(action.state, action); 38 | }); 39 | } 40 | 41 | function routine(state: number, action: Action) { 42 | if (action.type === 'success') { 43 | return withSuccess(action.result, action.delay); 44 | } else { 45 | return withError(action.error, action.delay); 46 | } 47 | } 48 | 49 | describe('Test optimistic-state', () => { 50 | it('should optimistically update state and give result when resolved', async () => { 51 | const actions: Action[] = [{ state: 1, type: 'success', delay: 300, result: 'Result for 1' }]; 52 | let state = 0; 53 | let result; 54 | const optimisticHandler = optimisticState({ 55 | initialState: state, 56 | routine, 57 | handleState: (_state) => (state = _state), 58 | handleResult: (_result) => (result = _result), 59 | }); 60 | 61 | processActions(actions, optimisticHandler); 62 | 63 | expect(state).toEqual(1); 64 | expect(result).toEqual(undefined); 65 | 66 | await wait(400); 67 | 68 | expect(result).toEqual('Result for 1'); 69 | }); 70 | 71 | it('should give error and rollback when rejected', async () => { 72 | const actions: Action[] = [{ state: 1, type: 'error', delay: 300, error: 'error for 1' }]; 73 | let state = 0; 74 | let error; 75 | const optimisticHandler = optimisticState({ 76 | initialState: state, 77 | routine, 78 | handleState: (_state) => (state = _state), 79 | handleError: (_errror) => (error = _errror), 80 | }); 81 | 82 | processActions(actions, optimisticHandler); 83 | 84 | expect(state).toEqual(1); 85 | expect(error).toEqual(undefined); 86 | 87 | await wait(400); 88 | 89 | expect(state).toEqual(0); 90 | expect(error).toEqual('error for 1'); 91 | }); 92 | 93 | it('should handle race conditions when multiple promises are involved', async () => { 94 | const actions: Action[] = [ 95 | { state: 1, type: 'success', delay: 300, result: 'Result for 1' }, 96 | { state: 2, type: 'success', delay: 800, result: 'Result for 2' }, 97 | { state: 3, type: 'success', delay: 500, result: 'Result for 3' }, 98 | ]; 99 | let state = 0; 100 | let result; 101 | let error; 102 | 103 | const optimisticHandler = optimisticState({ 104 | initialState: state, 105 | routine, 106 | handleState: (_state) => (state = _state), 107 | handleResult: (_result) => (result = _result), 108 | handleError: (_errror) => (error = _errror), 109 | }); 110 | 111 | processActions(actions, optimisticHandler); 112 | 113 | expect(state).toEqual(3); 114 | 115 | await wait(1000); 116 | 117 | expect(state).toEqual(3); 118 | expect(result).toEqual('Result for 3'); 119 | }); 120 | 121 | it('should ignore previous promises if last promise is resolved', async () => { 122 | const actions: Action[] = [ 123 | { state: 1, type: 'success', delay: 300, result: 'Result for 1' }, 124 | { state: 2, type: 'error', delay: 800, error: 'Error for 2' }, 125 | { state: 3, type: 'success', delay: 500, result: 'Result for 3' }, 126 | ]; 127 | let state = 0; 128 | let result; 129 | let error; 130 | 131 | const optimisticHandler = optimisticState({ 132 | initialState: state, 133 | routine, 134 | handleState: (_state) => (state = _state), 135 | handleResult: (_result) => (result = _result), 136 | handleError: (_errror) => (error = _errror), 137 | }); 138 | 139 | processActions(actions, optimisticHandler); 140 | 141 | expect(state).toEqual(3); 142 | 143 | // it shouldn't wait for previous promises to get resolve/reject if last is passed 144 | await wait(600); 145 | 146 | expect(state).toEqual(3); 147 | expect(result).toEqual('Result for 3'); 148 | expect(error).toEqual(undefined); 149 | 150 | // even previous request fails nothing should change 151 | await wait(1000); 152 | expect(state).toEqual(3); 153 | expect(result).toEqual('Result for 3'); 154 | expect(error).toEqual(undefined); 155 | }); 156 | 157 | it('should roll back to last passed state and returns the error along with the last resolve value ', async () => { 158 | const actions: Action[] = [ 159 | { state: 1, type: 'success', delay: 300, result: 'Result for 1' }, 160 | { state: 2, type: 'error', delay: 400, error: 'error for 2' }, 161 | { state: 3, type: 'success', delay: 400, result: 'Result for 3' }, 162 | { state: 4, type: 'error', delay: 800, error: 'error for 4' }, 163 | { state: 5, type: 'error', delay: 500, error: 'error for 5' }, 164 | ]; 165 | 166 | let state = 0; 167 | let result; 168 | let error; 169 | 170 | const optimisticHandler = optimisticState({ 171 | initialState: state, 172 | routine, 173 | handleState: (_state) => (state = _state), 174 | handleResult: (_result) => (result = _result), 175 | handleError: (_errror) => (error = _errror), 176 | }); 177 | 178 | processActions(actions, optimisticHandler); 179 | 180 | expect(state).toEqual(5); 181 | 182 | await wait(1000); 183 | 184 | expect(state).toEqual(3); 185 | expect(result).toEqual('Result for 3'); 186 | expect(error).toEqual('error for 5'); 187 | }); 188 | 189 | it('should roll back to initial value if all are is error', async () => { 190 | const actions: Action[] = [ 191 | { state: 1, type: 'error', delay: 200, error: 'error for 1' }, 192 | { state: 2, type: 'error', delay: 500, error: 'error for 2' }, 193 | { state: 3, type: 'error', delay: 300, error: 'error for 3' }, 194 | ]; 195 | 196 | let state = 999; 197 | let result; 198 | let error; 199 | 200 | const optimisticHandler = optimisticState({ 201 | initialState: state, 202 | routine, 203 | handleState: (_state) => (state = _state), 204 | handleResult: (_result) => (result = _result), 205 | handleError: (_errror) => (error = _errror), 206 | }); 207 | 208 | processActions(actions, optimisticHandler); 209 | 210 | expect(state).toEqual(3); 211 | 212 | await wait(600); 213 | 214 | expect(state).toEqual(999); 215 | expect(result).toEqual(undefined); 216 | expect(error).toEqual('error for 3'); 217 | }); 218 | 219 | it('should roll back to last resolve state in case actions are triggered in future and all failed', async () => { 220 | let state = 999; 221 | let result; 222 | let error; 223 | 224 | const optimisticHandler = optimisticState({ 225 | initialState: state, 226 | routine, 227 | handleState: (_state) => (state = _state), 228 | handleResult: (_result) => (result = _result), 229 | handleError: (_errror) => (error = _errror), 230 | }); 231 | 232 | processActions( 233 | [{ state: 1, type: 'success', delay: 200, result: 'Result for 1' }], 234 | optimisticHandler, 235 | ); 236 | 237 | expect(state).toEqual(1); 238 | 239 | await wait(300); 240 | expect(state).toEqual(1); 241 | expect(result).toEqual('Result for 1'); 242 | 243 | const actions: Action[] = [ 244 | { state: 2, type: 'error', delay: 300, error: 'error for 2' }, 245 | { state: 3, type: 'error', delay: 200, error: 'error for 3' }, 246 | ]; 247 | 248 | processActions(actions, optimisticHandler); 249 | expect(state).toEqual(3); 250 | 251 | await wait(400); 252 | 253 | expect(state).toEqual(1); 254 | expect(result).toEqual('Result for 1'); 255 | expect(error).toEqual('error for 3'); 256 | }); 257 | }); 258 | --------------------------------------------------------------------------------