├── .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 |
18 | {people &&
19 | people.map(person => {
20 | return (
21 | -
22 |
23 |
24 | );
25 | })}
26 |
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 | [](https://travis-ci.org/bigab/use-epic)
6 | [](https://github.com/bigab/use-epic/blob/master/LICENSE)
7 | [](https://badge.fury.io/js/use-epic) [](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 | **[](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 | **[](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 |
--------------------------------------------------------------------------------