├── .travis.yml ├── serve.json ├── src ├── provider.js ├── operators.js └── main.js ├── examples ├── simple-fetch │ ├── main.js │ ├── index.html │ ├── styles.css │ └── PeopleList.js └── alarm-clock │ ├── index.html │ ├── styles.css │ └── main.js ├── .eslintrc ├── .github └── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md ├── .gitignore ├── LICENSE ├── CONTRIBUTING.md ├── rollup.config.js ├── package.json ├── CODE_OF_CONDUCT.md ├── README.md └── test └── test.js /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: node 3 | -------------------------------------------------------------------------------- /serve.json: -------------------------------------------------------------------------------- 1 | { 2 | "redirects": [{ "source": "/", "destination": "/examples" }] 3 | } 4 | -------------------------------------------------------------------------------- /src/provider.js: -------------------------------------------------------------------------------- 1 | import React, { useMemo, createContext } from 'react'; 2 | const DEFAULT_CONTEXT = {}; 3 | 4 | export const EpicDepsContext = createContext(DEFAULT_CONTEXT); 5 | 6 | export const EpicDepsProvider = ({ children, ...props }) => { 7 | // eslint-disable-next-line react-hooks/exhaustive-deps 8 | const value = useMemo(() => props, Object.values(props)); 9 | return React.createElement(EpicDepsContext.Provider, { value }, children); 10 | }; 11 | -------------------------------------------------------------------------------- /examples/simple-fetch/main.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { PeopleList } from './PeopleList'; 4 | import './styles.css'; 5 | 6 | function App() { 7 | return ( 8 | 9 |

use-epic

10 |

A simple example of loading a list on mount

11 | 12 |
13 | ); 14 | } 15 | 16 | const rootElement = document.getElementById('root'); 17 | ReactDOM.render(, rootElement); 18 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parserOptions": { 3 | "sourceType": "module", 4 | "ecmaVersion": 2019 5 | }, 6 | "plugins": ["react", "prettier", "react-hooks"], 7 | "extends": [ 8 | "eslint:recommended", 9 | "plugin:react/recommended", 10 | "plugin:prettier/recommended" 11 | ], 12 | "env": { 13 | "browser": true, 14 | "commonjs": true, 15 | "es6": true, 16 | "node": true, 17 | "jest": true 18 | }, 19 | "rules": { 20 | "react/prop-types": 0, 21 | "react-hooks/rules-of-hooks": "error", 22 | "react-hooks/exhaustive-deps": "warn" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /examples/alarm-clock/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | Alarm Clock Example 10 | 11 | 12 | 13 | 16 |
17 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /examples/simple-fetch/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | Simple Fetch Example 10 | 11 | 12 | 13 | 16 |
17 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /examples/alarm-clock/styles.css: -------------------------------------------------------------------------------- 1 | :root { 2 | font-family: sans-serif; 3 | font-size: 40px; 4 | } 5 | 6 | .display { 7 | display: block; 8 | margin: 0.2rem; 9 | height: 1.2rem; 10 | } 11 | 12 | button { 13 | font-size: 0.8rem; 14 | background: none; 15 | padding: 0.5rem 1rem; 16 | cursor: pointer; 17 | border: 1px solid lightgray; 18 | border-radius: 0.2rem; 19 | margin: 0.2rem; 20 | color: white; 21 | text-shadow: 1px 1px 1.5px #333; 22 | } 23 | 24 | button[disabled] { 25 | opacity: 0.5; 26 | } 27 | 28 | .snooze { 29 | background: greenyellow; 30 | } 31 | .dismiss { 32 | background: tomato; 33 | } 34 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Dependency directories 24 | node_modules/ 25 | 26 | # Optional npm cache directory 27 | .npm 28 | 29 | # Optional eslint cache 30 | .eslintcache 31 | 32 | # Optional REPL history 33 | .node_repl_history 34 | 35 | # Output of 'npm pack' 36 | *.tgz 37 | 38 | # Yarn Integrity file 39 | .yarn-integrity 40 | 41 | # dotenv environment variables file 42 | .env 43 | 44 | # editor workspace files 45 | *.code-workspace 46 | 47 | # My Project ignores 48 | dist 49 | -------------------------------------------------------------------------------- /src/operators.js: -------------------------------------------------------------------------------- 1 | import { pipe, of } from 'rxjs'; 2 | import { filter, switchMap, map, pairwise } from 'rxjs/operators'; 3 | // similar to https://github.com/redux-observable/redux-observable/blob/master/src/operators.js 4 | 5 | export const ofType = (...keys) => 6 | pipe( 7 | filter( 8 | (action) => 9 | keys.includes(action) || 10 | keys.includes(action.type) || 11 | (Array.isArray(action) && keys.includes(action[0])) 12 | ) 13 | ); 14 | 15 | export const distinctUntilPropertyChanged = () => 16 | pipe( 17 | switchMap((v, i) => (i === 0 ? of({}, v) : of(v))), 18 | pairwise(), 19 | filter(([prev, obj]) => 20 | typeof obj === 'object' 21 | ? !( 22 | Object.keys(prev).every((k) => prev[k] === obj[k]) && 23 | Object.keys(obj).every((k) => prev[k] === obj[k]) 24 | ) 25 | : prev !== obj 26 | ), 27 | map(([, v]) => v) 28 | ); 29 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Adam L Barrett 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 | -------------------------------------------------------------------------------- /examples/simple-fetch/styles.css: -------------------------------------------------------------------------------- 1 | :root { 2 | font-family: sans-serif; 3 | padding: 1rem; 4 | } 5 | 6 | h1 { 7 | text-align: center; 8 | } 9 | 10 | h2 { 11 | margin: 0.5rem auto 1rem; 12 | max-width: 50rem; 13 | padding: 1rem; 14 | } 15 | 16 | li { 17 | padding: 1rem; 18 | margin-bottom: 1rem; 19 | border-radius: 5px; 20 | border: 1px solid #d8d8d8; 21 | box-shadow: 2px 2px 3px #d8d8d8; 22 | } 23 | 24 | .list { 25 | padding: 0; 26 | list-style: none; 27 | max-width: 50rem; 28 | margin: auto; 29 | } 30 | 31 | .person { 32 | display: flex; 33 | } 34 | 35 | .img { 36 | width: 8rem; 37 | height: 8rem; 38 | margin: 0; 39 | margin-right: 10px; 40 | text-align: center; 41 | flex-shrink: 0; 42 | } 43 | 44 | .img img { 45 | max-width: 100%; 46 | max-height: 8rem; 47 | margin: 0 auto; 48 | } 49 | 50 | .name, 51 | .location { 52 | text-transform: capitalize; 53 | } 54 | 55 | .info h3 { 56 | margin: 0 0 0.5rem 0; 57 | } 58 | .info ul { 59 | list-style: none; 60 | padding: 0; 61 | margin: 0; 62 | } 63 | .info ul li { 64 | margin-right: 0.5rem; 65 | box-shadow: 0 0; 66 | border: 0 none; 67 | padding: 0; 68 | } 69 | 70 | .error { 71 | font-weight: bold; 72 | color: red; 73 | } 74 | 75 | .faded { 76 | opacity: 0.5; 77 | } 78 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | Thank you for contributing to @bigab/use-epic! 2 | 3 | ## Reporting Bugs 4 | 5 | To report a bug, please visit [GitHub Issues](../../issues). 6 | 7 | When filing a bug, it is helpful to include small examples using tools like [CodeSandbox][1] or [CodePen][2]. 8 | 9 | Search for previous tickets, if there is one add to that one rather than creating another. 10 | 11 | ## Contributing 12 | 13 | When contributing, please include tests with new features or bug fixes in a feature branch until you're ready to submit the code for consideration; then push to the fork, and submit a pull request. More detailed steps are as follows: 14 | 15 | 1. Navigate to your clone of the @bigab/use-epic repository - `cd /path/to/@bigab/use-epic` 16 | 2. Create a new feature branch - `git checkout -b some-fix` 17 | 3. Make some changes 18 | 4. Update tests to accomodate your changes 19 | 5. Run tests (`npm test`) and make sure they pass 20 | 6. Update the documentation if necessary 21 | 7. Push your changes to your remote branch - `git push -u origin some-fix` 22 | 8. Submit a pull request! Navigate to [Pull Requests](../../pulls) and click the 'New Pull Request' button. Fill in some details about your potential patch including a meaningful title. When finished, press "Send pull request". 23 | 24 | [1]: https://codesandbox.io 25 | [2]: https://codepen.io/ 26 | -------------------------------------------------------------------------------- /examples/simple-fetch/PeopleList.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { ajax } from 'rxjs/ajax'; 3 | import { map } from 'rxjs/operators'; 4 | import { useEpic } from 'use-epic'; 5 | 6 | const peopleEpic = () => 7 | ajax 8 | .getJSON( 9 | `https://randomuser.me/api/?results=15&nat=us&seed=use-epic-simple-ajax` 10 | ) 11 | .pipe(map(({ results }) => results)); 12 | 13 | export const PeopleList = () => { 14 | const [people] = useEpic(peopleEpic); 15 | 16 | return ( 17 | 27 | ); 28 | }; 29 | 30 | const Person = ({ 31 | name, 32 | picture: { large }, 33 | email, 34 | location, 35 | dob: { age }, 36 | gender, 37 | }) => ( 38 |
39 |
40 | 41 |
42 |
43 |

44 | {name.first} {name.last} 45 |

46 |
    47 |
  • {email}
  • 48 |
  • 49 | {location.city}, {location.state} 50 |
  • 51 |
  • 52 | {age}, {gender} 53 |
  • 54 |
55 |
56 |
57 | ); 58 | -------------------------------------------------------------------------------- /examples/alarm-clock/main.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { of, interval, concat } from 'rxjs'; 4 | import { 5 | takeWhile, 6 | takeUntil, 7 | scan, 8 | startWith, 9 | repeatWhen, 10 | share, 11 | } from 'rxjs/operators'; 12 | import { useEpic, ofType } from 'use-epic'; 13 | import './styles.css'; 14 | 15 | function alarmClockEpic(action$) { 16 | const snooze$ = action$.pipe(ofType('snooze')); 17 | const dismiss$ = action$.pipe(ofType('dismiss')); 18 | 19 | const alarm$ = concat( 20 | interval(250).pipe( 21 | startWith(5), 22 | scan(time => time - 1), 23 | takeWhile(time => time > 0) 24 | ), 25 | of('Wake up! 🎉') 26 | ).pipe(share()); 27 | 28 | const snoozableAlarm$ = alarm$.pipe( 29 | repeatWhen(() => snooze$.pipe(takeUntil(dismiss$))) 30 | ); 31 | 32 | return concat(snoozableAlarm$, of('Have a wonderful day! 🤗')); 33 | } 34 | 35 | function App() { 36 | const [display, dispatch] = useEpic(alarmClockEpic); 37 | const snooze = () => dispatch('snooze'); 38 | const dismiss = () => dispatch('dismiss'); 39 | 40 | return ( 41 | <> 42 |
{display}
43 | 46 | 49 | 50 | ); 51 | } 52 | 53 | const rootElement = document.getElementById('root'); 54 | ReactDOM.render(, rootElement); 55 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import resolve from 'rollup-plugin-node-resolve'; 2 | import commonjs from 'rollup-plugin-commonjs'; 3 | import babel from 'rollup-plugin-babel'; 4 | import pkg from './package.json'; 5 | 6 | export default [ 7 | { 8 | input: 'src/main.js', 9 | external: ['react', 'rxjs', 'rxjs/operators'], 10 | output: [ 11 | { file: pkg.module, format: 'es' }, 12 | { file: pkg.main, format: 'cjs', exports: 'named' }, 13 | ], 14 | plugins: [ 15 | resolve({ 16 | mainFields: ['module', 'main', 'browser'], 17 | dedupe: ['react', 'rxjs'], 18 | }), 19 | babel({ 20 | exclude: 'node_modules/**', 21 | presets: [ 22 | ['@babel/preset-env', { targets: { chrome: 76 } }], 23 | '@babel/preset-react', 24 | ], 25 | }), 26 | ], 27 | }, 28 | // browser-friendly UMD build 29 | { 30 | input: 'src/main.js', 31 | output: { 32 | name: 'useEpic', 33 | file: pkg.browser, 34 | format: 'umd', 35 | globals: { 36 | react: 'React', 37 | rxjs: 'rxjs', 38 | 'rxjs/operators': 'rxjs.operators', 39 | }, 40 | exports: 'named', 41 | }, 42 | plugins: [ 43 | resolve({ 44 | mainFields: ['module', 'main', 'browser'], 45 | dedupe: ['react', 'rxjs'], 46 | }), 47 | babel({ 48 | exclude: 'node_modules/**', 49 | presets: [ 50 | ['@babel/preset-env', { targets: { chrome: 76 } }], 51 | '@babel/preset-react', 52 | ], 53 | }), 54 | commonjs(), 55 | ], 56 | external: ['react', 'rxjs', 'rxjs/operators'], 57 | }, 58 | ]; 59 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "use-epic", 3 | "version": "0.5.0", 4 | "description": "Use RxJS Epics as state management for your React Components", 5 | "publishConfig": { 6 | "registry": "https://registry.npmjs.org/" 7 | }, 8 | "main": "dist/use-epic.cjs.js", 9 | "module": "dist/use-epic.esm.js", 10 | "browser": "dist/use-epic.umd.js", 11 | "scripts": { 12 | "prepublishOnly": "npm run build", 13 | "start": "run-p build:watch test:watch", 14 | "build": "rollup -c", 15 | "build:watch": "rollup -c -w", 16 | "test": "jest test/test.js --coverage", 17 | "test:watch": "jest test/test.js --watch", 18 | "test:debug": "node --inspect node_modules/.bin/jest --runInBand", 19 | "pretest": "npm run build", 20 | "examples": "serve", 21 | "release": "release-it" 22 | }, 23 | "repository": { 24 | "type": "git", 25 | "url": "git+https://github.com/BigAB/use-epic.git" 26 | }, 27 | "keywords": [ 28 | "react", 29 | "rxjs", 30 | "hooks", 31 | "epic", 32 | "observables" 33 | ], 34 | "author": "BigAB (http://bigab.net)", 35 | "license": "MIT", 36 | "bugs": { 37 | "url": "https://github.com/BigAB/use-epic/issues" 38 | }, 39 | "homepage": "https://github.com/BigAB/use-epic#readme", 40 | "peerDependencies": { 41 | "react": "^17.0.1", 42 | "rxjs": "^6.5.4" 43 | }, 44 | "devDependencies": { 45 | "@babel/core": "^7.8.4", 46 | "@babel/preset-env": "^7.8.4", 47 | "@babel/preset-react": "^7.8.3", 48 | "@testing-library/react-hooks": "^3.7.0", 49 | "babel-jest": "^26.6.3", 50 | "eslint": "^7.16.0", 51 | "eslint-config-prettier": "^7.1.0", 52 | "eslint-plugin-prettier": "^3.1.2", 53 | "eslint-plugin-react": "^7.18.3", 54 | "eslint-plugin-react-hooks": "^4.2.0", 55 | "husky": "^4.2.3", 56 | "jest": "^26.6.3", 57 | "npm-run-all": "^4.1.5", 58 | "prettier": "^2.2.1", 59 | "pretty-quick": "^3.1.0", 60 | "react": "17.0.1", 61 | "react-dom": "^17.0.1", 62 | "react-test-renderer": "^17.0.1", 63 | "release-it": "^14.2.2", 64 | "rollup": "^2.35.1", 65 | "rollup-plugin-babel": "^4.3.3", 66 | "rollup-plugin-commonjs": "^10.1.0", 67 | "rollup-plugin-node-resolve": "^5.2.0", 68 | "rxjs": "^6.5.4", 69 | "serve": "^11.3.0", 70 | "steal": "^2.2.4", 71 | "steal-css": "^1.3.2" 72 | }, 73 | "files": [ 74 | "dist" 75 | ], 76 | "husky": { 77 | "hooks": { 78 | "pre-commit": "pretty-quick --staged" 79 | } 80 | }, 81 | "prettier": { 82 | "trailingComma": "es5", 83 | "jsxSingleQuote": true, 84 | "singleQuote": true 85 | }, 86 | "jest": { 87 | "collectCoverageFrom": [ 88 | "src/*.js", 89 | "!src/index.js" 90 | ] 91 | }, 92 | "browserslist": [ 93 | "last 2 versions and >5%" 94 | ], 95 | "babel": { 96 | "presets": [ 97 | [ 98 | "@babel/preset-env", 99 | { 100 | "targets": { 101 | "node": "current" 102 | } 103 | } 104 | ] 105 | ] 106 | }, 107 | "steal": { 108 | "plugins": [ 109 | "steal-css" 110 | ] 111 | }, 112 | "release-it": { 113 | "hooks": { 114 | "before:init": "npm test" 115 | } 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | import { 2 | useRef, 3 | useState, 4 | useCallback, 5 | useMemo, 6 | useContext, 7 | useEffect, 8 | } from 'react'; 9 | import { Subject, BehaviorSubject, EMPTY, isObservable } from 'rxjs'; 10 | import { distinctUntilChanged, catchError, tap } from 'rxjs/operators'; 11 | import { EpicDepsProvider, EpicDepsContext } from './provider'; 12 | import { distinctUntilPropertyChanged } from './operators'; 13 | 14 | export * from './operators'; 15 | export { EpicDepsProvider }; 16 | 17 | const DEFAULT_DEPS = {}; 18 | 19 | export const useEpic = ( 20 | epic, 21 | { props, deps: dependencies = DEFAULT_DEPS } = {} 22 | ) => { 23 | // props 24 | const props$ref = useRef(); 25 | if (!props$ref.current) { 26 | props$ref.current = new BehaviorSubject(props).pipe( 27 | distinctUntilPropertyChanged() 28 | ); 29 | } 30 | const props$ = props$ref.current; 31 | props$.next(props); 32 | 33 | // dependencies 34 | const providedDeps = useContext(EpicDepsContext); 35 | const depsCheck = useMemo( 36 | () => ({ ...providedDeps, ...dependencies, props$ }), 37 | [providedDeps, dependencies, props$] 38 | ); 39 | // Only recreate deps if any shallow value changes 40 | // eslint-disable-next-line react-hooks/exhaustive-deps 41 | const deps = useMemo(() => depsCheck, Object.values(depsCheck)); 42 | 43 | // state 44 | const [state, setState] = useState(); 45 | const stateRef = useRef(); 46 | if (!stateRef.current) { 47 | stateRef.current = new BehaviorSubject(stateRef.current).pipe( 48 | tap((state) => { 49 | setState(state); 50 | }), 51 | catchError((err) => { 52 | // TODO: What should we do on error? 53 | throw err; 54 | }) 55 | ); 56 | } 57 | const state$ = stateRef.current; 58 | // subscribe to state$ immediately, but unsubscribe on unmount 59 | useEffect(() => { 60 | const sub = state$.subscribe(); 61 | return () => sub.unsubscribe(); 62 | }, [state$]); 63 | 64 | // actions 65 | const actionsRef = useRef(); 66 | if (!actionsRef.current) { 67 | actionsRef.current = new Subject(); 68 | } 69 | const actions$ = actionsRef.current; 70 | const dispatch = useCallback( 71 | (...args) => { 72 | actions$.next(args.length > 1 ? args : args[0]); 73 | }, 74 | [actions$] 75 | ); 76 | 77 | // epics are not recomputed, only the first value passed is used 78 | const epicRef = useRef(epic); 79 | 80 | // new state observable is recomputed, every time deps change 81 | const newState$ = useMemo(() => { 82 | const newState$ = epicRef.current( 83 | actions$.asObservable(), 84 | state$.asObservable(), 85 | deps 86 | ); 87 | if (newState$ && !isObservable(newState$)) { 88 | if ('production' !== process.env.NODE_ENV) { 89 | // eslint-disable-next-line no-console 90 | console.warn( 91 | 'use-epic: Epic returned something that was not an RXJS observable' 92 | ); 93 | } 94 | return EMPTY; 95 | } 96 | return newState$ || EMPTY; 97 | }, [actions$, state$, deps]); 98 | 99 | useEffect(() => { 100 | const subscription = newState$ 101 | .pipe(distinctUntilChanged()) 102 | .subscribe(state$); 103 | return () => subscription.unsubscribe(); 104 | }, [newState$, state$]); 105 | 106 | return [state, dispatch]; 107 | }; 108 | 109 | export default useEpic; 110 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at bigab@live.ca. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🏰 use-epic 2 | 3 | Use RxJS Epics as state management for your React Components 4 | 5 | [![Build Status](https://travis-ci.org/bigab/use-epic.svg?branch=master)](https://travis-ci.org/bigab/use-epic) 6 | [![MIT](https://img.shields.io/badge/license-MIT-blue.svg?style=flat)](https://github.com/bigab/use-epic/blob/master/LICENSE) 7 | [![npm version](https://badge.fury.io/js/use-epic.svg)](https://badge.fury.io/js/use-epic) [![Greenkeeper badge](https://badges.greenkeeper.io/BigAB/use-epic.svg)](https://greenkeeper.io/) 8 | 9 | ### What is an **Epic**❓ 10 | 11 | An **Epic** is a function which takes an Observable of actions (`action$`), an Observable of the current state (`state$`), and an object of dependencies (`deps`) and returns an Observable. 12 | 13 | The idea of the **Epic** comes out of the fantastic redux middleware [redux-observable](https://redux-observable.js.org/), but a _noteable difference_ is that, because redux-observable is redux middleware, the observable returned from the **Epic** emits new `actions` to be run through **reducers** to create new state, `useEpic()` skips the redux middleman and expects the 14 | **Epic** to return an observable of `state` updates. 15 | 16 | ```js 17 | function Epic(action$, state$, deps) { 18 | return newState$; 19 | } 20 | ``` 21 | 22 | This simple idea opens up all the fantastic abilites of [RxJS](https://rxjs.dev/) to your React components with a simple but powerful API. 23 | 24 | ## :mag_right: Usage 25 | 26 | ```js 27 | function productEpic(action$, state$, deps) { 28 | const { productStore, cartObserver, props$ } = deps; 29 | combineLatest(action$.pipe(ofType('addToCart')), state$) 30 | .pipe( 31 | map(([productId, products]) => products.find(p => p.id === productId)) 32 | ) 33 | .subscribe(cartObserver); 34 | 35 | return props$.pipe( 36 | map(props => props.category), 37 | switchMap(category => productStore.productsByCategory(category)), 38 | startWith([]) 39 | ); 40 | } 41 | 42 | const ProductsComponent = props => { 43 | const [products, dispatch] = useEpic(productEpic, { props }); 44 | 45 | // map dispatch to a component callback 46 | const addToCart = productId => 47 | dispatch({ type: 'addToCart', payload: productId }); 48 | 49 | return ; 50 | }; 51 | ``` 52 | 53 | ## :hammer_and_pick: Installation 54 | 55 | `use-epic` requires both `react` and `rxjs` as **peer dependencies**. 56 | 57 | ```sh 58 | npm install use-epic rxjs react 59 | ``` 60 | 61 | ```sh 62 | yarn add use-epic rxjs react 63 | ``` 64 | 65 | ## :card_file_box: Examples 66 | 67 | See [examples](./examples) locally with `npm run examples` 68 | 69 | **[![Simple Fetch Example - CodeSandbox](https://img.shields.io/badge/example-simple_fetch-black?logo=codesandbox&style=for-the-badge)](https://codesandbox.io/s/use-epic-simple-ajax-list-load-wtl2r?fontsize=14&module=%2Fsrc%2FPeopleList.js)** 70 | **([source examples](./examples/simple-fetch/PeopleList.js))** 71 | 72 | **[![Alarm Clock Example - CodeSandbox](https://img.shields.io/badge/example-alarm_clock-black?logo=codesandbox&style=for-the-badge)](https://codesandbox.io/s/alarm-clock-5x9vy?fontsize=14)** 73 | **([source examples](./examples/alarm-clock/main.js))** 74 | 75 | [Beer Search] _\*coming soon\*_ 76 | [Pull to Refresh] _\*coming soon\*_ 77 | [Working with simple-store] _\*coming soon\*_ 78 | 79 | ## :book: API 80 | 81 | - [`useEpic()`](#useepic) 82 | - [`epic()`](#epic) 83 | - [`ofType()`](#oftype) 84 | - [``](#epicdepsprovider) 85 | 86 | ### `useEpic()` 87 | 88 | A [React hook](https://reactjs.org/docs/hooks-intro.html) for using RxJS Observables for state management. 89 | 90 | #### `const [state, dispatch] = useEpic( epic, options? );` 91 | 92 | The `useEpic()` hook, accepts an `epic` function, and an `options` object, and returns a tuple of `state` and a `dispatch` callback, similar to [`useReducer()`](https://reactjs.org/docs/hooks-reference.html#usereducer). 93 | 94 | **arguments** 95 | 96 | - `epic` an [epic](#epic) function, [described below](#epic) . 97 | 98 | `function myEpic( action$, state$, deps ) { return newState$ }` 99 | 100 | It should be noted, that only the first Epic function passed to `useEpic()` will be retained, so if you write your function inline like: 101 | 102 | ```js 103 | const [state] = useEpic((action$, state$, deps) => { 104 | return action$.pipe(switchMap(action => fetchData(action.id))); 105 | }); 106 | ``` 107 | 108 | ...any variable closures used in the epic will not change, and component renders will generate a new `Epic` function that will merely be discared. For that reason we encourage defining Epics outside of the component. 109 | 110 | - `options` _\*optional_ an object with some special properties: 111 | - `deps` - an object with keys, any key/values on this deps object will be available on the `deps` argument in the `Epic` function 112 | - `props` - a way to "pass" component props into the `Epic`, anything passed here will be emitted to the special, always available, `deps.props$`, in the `Epic`. This should be used with caution, as it limits portability, but is available for when dispatching an action is not appropriate. 113 | 114 | ```js 115 | const CatDetails = props => { 116 | const [cat] = useEpic(kittenEpic, { deps: { kittenService }, props: cat.id }); 117 |
; 118 | }; 119 | ``` 120 | 121 | ### `epic()` 122 | 123 | An **`epic`** is a function, that accepts an Observable of actions (`action$`), an Observable of the current state (`state$`), and an object of dependencies (`deps`) and returns an Observable of `stateUpdates$`. 124 | 125 | #### `function myEpic( action$, state$, deps ) { return newState$ }` 126 | 127 | The **`epic`** will be called by `useEpic()`, passing the `action$`, `state$` and `deps` arguments, and it may either return a new [RxJS Observable](https://rxjs.dev/api/index/class/Observable) or `undefined`. If an observable is returned, and values emitted from that observable are set as `state`, the first element of the tuple returned from `useEpic()`. 128 | 129 | ```js 130 | const [state, dispatch] = useEpic(epic); 131 | ``` 132 | 133 | **arguments passed when the epic is called** 134 | 135 | - `action$` An observable of dispatched `actions`. The `actions` emitted are anything passed to the `dispatch()` callback returned from `useEpic()`. They can be anything, but by convention are often either objects with a `type`, `payload` and sometimes `meta` properties (e.g. `{ type: 'activate', payload: user }`), or an array tuple with the `type` as the first element and the payload as the second (e.g. `['activate', user]`). 136 | 137 | - `state$` An observable of the current `state`. It can be sometimes helpful to have a reference to the current state when composing streams, say if your `action.payload` is an `id` and you'd like to map that to a state entity before further processing it. Unless the observable returned from `useEpic()` has initial state, from using `startWith()` or a `BehaviorSubject`, this will emit `undefined` to start. 138 | ⚠️ Caution: When using `state$` it is possible to find yourself in an inifinte asynchronous loop. Take care in how it is used along with the returned `newState$` observable. 139 | 140 | - `deps` an object of key/value pairs provided by the `options` of `useEpic` when it is called, or from the `` component. 141 | 142 | The `deps` argument can be very useful for provding a dependency injection point into your `Epic`s and therefore into your components. For example, if you provide an `ajax` dependecy in deps, you could provide the RxJS `ajax` function by default, but stub out `ajax` for tests or demo pages by wrapping your component in an `` component. 143 | 144 | ```js 145 | const kittyEpic = (action$, state$, { ajax: rxjs.ajax }) => { 146 | return action$.pipe( 147 | switchMap(({ payload: id })=> ajax(`/api/kittens/${id}`)) 148 | ); 149 | } 150 | 151 | const KittyComponent = () => { 152 | const [kitty, dispatch] = useEpic(kittyEpic); 153 | 154 | //... render and such 155 | } 156 | 157 | // mocking for tests 158 | test('should load kitty details when clicked', async () => { 159 | // stub out ajax for the test 160 | const fakeResponse = { name: 'Snuggles', breed: 'tabby' }; 161 | const ajaxStub = jest.fn(() => Promise.resolve(fakeResponse)); 162 | 163 | const { getByLabelText, getByText } = render( 164 | 165 | 166 | 167 | ); 168 | 169 | fireEvent.click(getByLabelText(/Cat #1/i)); 170 | const detailsName = await getByText(/^Name:/); 171 | expect(detailsName.textContent).toBe('Name: Snuggles') 172 | }); 173 | ``` 174 | 175 | The `deps` object can be good for providing "services", config, or any number of other useful features to help decouple your components from their dependecies. 176 | 177 | `deps.props$` 178 | There is a special property `props$` which is always provided by `useEpic()` and is the methods in which components can pass props into the Epic. The `options.props` property of the `useEpic()` call is always emitted to the `deps.props$` observable. 179 | 180 | ### `ofType()` 181 | 182 | A [RxJS Operator](https://rxjs.dev/guide/operators) for convient filtering of action\$ by `type` 183 | 184 | #### `action$.pipe( ofType( type, ...moreTypes? ) );` 185 | 186 | Just a convinience operator for filtering `actions` by type, from either the action itself `'promote'`, the conventional object form `{ type: 'promote', payload: { id: 23 } }` or array form `['promote', { id: 23 }]`. The `ofType()` operator only filters, so your `type` property will still be in the emitted value for the next operator or subscription. 187 | 188 | **arguments** 189 | 190 | - `type` the `ofType()` operator can take one or more `type` arguments to match on, if any of the `types` match for the action emitted, the `action` will be emitted further down the stream. The `type` arguments are not restriced to `Strings`, they can be anything including symbols, functions or objects. They are matched with SameValueZero (pretty much `===`) comparison. 191 | 192 | ```js 193 | const promotionChange$ = action$.pipe(ofType('promote', 'depromote')); 194 | ``` 195 | 196 | ### `` 197 | 198 | A React Provider component that supplies `deps` to any `epic` function used by the `useEpic()` hook, called anywhere lower in the component tree, just like Reacts [`context.Provider`](https://reactjs.org/docs/context.html#contextprovider) 199 | 200 | ```jsx 201 | 202 | 203 | 204 | 205 | // const kittyEpic = ( action$, state$, { kittenService, catConfig }) => { 206 | // ... 207 | // } 208 | ``` 209 | 210 | Any `props` passed to the `EpicDepsProvider` component will be merged onto the `deps` object passed to the `epic` function when calling `useEpic()`. Any change in `deps` will unsubscribe from the `newState$` observable, and recall the `epic` function, setting up new subscriptions, so try to change `deps` sparingly. 211 | 212 | ## Testing 213 | 214 | One benefit of using Epics for state management is that they are easy to test. Because they are just functions, you can ensure the behaviour of your Epic, just by calling it with some test observables and deps, emitting actions, and asserting on the `newState$` emitted. 215 | 216 | _TODO: Create testing example_ 217 | _TODO: Create epic testing helper method_ 218 | 219 | ## :seedling: Contribute 220 | 221 | Think you'd like to contribute to this project? Check out our [contributing guideline](./CONTRIBUTING.md) and feel free to create issues and pull requests! 222 | 223 | ## License 224 | 225 | MIT © [Adam L Barrett](./LICENSE) 226 | -------------------------------------------------------------------------------- /test/test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { renderHook, act } from '@testing-library/react-hooks'; 3 | import { BehaviorSubject, Subject } from 'rxjs'; 4 | import { take, toArray } from 'rxjs/operators'; 5 | import useEpicModule, { useEpic, EpicDepsProvider, ofType } from '../src/main'; 6 | 7 | describe('useEpic()', () => { 8 | test('useEpic should be the default export (as well as named export)', () => { 9 | expect(useEpicModule).toBe(useEpic); 10 | }); 11 | 12 | test('it should subscribe to the state observable returned from the epic', async () => { 13 | // ARRANGE 14 | const subject = new BehaviorSubject({ foo: 'bar' }); 15 | const { result, waitForNextUpdate } = renderHook(() => 16 | useEpic(() => subject.asObservable()) 17 | ); 18 | const [initialState] = result.current; 19 | let nextState, lastState; 20 | 21 | // ACT 22 | await act(async () => { 23 | subject.next({ foo: 'baz' }); 24 | await waitForNextUpdate(); 25 | nextState = result.current[0]; 26 | subject.next({ foo: 'xop' }); 27 | // await waitForNextUpdate(); // confused why this breaks test with timeout 28 | lastState = result.current[0]; 29 | }); 30 | 31 | // ASSERT 32 | expect(initialState.foo).toBe('bar'); 33 | expect(nextState.foo).toBe('baz'); 34 | expect(lastState.foo).toBe('xop'); 35 | }); 36 | 37 | test('if the observable returned from the epic immediatly emits state, state should be available immediatly for the component', async () => { 38 | // ARRANGE 39 | const subject = new BehaviorSubject({ foo: 'pre' }); // Observable with state 40 | 41 | // ACT 42 | const { result } = renderHook(() => useEpic(() => subject)); 43 | 44 | let [state] = result.current; 45 | 46 | // ASSERT 47 | expect(state.foo).toBe('pre'); 48 | }); 49 | 50 | test('it should provide a stream of dispatched actions to the epic as the first argument', () => { 51 | // ARRANGE 52 | const epicStub = jest.fn(); 53 | 54 | const { result } = renderHook(() => useEpic(epicStub)); 55 | 56 | // get the observable passed to the epicStub as the first argument 57 | const action$ = epicStub.mock.calls[0][0]; 58 | 59 | let expected; // to be assigned after 4th action 60 | // an observable stream that will wait for 4 actions and then 61 | // assign those actions as an array to the expected variable 62 | action$.pipe(take(4), toArray()).subscribe((values) => (expected = values)); 63 | 64 | // ACT 65 | const [, dispatch] = result.current; 66 | act(() => { 67 | dispatch(1); 68 | dispatch(2); 69 | dispatch(3); 70 | dispatch(4); 71 | }); 72 | 73 | // ASSERT 74 | expect(expected).toEqual([1, 2, 3, 4]); 75 | }); 76 | 77 | test('it should provide a observable of the current state to the epic as the second argument', () => { 78 | // ARRANGE 79 | const subject = new BehaviorSubject(1); 80 | const epicStub = jest.fn(() => subject); // epicStub returns the subject 81 | renderHook(() => useEpic(epicStub)); 82 | 83 | // get the observable passed to the epicStub as the second argument 84 | const state$ = epicStub.mock.calls[0][1]; 85 | 86 | let expected; // to be assigned after 4th action 87 | // an observable stream that will wait for 4 actions and then 88 | // assign those actions as an array to the expected variable 89 | const stateChanges$ = state$.pipe(take(4), toArray()); 90 | 91 | // ACT 92 | act(() => { 93 | // the behaviour starts with a stateful 1 state when subscribed 94 | stateChanges$.subscribe((values) => (expected = values)); 95 | 96 | subject.next(2); 97 | subject.next(3); 98 | subject.next(4); 99 | }); 100 | 101 | // ASSERT 102 | expect(expected).toEqual([1, 2, 3, 4]); 103 | }); 104 | 105 | test('it should unsubscribe from the observable returned by the epic when the component is unmounted', async () => { 106 | // ARRANGE 107 | const subject = new BehaviorSubject(1); 108 | const epicStub = jest.fn(() => subject); // epicStub returns the subject 109 | const { result, unmount, waitForNextUpdate } = renderHook(() => 110 | useEpic(epicStub) 111 | ); 112 | 113 | // ACT 114 | await act(async () => { 115 | subject.next(2); 116 | await waitForNextUpdate(); 117 | unmount(); 118 | subject.next(3); 119 | subject.next(4); 120 | }); 121 | 122 | // ASSERT 123 | expect(result.current[0]).toEqual(2); 124 | expect(subject.observers.length).toBe(0); 125 | }); 126 | 127 | test('it should warn and do nothing if the epic returns something other than undefined or an observable', async () => { 128 | // ARRANGE 129 | const epicStub = jest.fn(() => []); 130 | const origWarn = console.warn; 131 | console.warn = jest.fn(); 132 | 133 | // ACT 134 | renderHook(() => useEpic(epicStub)); 135 | 136 | // ASSERT 137 | expect(console.warn.mock.calls[0][0]).toBe( 138 | 'use-epic: Epic returned something that was not an RXJS observable' 139 | ); 140 | 141 | // CLEANUP 142 | console.warn = origWarn; 143 | }); 144 | 145 | test.skip('Should just throw any errors emitted from the observable returned by useEpic', () => { 146 | // ARRANGE 147 | const theError = new Error('Something has gone very very wrong'); 148 | const subject = new BehaviorSubject(); 149 | const epicStub = jest.fn(() => subject); 150 | const { result } = renderHook(() => useEpic(epicStub)); 151 | 152 | // ACT 153 | act(() => { 154 | subject.error(theError); 155 | }); 156 | 157 | // // ASSERT 158 | expect(result.error).toBe(theError); 159 | }); 160 | 161 | describe('useEpic options argument', () => { 162 | test('options.deps values passed to useEpic will be provided to the epic in an object as the third argument', async () => { 163 | // ARRANGE 164 | const expected = function someDep() {}; 165 | const epicStub = jest.fn(); 166 | // ACT 167 | renderHook(() => useEpic(epicStub, { deps: { expected } })); 168 | 169 | // ASSERT 170 | expect(epicStub.mock.calls[0][2].expected).toBe(expected); 171 | }); 172 | 173 | test('options.props - values passed to use epic will emit to a special deps.props$ observable', async () => { 174 | // ARRANGE 175 | const initialProps = { count: 1 }; 176 | const epicStub = jest.fn(); 177 | const { rerender, waitForNextUpdate } = renderHook( 178 | (props) => { 179 | return useEpic(epicStub, { props }); 180 | }, 181 | { 182 | initialProps, 183 | } 184 | ); 185 | const props$ = epicStub.mock.calls[0][2].props$; 186 | let expected; 187 | props$ 188 | .pipe(take(4), toArray()) 189 | .subscribe((values) => (expected = values)); 190 | 191 | // ACT 192 | await act(async () => { 193 | rerender({ count: 2 }); 194 | await waitForNextUpdate(); 195 | rerender({ count: 3 }); 196 | // await waitForNextUpdate(); // confused why this breaks test with timeout 197 | rerender({ count: 4 }); 198 | // await waitForNextUpdate(); // ditto 199 | }); 200 | 201 | // ASSERT 202 | expect(expected).toEqual([ 203 | { count: 1 }, 204 | { count: 2 }, 205 | { count: 3 }, 206 | { count: 4 }, 207 | ]); 208 | }); 209 | 210 | test('options.props - values passed to use epic can be arrays as well', async () => { 211 | // ARRANGE 212 | const initialProps = { count: 1 }; 213 | const epicStub = jest.fn(); 214 | const { rerender, waitForNextUpdate } = renderHook( 215 | (props) => { 216 | return useEpic(epicStub, { props: [props.count] }); 217 | }, 218 | { 219 | initialProps, 220 | } 221 | ); 222 | const props$ = epicStub.mock.calls[0][2].props$; 223 | let expected; 224 | props$ 225 | .pipe(take(4), toArray()) 226 | .subscribe((values) => (expected = values)); 227 | 228 | // ACT 229 | await act(async () => { 230 | rerender({ count: 2 }); 231 | await waitForNextUpdate(); 232 | rerender({ count: 3 }); 233 | // await waitForNextUpdate(); // confused why this breaks test with timeout 234 | rerender({ count: 4 }); 235 | // await waitForNextUpdate(); // ditto 236 | }); 237 | 238 | // ASSERT 239 | expect(expected).toEqual([[1], [2], [3], [4]]); 240 | }); 241 | 242 | test('options.props - values passed to use epic can be scalar values as well', async () => { 243 | // ARRANGE 244 | const initialProps = { count: 1 }; 245 | const epicStub = jest.fn(); 246 | const { rerender, waitForNextUpdate } = renderHook( 247 | (props) => { 248 | return useEpic(epicStub, { props: props.count }); 249 | }, 250 | { 251 | initialProps, 252 | } 253 | ); 254 | const props$ = epicStub.mock.calls[0][2].props$; 255 | let expected; 256 | props$ 257 | .pipe(take(4), toArray()) 258 | .subscribe((values) => (expected = values)); 259 | 260 | // ACT 261 | await act(async () => { 262 | rerender({ count: 2 }); 263 | await waitForNextUpdate(); 264 | rerender({ count: 3 }); 265 | // await waitForNextUpdate(); // confused why this breaks test with timeout 266 | rerender({ count: 4 }); 267 | // await waitForNextUpdate(); // ditto 268 | }); 269 | 270 | // ASSERT 271 | expect(expected).toEqual([1, 2, 3, 4]); 272 | }); 273 | }); 274 | }); 275 | 276 | describe('dispatch()', () => { 277 | test('if dispatch is provided more than one argument, the action dispatched to the action$ stream is an array of args', () => { 278 | // ARRANGE 279 | const epicStub = jest.fn(); 280 | const { result } = renderHook(() => useEpic(epicStub)); 281 | 282 | // get the observable passed to the epicStub as the first argument 283 | const action$ = epicStub.mock.calls[0][0]; 284 | 285 | let expected; // to be assigned after 2nd action 286 | // an observable stream that will wait for 2 actions and then 287 | // assign those actions as an array to the expected variable 288 | action$.pipe(take(2), toArray()).subscribe((values) => (expected = values)); 289 | 290 | // ACT 291 | const [, dispatch] = result.current; 292 | act(() => { 293 | dispatch('type', { foo: 'bar' }); 294 | dispatch(1, 2, 3, 4); 295 | }); 296 | 297 | // ASSERT 298 | expect(expected).toEqual([ 299 | ['type', { foo: 'bar' }], 300 | [1, 2, 3, 4], 301 | ]); 302 | }); 303 | }); 304 | 305 | describe('', () => { 306 | test('Any props passed to will be passed along to the epics third argument', () => { 307 | // ARRANGE 308 | const epicStub = jest.fn(); 309 | const someDep = jest.fn(); 310 | const anotherDep = jest.fn(); 311 | const Provider = ({ children }) => 312 | React.createElement( 313 | EpicDepsProvider, 314 | { 315 | someDep: someDep, 316 | another: anotherDep, 317 | }, 318 | children 319 | ); 320 | 321 | // act 322 | renderHook(() => useEpic(epicStub), { 323 | wrapper: Provider, 324 | }); 325 | 326 | // get the deps object passed to the epicStub as the third argument 327 | const deps = epicStub.mock.calls[0][2]; 328 | 329 | // ASSERT 330 | expect(deps.someDep).toBe(someDep); 331 | expect(deps.another).toBe(anotherDep); 332 | }); 333 | 334 | test('Props passed to will be merged with option.deps passed in useEpic', () => { 335 | // ARRANGE 336 | const epicStub = jest.fn(); 337 | const someDep = jest.fn(); 338 | const anotherDep = jest.fn(); 339 | const Provider = ({ children }) => 340 | React.createElement( 341 | EpicDepsProvider, 342 | { 343 | someDep: someDep, 344 | }, 345 | children 346 | ); 347 | 348 | // act 349 | renderHook(() => useEpic(epicStub, { deps: { anotherDep } }), { 350 | wrapper: Provider, 351 | }); 352 | 353 | // get the deps object passed to the epicStub as the third argument 354 | const deps = epicStub.mock.calls[0][2]; 355 | 356 | // ASSERT 357 | expect(deps.someDep).toBe(someDep); 358 | expect(deps.anotherDep).toBe(anotherDep); 359 | }); 360 | }); 361 | 362 | describe('ofType()', () => { 363 | test('filters actions based on action, action.type, or if action is an array [type, payload]', () => { 364 | // ARRANGE 365 | const subject = new Subject(); 366 | const result = subject.pipe(take(9), ofType('someType'), toArray()); 367 | let expected; 368 | result.subscribe((values) => (expected = values)); 369 | 370 | // ACT 371 | subject.next('someType'); 372 | subject.next('wrongType'); 373 | subject.next({ type: 'someType', payload: 1 }); 374 | subject.next({ type: 'wrongType', payload: 2 }); 375 | subject.next({ type: 'someType', payload: 3 }); 376 | subject.next({ type: 'XXXXXXXX', payload: 4 }); 377 | subject.next({ payload: 5 }); 378 | subject.next(['someType', 6]); 379 | subject.next(['nope', 7]); 380 | 381 | // ASSERT 382 | expect(expected).toEqual([ 383 | 'someType', 384 | { type: 'someType', payload: 1 }, 385 | { type: 'someType', payload: 3 }, 386 | ['someType', 6], 387 | ]); 388 | }); 389 | }); 390 | --------------------------------------------------------------------------------