()({
8 | decrement: () => (state) => state - 1,
9 | increment: () => (state) => state + 1,
10 | });
11 |
12 | export const App: FC = () => {
13 | const [counter, counterUpdates] = useStore(COUNTER_STATE, COUNTER_UPDATES);
14 |
15 | return (
16 |
17 |
18 | {counter}
19 |
20 |
21 | );
22 | };
23 |
--------------------------------------------------------------------------------
/packages/examples/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../tsconfig.json",
3 | "compilerOptions": {
4 | "lib": ["ES2019", "DOM"],
5 | "jsx": "react"
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/packages/rx-effects-react/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Change Log
2 |
3 | All notable changes to this project will be documented in this file.
4 | See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5 |
6 | ## [1.1.2](https://github.com/mnasyrov/rx-effects/compare/v1.1.1...v1.1.2) (2024-07-12)
7 |
8 | **Note:** Version bump only for package rx-effects-react
9 |
10 | ## [1.1.1](https://github.com/mnasyrov/rx-effects/compare/v1.1.0...v1.1.1) (2023-08-05)
11 |
12 | **Note:** Version bump only for package rx-effects-react
13 |
14 | # [1.1.0](https://github.com/mnasyrov/rx-effects/compare/v1.0.1...v1.1.0) (2023-02-01)
15 |
16 | ### Features
17 |
18 | - Made `ViewController` to accept queries for external parameters (breaking change) ([#17](https://github.com/mnasyrov/rx-effects/issues/17)) ([ad49f8a](https://github.com/mnasyrov/rx-effects/commit/ad49f8a70eda02a415c37de7de320582f4a91d0e))
19 |
20 | ## [1.0.1](https://github.com/mnasyrov/rx-effects/compare/v1.0.0...v1.0.1) (2023-01-23)
21 |
22 | ### Bug Fixes
23 |
24 | - Fixed rerendering by `useObserver()` and reduced excess unsubscribe/subscribe on rerendering a parent component ([#13](https://github.com/mnasyrov/rx-effects/issues/13)) ([469b251](https://github.com/mnasyrov/rx-effects/commit/469b251797980b6280eb98d097e1b24747675879))
25 |
26 | # [1.0.0](https://github.com/mnasyrov/rx-effects/compare/v0.7.2...v1.0.0) (2022-12-20)
27 |
28 | ### Features
29 |
30 | - Updated API for the library. Introduced tooling for ViewControllers with Ditox.js DI container. ([7cffcd0](https://github.com/mnasyrov/rx-effects/commit/7cffcd03f915337fa27e3b55f30fd1ad0c45a087))
31 |
32 | ## [0.7.2](https://github.com/mnasyrov/rx-effects/compare/v0.7.1...v0.7.2) (2022-10-29)
33 |
34 | ### Features
35 |
36 | - Introduced `scope.onDestroy()` and `scope.subscribe()`. Added info about API deprecation. ([#9](https://github.com/mnasyrov/rx-effects/issues/9)) ([4467782](https://github.com/mnasyrov/rx-effects/commit/44677829f889aa4fbca12fb467f149cd0fab6869))
37 |
38 | ## [0.7.1](https://github.com/mnasyrov/rx-effects/compare/v0.7.0...v0.7.1) (2022-10-26)
39 |
40 | **Note:** Version bump only for package rx-effects-react
41 |
42 | # [0.7.0](https://github.com/mnasyrov/rx-effects/compare/v0.6.0...v0.7.0) (2022-10-26)
43 |
44 | **Note:** Version bump only for package rx-effects-react
45 |
46 | # [0.6.0](https://github.com/mnasyrov/rx-effects/compare/v0.5.2...v0.6.0) (2022-08-28)
47 |
48 | ### Features
49 |
50 | - Refactored Query API ([0ba6d12](https://github.com/mnasyrov/rx-effects/commit/0ba6d12df5f99cf98f04f130a89be814c90180f8))
51 |
52 | ## [0.5.2](https://github.com/mnasyrov/rx-effects/compare/v0.5.1...v0.5.2) (2022-01-26)
53 |
54 | **Note:** Version bump only for package rx-effects-react
55 |
56 | ## [0.5.1](https://github.com/mnasyrov/rx-effects/compare/v0.5.0...v0.5.1) (2022-01-11)
57 |
58 | **Note:** Version bump only for package rx-effects-react
59 |
60 | # [0.5.0](https://github.com/mnasyrov/rx-effects/compare/v0.4.1...v0.5.0) (2022-01-11)
61 |
62 | ### Bug Fixes
63 |
64 | - Fixed eslint rules ([6975806](https://github.com/mnasyrov/rx-effects/commit/69758063de4d9f6b7821b439aad054087df249b9))
65 | - **rx-effects-react:** Fixed calling `destroy()` of a class-based controller by `useController()` ([1bdf6b5](https://github.com/mnasyrov/rx-effects/commit/1bdf6b55df6f41988bf7b481ec2019c97731f127))
66 |
67 | ## [0.4.1](https://github.com/mnasyrov/rx-effects/compare/v0.4.0...v0.4.1) (2021-11-10)
68 |
69 | **Note:** Version bump only for package rx-effects-react
70 |
71 | # [0.4.0](https://github.com/mnasyrov/rx-effects/compare/v0.3.3...v0.4.0) (2021-09-30)
72 |
73 | **Note:** Version bump only for package rx-effects-react
74 |
75 | ## [0.3.3](https://github.com/mnasyrov/rx-effects/compare/v0.3.2...v0.3.3) (2021-09-27)
76 |
77 | ### Features
78 |
79 | - Store.update() can apply an array of mutations ([d778ac9](https://github.com/mnasyrov/rx-effects/commit/d778ac99549a9ac1887ea03ab77d5f0fa6527d1f))
80 |
81 | ## [0.3.2](https://github.com/mnasyrov/rx-effects/compare/v0.3.1...v0.3.2) (2021-09-14)
82 |
83 | ### Bug Fixes
84 |
85 | - useController() hook triggers rerenders if it is used without dependencies. ([f0b5582](https://github.com/mnasyrov/rx-effects/commit/f0b5582b7e801bd86882694d8d7dbb5456ca33bb))
86 |
87 | ## [0.3.1](https://github.com/mnasyrov/rx-effects/compare/v0.3.0...v0.3.1) (2021-09-07)
88 |
89 | **Note:** Version bump only for package rx-effects-react
90 |
91 | # [0.3.0](https://github.com/mnasyrov/rx-effects/compare/v0.2.2...v0.3.0) (2021-09-07)
92 |
93 | ### Features
94 |
95 | - Introduced `StateQueryOptions` for query mappers. Strict equality === is used by default as value comparators. ([5cc97e0](https://github.com/mnasyrov/rx-effects/commit/5cc97e0f7ab1623ffbdc133e5bfbe63911d68b56))
96 |
97 | ## [0.2.2](https://github.com/mnasyrov/rx-effects/compare/v0.2.1...v0.2.2) (2021-09-02)
98 |
99 | **Note:** Version bump only for package rx-effects-react
100 |
101 | ## [0.2.1](https://github.com/mnasyrov/rx-effects/compare/v0.2.0...v0.2.1) (2021-08-15)
102 |
103 | ### Bug Fixes
104 |
105 | - Added a missed export for `useController()` hook ([a5e5c92](https://github.com/mnasyrov/rx-effects/commit/a5e5c92da8a288f44c41dac2cb70c96d788eea38))
106 |
107 | # [0.2.0](https://github.com/mnasyrov/rx-effects/compare/v0.1.0...v0.2.0) (2021-08-09)
108 |
109 | ### Features
110 |
111 | - Renamed `EffectScope` to `Scope`. Extended `Scope` and `declareState()`. ([21d97be](https://github.com/mnasyrov/rx-effects/commit/21d97be080897f33f674d461397e8f1e86ac8eef))
112 |
113 | # [0.1.0](https://github.com/mnasyrov/rx-effects/compare/v0.0.8...v0.1.0) (2021-08-03)
114 |
115 | ### Features
116 |
117 | - Introduced `Controller`, `useController()` and `mergeQueries()` ([d84a2e2](https://github.com/mnasyrov/rx-effects/commit/d84a2e2b8d1f57ca59e9664004de844a1f8bcf1f))
118 |
119 | ## [0.0.8](https://github.com/mnasyrov/rx-effects/compare/v0.0.7...v0.0.8) (2021-07-26)
120 |
121 | ### Bug Fixes
122 |
123 | - Dropped stateEffects for a while. Added stubs for docs. ([566ab80](https://github.com/mnasyrov/rx-effects/commit/566ab8085b6e493942bf908e3000097561a14724))
124 |
125 | ## [0.0.7](https://github.com/mnasyrov/rx-effects/compare/v0.0.6...v0.0.7) (2021-07-23)
126 |
127 | **Note:** Version bump only for package rx-effects-react
128 |
129 | ## [0.0.6](https://github.com/mnasyrov/rx-effects/compare/v0.0.5...v0.0.6) (2021-07-12)
130 |
131 | **Note:** Version bump only for package rx-effects-react
132 |
133 | ## [0.0.5](https://github.com/mnasyrov/rx-effects/compare/v0.0.4...v0.0.5) (2021-07-11)
134 |
135 | **Note:** Version bump only for package @rx-effects/react
136 |
137 | ## [0.0.4](https://github.com/mnasyrov/rx-effects/compare/v0.0.3...v0.0.4) (2021-07-11)
138 |
139 | **Note:** Version bump only for package @rx-effects/react
140 |
141 | ## [0.0.3](https://github.com/mnasyrov/rx-effects/compare/v0.0.2...v0.0.3) (2021-07-11)
142 |
143 | **Note:** Version bump only for package @rx-effects/react
144 |
145 | ## 0.0.2 (2021-07-11)
146 |
147 | **Note:** Version bump only for package @rx-effects/react
148 |
--------------------------------------------------------------------------------
/packages/rx-effects-react/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2021 Mikhail Nasyrov
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/packages/rx-effects-react/README.md:
--------------------------------------------------------------------------------
1 | # RxEffects: rx-effects-react
2 |
3 |
4 |
5 | Reactive state and effect management with RxJS. Tooling for React.js.
6 |
7 | [](https://www.npmjs.com/package/rx-effects-react)
8 | [](https://www.npmjs.com/package/rx-effects-react)
9 | [](https://www.npmjs.com/package/rx-effects-react)
10 | [](https://github.com/mnasyrov/rx-effects/blob/master/LICENSE)
11 | [](https://coveralls.io/github/mnasyrov/rx-effects?branch=main)
12 |
13 | ## Documentation
14 |
15 | - [Main docs](https://github.com/mnasyrov/rx-effects#readme)
16 | - [API docs](docs/README.md)
17 |
18 | ## Installation
19 |
20 | ```
21 | npm install rx-effects rx-effects-react --save
22 | ```
23 |
24 | ## Usage
25 |
26 | The package provides utility hooks to bind the core [RxEffects][rx-effects/docs]
27 | to React components and hooks:
28 |
29 | - [`useConst`](docs/README.md#useconst) – keeps the value as a constant between renders.
30 | - [`useController`](docs/README.md#usecontroller) – creates an ad-hoc controller by the factory and destroys it on unmounting.
31 | - [`useObservable`](docs/README.md#useobservable) – returns a value provided by `source$` observable.
32 | - [`useObserver`](docs/README.md#useobserver) – subscribes the provided observer or `next` handler on `source$` observable.
33 | - [`useSelector`](docs/README.md#useselector) – returns a value provided by `source$` observable.
34 | - [`useQuery`](docs/README.md#usequery) – returns a value which is provided by the query.
35 |
36 | Example:
37 |
38 | ```tsx
39 | // pizzaShopComponent.tsx
40 |
41 | import React, { FC, useEffect } from 'react';
42 | import { useConst, useObservable, useQuery } from 'rx-effects-react';
43 | import { createPizzaShopController } from './pizzaShop';
44 |
45 | export const PizzaShopComponent: FC = () => {
46 | // Creates the controller and destroy it on unmounting the component
47 | const controller = useConst(() => createPizzaShopController());
48 | useEffect(() => controller.destroy, [controller]);
49 |
50 | // The same creation can be achieved by using `useController()` helper:
51 | // const controller = useController(createPizzaShopController);
52 |
53 | // Using the controller
54 | const { ordersQuery, addPizza, removePizza, submitCart, submitState } =
55 | controller;
56 |
57 | // Subscribing to state data and the effect stata
58 | const orders = useQuery(ordersQuery);
59 | const isPending = useQuery(submitState.pending);
60 | const submitError = useObservable(submitState.error$, undefined);
61 |
62 | // Actual rendering should be here.
63 | return null;
64 | };
65 | ```
66 |
67 | ---
68 |
69 | [rx-effects/docs]: https://github.com/mnasyrov/rx-effects/blob/main/packages/rx-effects/README.md
70 |
71 | © 2021 [Mikhail Nasyrov](https://github.com/mnasyrov), [MIT license](./LICENSE)
72 |
--------------------------------------------------------------------------------
/packages/rx-effects-react/docs/.nojekyll:
--------------------------------------------------------------------------------
1 | TypeDoc added this file to prevent GitHub Pages from using Jekyll. You can turn off this behavior by setting the `githubPages` option to false.
--------------------------------------------------------------------------------
/packages/rx-effects-react/index.ts:
--------------------------------------------------------------------------------
1 | export * from './src';
2 |
--------------------------------------------------------------------------------
/packages/rx-effects-react/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "rx-effects-react",
3 | "version": "1.1.2",
4 | "description": "Reactive state and effects management. Tooling for React.js",
5 | "license": "MIT",
6 | "author": "Mikhail Nasyrov (https://github.com/mnasyrov)",
7 | "homepage": "https://github.com/mnasyrov/rx-effects",
8 | "bugs": {
9 | "url": "https://github.com/mnasyrov/rx-effects/issues"
10 | },
11 | "repository": {
12 | "type": "git",
13 | "url": "https://github.com/mnasyrov/rx-effects.git"
14 | },
15 | "keywords": [
16 | "react",
17 | "state",
18 | "effect",
19 | "management",
20 | "state-management",
21 | "reactive",
22 | "rxjs",
23 | "rx",
24 | "effector"
25 | ],
26 | "engines": {
27 | "node": ">=12"
28 | },
29 | "main": "dist/cjs/index.js",
30 | "module": "dist/esm/index.js",
31 | "types": "dist/esm/index.d.ts",
32 | "sideEffects": false,
33 | "files": [
34 | "dist",
35 | "docs",
36 | "src",
37 | "index.ts",
38 | "LICENSE",
39 | "README.md"
40 | ],
41 | "scripts": {
42 | "clean": "rm -rf dist",
43 | "build": "npm run build:cjs && npm run build:esm",
44 | "build:cjs": "tsc -p tsconfig.build.json --outDir dist/cjs --module commonjs",
45 | "build:esm": "tsc -p tsconfig.build.json --outDir dist/esm --module es2015"
46 | },
47 | "dependencies": {
48 | "rx-effects": "1.1.2"
49 | },
50 | "peerDependencies": {
51 | "ditox-react": ">=2.2 || >=3",
52 | "react": ">=17.0.0",
53 | "rxjs": ">=7.0.0"
54 | },
55 | "devDependencies": {
56 | "@testing-library/react": "12.1.2",
57 | "@testing-library/react-hooks": "7.0.2",
58 | "@types/react": "17.0.38",
59 | "react": "17.0.2",
60 | "react-dom": "17.0.2",
61 | "react-test-renderer": "17.0.2"
62 | },
63 | "gitHead": "666d5b8672ebe6cb02174366a799d1da27d387a9"
64 | }
65 |
--------------------------------------------------------------------------------
/packages/rx-effects-react/src/index.ts:
--------------------------------------------------------------------------------
1 | export * from './useConst';
2 | export * from './useController';
3 | export * from './useObservable';
4 | export { useObserver } from './useObserver';
5 | export * from './useSelector';
6 | export * from './useQuery';
7 | export * from './useStore';
8 | export * from './mvc';
9 |
--------------------------------------------------------------------------------
/packages/rx-effects-react/src/mvc.test.tsx:
--------------------------------------------------------------------------------
1 | import { renderHook } from '@testing-library/react-hooks';
2 | import { Container, token } from 'ditox';
3 | import { DependencyContainer, useDependency } from 'ditox-react';
4 | import React from 'react';
5 | import {
6 | declareController,
7 | declareViewController,
8 | InferredService,
9 | Query,
10 | } from 'rx-effects';
11 | import {
12 | createControllerContainer,
13 | useInjectableController,
14 | useViewController,
15 | } from './mvc';
16 |
17 | describe('useInjectableController()', () => {
18 | it('should fail in case there is no a dependency container in the render tree', () => {
19 | const viewController = declareController({}, () => ({}));
20 |
21 | const { result } = renderHook(() =>
22 | useInjectableController(viewController),
23 | );
24 |
25 | expect(result.error).toEqual(
26 | new Error('Container is not provided by DependencyContainer component'),
27 | );
28 | });
29 |
30 | it('should not fail in case there is a dependency container in the render tree', () => {
31 | const viewController = declareController({}, () => ({}));
32 |
33 | const { result } = renderHook(
34 | () => useInjectableController(viewController),
35 | {
36 | wrapper: ({ children }) => (
37 | {children}
38 | ),
39 | },
40 | );
41 |
42 | expect(result.error).toBeUndefined();
43 | });
44 |
45 | it('should injects dependencies from a container in the render tree', () => {
46 | const VALUE_TOKEN = token();
47 | const valueBinder = (container: Container) => {
48 | container.bindValue(VALUE_TOKEN, 1);
49 | };
50 |
51 | const onDestroy = jest.fn();
52 |
53 | const viewController = declareController(
54 | { value: VALUE_TOKEN },
55 | ({ value }) => ({
56 | getValue: () => value * 10,
57 | destroy: () => onDestroy(),
58 | }),
59 | );
60 |
61 | const { result, unmount } = renderHook(
62 | () => useInjectableController(viewController),
63 | {
64 | wrapper: ({ children }) => (
65 |
66 | {children}
67 |
68 | ),
69 | },
70 | );
71 |
72 | expect(result.current.getValue()).toBe(10);
73 | expect(onDestroy).toHaveBeenCalledTimes(0);
74 |
75 | unmount();
76 | expect(onDestroy).toHaveBeenCalledTimes(1);
77 | });
78 | });
79 | describe('useViewController()', () => {
80 | it('should fail in case there is no a dependency container in the render tree', () => {
81 | const viewController = declareViewController(() => ({}));
82 |
83 | const { result } = renderHook(() => useViewController(viewController));
84 |
85 | expect(result.error).toEqual(
86 | new Error('Container is not provided by DependencyContainer component'),
87 | );
88 | });
89 |
90 | it('should not fail in case there is a dependency container in the render tree', () => {
91 | const viewController = declareViewController(() => ({}));
92 |
93 | const { result } = renderHook(() => useViewController(viewController), {
94 | wrapper: ({ children }) => (
95 | {children}
96 | ),
97 | });
98 |
99 | expect(result.error).toBeUndefined();
100 | });
101 |
102 | it('should injects dependencies from a container in the render tree', () => {
103 | const VALUE_TOKEN = token();
104 | const valueBinder = (container: Container) => {
105 | container.bindValue(VALUE_TOKEN, 1);
106 | };
107 |
108 | const onDestroy = jest.fn();
109 |
110 | const viewController = declareViewController(
111 | { value: VALUE_TOKEN },
112 | ({ value }) => ({
113 | getValue: () => value * 10,
114 | destroy: () => onDestroy(),
115 | }),
116 | );
117 |
118 | const { result, unmount } = renderHook(
119 | () => useViewController(viewController),
120 | {
121 | wrapper: ({ children }) => (
122 |
123 | {children}
124 |
125 | ),
126 | },
127 | );
128 |
129 | expect(result.current.getValue()).toBe(10);
130 | expect(onDestroy).toHaveBeenCalledTimes(0);
131 |
132 | unmount();
133 | expect(onDestroy).toHaveBeenCalledTimes(1);
134 | });
135 |
136 | it('should create a view controller and pass parameters to it without recreation', () => {
137 | const VALUE_TOKEN = token();
138 | const valueBinder = (container: Container) => {
139 | container.bindValue(VALUE_TOKEN, 1);
140 | };
141 |
142 | const onDestroy = jest.fn();
143 |
144 | const viewController = declareViewController(
145 | { value: VALUE_TOKEN },
146 | ({ value }) =>
147 | (scope, param: Query) => ({
148 | getValue: () => value * 10 + param.get(),
149 | destroy: () => onDestroy(),
150 | }),
151 | );
152 |
153 | const { result, rerender, unmount } = renderHook(
154 | (param: number) => useViewController(viewController, param),
155 | {
156 | initialProps: 2,
157 | wrapper: ({ children }) => (
158 |
159 | {children}
160 |
161 | ),
162 | },
163 | );
164 |
165 | expect(result.current.getValue()).toBe(12);
166 | expect(onDestroy).toHaveBeenCalledTimes(0);
167 |
168 | rerender(4);
169 | expect(result.current.getValue()).toBe(14);
170 | expect(onDestroy).toHaveBeenCalledTimes(0);
171 |
172 | unmount();
173 | expect(onDestroy).toHaveBeenCalledTimes(1);
174 | });
175 | });
176 |
177 | describe('createControllerContainer()', () => {
178 | it('should create a Functional Component which add a provided controller to DI tree', () => {
179 | const VALUE_TOKEN = token();
180 | const valueBinder = (container: Container) => {
181 | container.bindValue(VALUE_TOKEN, 1);
182 | };
183 |
184 | const controllerFactory = declareController(
185 | { value: VALUE_TOKEN },
186 | ({ value }) => {
187 | return {
188 | getValue: () => value,
189 | };
190 | },
191 | );
192 |
193 | const serviceToken = token>();
194 | const ControllerContainer = createControllerContainer(
195 | serviceToken,
196 | controllerFactory,
197 | );
198 |
199 | const { result } = renderHook(() => useDependency(serviceToken), {
200 | wrapper: ({ children }) => (
201 |
202 | {children}
203 |
204 | ),
205 | });
206 |
207 | expect(result.current.getValue()).toBe(1);
208 | });
209 | });
210 |
--------------------------------------------------------------------------------
/packages/rx-effects-react/src/mvc.tsx:
--------------------------------------------------------------------------------
1 | import { declareModule, Token } from 'ditox';
2 | import { DependencyModule, useDependencyContainer } from 'ditox-react';
3 | import React, { FC, useEffect, useMemo, useRef } from 'react';
4 | import {
5 | Controller,
6 | ControllerFactory,
7 | createStore,
8 | Query,
9 | ViewControllerFactory,
10 | } from 'rx-effects';
11 | import { Store } from 'rx-effects/src/index';
12 |
13 | type AnyObject = Record;
14 |
15 | export function useInjectableController>(
16 | factory: ControllerFactory,
17 | ): Controller {
18 | const container = useDependencyContainer('strict');
19 | const controller = useMemo(() => factory(container), [container, factory]);
20 |
21 | useEffect(() => () => controller.destroy(), [controller]);
22 |
23 | return controller;
24 | }
25 |
26 | export function useViewController<
27 | Result extends Record,
28 | Params extends unknown[],
29 | QueryParams extends {
30 | [K in keyof Params]: Params[K] extends infer V ? Query : never;
31 | },
32 | >(
33 | factory: ViewControllerFactory,
34 | ...params: Params
35 | ): Controller {
36 | const container = useDependencyContainer('strict');
37 |
38 | const storesRef = useRef[]>();
39 |
40 | const controller = useMemo(() => {
41 | if (!storesRef.current) {
42 | storesRef.current = createStoresForParams(params);
43 | }
44 |
45 | return factory(container, ...(storesRef.current as unknown as QueryParams));
46 |
47 | // eslint-disable-next-line react-hooks/exhaustive-deps
48 | }, [container, factory]);
49 |
50 | useEffect(() => {
51 | const stores = storesRef.current;
52 | if (stores) {
53 | params.forEach((value, index) => stores[index].set(value));
54 | }
55 | // eslint-disable-next-line react-hooks/exhaustive-deps
56 | }, params);
57 |
58 | useEffect(() => () => controller.destroy(), [controller]);
59 |
60 | return controller;
61 | }
62 |
63 | function createStoresForParams(params: any[]): Store[] {
64 | return params.length === 0
65 | ? []
66 | : new Array(params.length)
67 | .fill(undefined)
68 | .map((_, index) => createStore(params[index]));
69 | }
70 |
71 | export function createControllerContainer(
72 | token: Token,
73 | factory: ControllerFactory,
74 | ): FC {
75 | const module = declareModule({ factory, token });
76 |
77 | return ({ children }) => (
78 | {children}
79 | );
80 | }
81 |
--------------------------------------------------------------------------------
/packages/rx-effects-react/src/test/testUtils.ts:
--------------------------------------------------------------------------------
1 | import { defer, MonoTypeOperatorFunction, noop } from 'rxjs';
2 | import { finalize } from 'rxjs/operators';
3 |
4 | export function monitorSubscriptionCount(
5 | onCountUpdate: (count: number) => void = noop,
6 | ): MonoTypeOperatorFunction {
7 | return (source$) => {
8 | let counter = 0;
9 |
10 | return defer(() => {
11 | counter += 1;
12 | onCountUpdate(counter);
13 | return source$;
14 | }).pipe(
15 | finalize(() => {
16 | counter -= 1;
17 | onCountUpdate(counter);
18 | }),
19 | );
20 | };
21 | }
22 |
--------------------------------------------------------------------------------
/packages/rx-effects-react/src/useConst.test.ts:
--------------------------------------------------------------------------------
1 | import { renderHook } from '@testing-library/react-hooks';
2 |
3 | import { useConst } from './useConst';
4 |
5 | describe('useConst()', () => {
6 | it('should return preserve the first value between rerenders', () => {
7 | const { result, rerender } = renderHook(({ value }) => useConst(value), {
8 | initialProps: { value: 1 },
9 | });
10 | expect(result.current).toBe(1);
11 |
12 | rerender({ value: 2 });
13 | expect(result.current).toBe(1);
14 | });
15 |
16 | it('should call the factory only once', () => {
17 | const factory1 = jest.fn(() => 1);
18 | const factory2 = jest.fn(() => 2);
19 |
20 | const { result, rerender } = renderHook(
21 | ({ factory }) => useConst(factory),
22 | {
23 | initialProps: { factory: factory1 },
24 | },
25 | );
26 | expect(result.current).toBe(1);
27 |
28 | rerender({ factory: factory2 });
29 | expect(result.current).toBe(1);
30 |
31 | expect(factory1).toHaveBeenCalledTimes(1);
32 | expect(factory2).toHaveBeenCalledTimes(0);
33 | });
34 | });
35 |
--------------------------------------------------------------------------------
/packages/rx-effects-react/src/useConst.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/ban-types */
2 |
3 | import { useRef } from 'react';
4 |
5 | /**
6 | * Keeps the value as a constant between renders of a component.
7 | *
8 | * If the factory is provided, it is called only once.
9 | *
10 | * @param initialValue a value or a factory for the value
11 | */
12 | export function useConst(initialValue: (() => T) | T): T {
13 | const constRef = useRef<{ value: T } | void>();
14 |
15 | if (constRef.current === undefined) {
16 | constRef.current = {
17 | value:
18 | typeof initialValue === 'function'
19 | ? (initialValue as Function)()
20 | : initialValue,
21 | };
22 | }
23 |
24 | return constRef.current.value;
25 | }
26 |
--------------------------------------------------------------------------------
/packages/rx-effects-react/src/useController.test.ts:
--------------------------------------------------------------------------------
1 | import { renderHook } from '@testing-library/react-hooks';
2 | import { Controller } from 'rx-effects';
3 | import { useController } from './useController';
4 |
5 | describe('useController()', () => {
6 | it('should create a controller by the factory and destroy it on unmount', () => {
7 | const action = jest.fn();
8 | const destroy = jest.fn();
9 |
10 | function createController(): Controller<{
11 | value: number;
12 | action: () => void;
13 | }> {
14 | return { value: 1, action, destroy };
15 | }
16 |
17 | const { result, unmount } = renderHook(() =>
18 | useController(createController),
19 | );
20 |
21 | expect(result.current.value).toBe(1);
22 |
23 | result.current.action();
24 | expect(action).toHaveBeenCalledTimes(1);
25 |
26 | unmount();
27 | expect(destroy).toHaveBeenCalledTimes(1);
28 | });
29 |
30 | it('should not recreate the controller with empty dependencies after rerendering', () => {
31 | const destroy = jest.fn();
32 |
33 | const createController = () => ({ destroy });
34 |
35 | const { result, rerender, unmount } = renderHook(() =>
36 | useController(createController),
37 | );
38 |
39 | const controller1 = result.current;
40 | rerender();
41 | const controller2 = result.current;
42 |
43 | expect(controller1 === controller2).toBe(true);
44 |
45 | unmount();
46 | expect(destroy).toHaveBeenCalledTimes(1);
47 | });
48 |
49 | it('should recreate the controller if a dependency is changed', () => {
50 | const destroy = jest.fn();
51 |
52 | const createController = (value: number) => ({ value, destroy });
53 |
54 | const { result, rerender, unmount } = renderHook(
55 | ({ value }) => useController(() => createController(value), [value]),
56 | { initialProps: { value: 1 } },
57 | );
58 |
59 | const controller1 = result.current;
60 | expect(controller1.value).toBe(1);
61 |
62 | rerender({ value: 1 });
63 | const controller2 = result.current;
64 | expect(controller2).toBe(controller1);
65 | expect(controller2.value).toBe(1);
66 |
67 | rerender({ value: 2 });
68 | const controller3 = result.current;
69 | expect(controller3).not.toBe(controller2);
70 | expect(controller3.value).toBe(2);
71 |
72 | unmount();
73 | expect(destroy).toHaveBeenCalledTimes(2);
74 | });
75 | });
76 |
--------------------------------------------------------------------------------
/packages/rx-effects-react/src/useController.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable react-hooks/exhaustive-deps */
2 |
3 | import { useEffect, useMemo } from 'react';
4 | import { Controller } from 'rx-effects';
5 |
6 | const EMPTY_DEPENDENCIES: unknown[] = [];
7 |
8 | /**
9 | * Creates an ad-hoc controller by the factory and destroys it on unmounting a
10 | * component.
11 | *
12 | * The factory is not part of the dependencies by default. It should be
13 | * included explicitly when it is needed.
14 | *
15 | * @param factory a controller factory
16 | * @param dependencies array of hook dependencies to recreate the controller.
17 | */
18 | export function useController>>(
19 | factory: () => T,
20 | dependencies: unknown[] = EMPTY_DEPENDENCIES,
21 | ): T {
22 | const controller = useMemo(factory, dependencies);
23 | useEffect(() => () => controller.destroy(), [controller]);
24 |
25 | return controller;
26 | }
27 |
--------------------------------------------------------------------------------
/packages/rx-effects-react/src/useObservable.test.ts:
--------------------------------------------------------------------------------
1 | import { act, renderHook } from '@testing-library/react-hooks';
2 | import { Subject } from 'rxjs';
3 | import { monitorSubscriptionCount } from './test/testUtils';
4 | import { useObservable } from './useObservable';
5 |
6 | describe('useObservable()', () => {
7 | it('should render with the initial state', () => {
8 | const source$ = new Subject();
9 | const { result } = renderHook(() => useObservable(source$, 1));
10 | expect(result.current).toBe(1);
11 | });
12 |
13 | it('should subscribe on changes and unsubscribe on unmount', () => {
14 | const source$ = new Subject();
15 |
16 | let subscriptionCount = 0;
17 | const monitor$ = source$.pipe(
18 | monitorSubscriptionCount((count) => (subscriptionCount = count)),
19 | );
20 |
21 | const { result, unmount } = renderHook(() => useObservable(monitor$, 1));
22 | expect(subscriptionCount).toBe(1);
23 |
24 | act(() => source$.next(2));
25 | expect(result.current).toBe(2);
26 |
27 | unmount();
28 | act(() => source$.next(3));
29 | expect(result.current).toBe(2);
30 | expect(subscriptionCount).toBe(0);
31 | });
32 |
33 | it('should update the result only if the state comparator does not match a previous state with a next state', () => {
34 | type State = { key: number; value: number };
35 | const source$ = new Subject();
36 | const initialState = { key: 1, value: 1 };
37 |
38 | const { result } = renderHook(() =>
39 | useObservable(
40 | source$,
41 | initialState,
42 | (state1, state2) => state1.key === state2.key,
43 | ),
44 | );
45 | expect(result.current).toEqual({ key: 1, value: 1 });
46 |
47 | act(() => source$.next({ key: 1, value: 2 }));
48 | expect(result.current).toEqual({ key: 1, value: 1 });
49 |
50 | act(() => source$.next({ key: 2, value: 3 }));
51 | expect(result.current).toEqual({ key: 2, value: 3 });
52 | });
53 | });
54 |
--------------------------------------------------------------------------------
/packages/rx-effects-react/src/useObservable.ts:
--------------------------------------------------------------------------------
1 | import { identity, Observable } from 'rxjs';
2 | import { useSelector } from './useSelector';
3 |
4 | /**
5 | * Returns a value provided by `source$`.
6 | *
7 | * The hook returns the initial value and subscribes on the `source$`. After
8 | * that, the hook returns values which are provided by the source.
9 | *
10 | * @param source$ an observable for values
11 | * @param initialValue th first value which is returned by the hook
12 | * @param comparator a comparator for previous and next values
13 | *
14 | * @example
15 | * ```ts
16 | * const value = useObservable(source$, undefined);
17 | * ```
18 | */
19 | export function useObservable(
20 | source$: Observable,
21 | initialValue: T,
22 | comparator?: (v1: T, v2: T) => boolean,
23 | ): T {
24 | return useSelector(source$, initialValue, identity, comparator);
25 | }
26 |
--------------------------------------------------------------------------------
/packages/rx-effects-react/src/useObserver.test.ts:
--------------------------------------------------------------------------------
1 | import { renderHook } from '@testing-library/react-hooks';
2 | import { BehaviorSubject, PartialObserver, Subject } from 'rxjs';
3 | import { isBrowser, useObserver } from './useObserver';
4 |
5 | describe('useObserver()', () => {
6 | it('should subscribe a listener for next values', () => {
7 | const source$ = new Subject();
8 | const listener = jest.fn();
9 |
10 | renderHook(() => useObserver(source$, listener));
11 |
12 | source$.next(1);
13 | expect(listener).toHaveBeenNthCalledWith(1, 1);
14 | });
15 |
16 | it('should subscribe an observer', () => {
17 | const source$ = new Subject();
18 | const observer: PartialObserver = {
19 | next: jest.fn(),
20 | complete: jest.fn(),
21 | };
22 |
23 | renderHook(() => useObserver(source$, observer));
24 | source$.next(1);
25 | source$.complete();
26 |
27 | expect(observer.next).toHaveBeenCalledWith(1);
28 | expect(observer.complete).toHaveBeenCalled();
29 | });
30 |
31 | it('should resubscribe if a only source is changed', () => {
32 | const source1$ = new BehaviorSubject(1);
33 | const source2$ = new BehaviorSubject(2);
34 | const listener1 = jest.fn();
35 | const listener2 = jest.fn();
36 |
37 | const { rerender } = renderHook(
38 | ({ source$, listener }) => useObserver(source$, listener),
39 | { initialProps: { source$: source1$, listener: listener1 } },
40 | );
41 | rerender({ source$: source2$, listener: listener1 });
42 | rerender({ source$: source2$, listener: listener2 });
43 |
44 | expect(listener1).toHaveBeenNthCalledWith(1, 1);
45 | expect(listener1).toHaveBeenNthCalledWith(2, 2);
46 | expect(listener2).toHaveBeenCalledTimes(0);
47 |
48 | source2$.next(1);
49 | expect(listener2).toHaveBeenNthCalledWith(1, 1);
50 | expect(listener2).toHaveBeenCalledTimes(1);
51 | });
52 |
53 | it('should handled it all variants of the listener', () => {
54 | const sourceNext$ = new BehaviorSubject(1);
55 |
56 | expect(() => {
57 | renderHook(() =>
58 | useObserver(sourceNext$, {
59 | next: undefined,
60 | }),
61 | );
62 | renderHook(() => useObserver(sourceNext$, undefined as any));
63 |
64 | sourceNext$.next(1);
65 | }).not.toThrow();
66 |
67 | const sourceError$ = new BehaviorSubject(1);
68 | expect(() => {
69 | renderHook(() =>
70 | useObserver(sourceError$, {
71 | error: undefined,
72 | }),
73 | );
74 | renderHook(() => useObserver(sourceError$, undefined as any));
75 | sourceError$.error(new Error('some error'));
76 | }).not.toThrow();
77 |
78 | const sourceComplete$ = new BehaviorSubject(1);
79 | expect(() => {
80 | renderHook(() =>
81 | useObserver(sourceComplete$, {
82 | complete: undefined,
83 | }),
84 | );
85 | renderHook(() => useObserver(sourceComplete$, undefined as any));
86 | sourceComplete$.complete();
87 | }).not.toThrow();
88 | });
89 |
90 | it('should subscribe to error', () => {
91 | const source$ = new Subject();
92 | const observer: PartialObserver = {
93 | error: jest.fn(),
94 | };
95 |
96 | renderHook(() => useObserver(source$, observer));
97 |
98 | source$.error(new Error('some error'));
99 |
100 | expect(observer.error).toHaveBeenCalledTimes(1);
101 | });
102 |
103 | it('should use useLayoutEffect when isBrowser is true', () => {
104 | jest.resetModules();
105 |
106 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment
107 | // @ts-ignore
108 | global.window = {};
109 |
110 | const useIsomorphicLayoutEffect =
111 | // eslint-disable-next-line @typescript-eslint/no-var-requires
112 | require('./useObserver').useIsomorphicLayoutEffect;
113 |
114 | expect(typeof useIsomorphicLayoutEffect).toBe('function');
115 |
116 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment
117 | // @ts-ignore
118 | delete global.window;
119 | });
120 |
121 | it('should unsubscribe on unmount', () => {
122 | const source$ = new BehaviorSubject(1);
123 | const listener = jest.fn();
124 |
125 | const { unmount } = renderHook(() => useObserver(source$, listener));
126 |
127 | unmount();
128 | source$.next(2);
129 |
130 | expect(listener).toHaveBeenCalledTimes(1);
131 | expect(listener).toHaveBeenCalledWith(1);
132 | });
133 |
134 | it('should unsubscribe old Observable and subscribe to new one when it changes', () => {
135 | const source1$ = new BehaviorSubject(1);
136 | const source2$ = new BehaviorSubject(2);
137 | const listener = jest.fn();
138 |
139 | const { rerender } = renderHook(
140 | ({ source$ }) => {
141 | useObserver(source$, listener);
142 | },
143 | {
144 | initialProps: {
145 | source$: source1$,
146 | },
147 | },
148 | );
149 |
150 | expect(listener).toHaveBeenCalledTimes(1);
151 | expect(listener).toHaveBeenLastCalledWith(1);
152 |
153 | rerender({ source$: source2$ });
154 |
155 | expect(listener).toHaveBeenCalledTimes(2);
156 | expect(listener).toHaveBeenLastCalledWith(2);
157 | });
158 |
159 | it('should not subscribe a new observer in case a listener is changed', () => {
160 | const source$ = new BehaviorSubject(1);
161 | const listener1 = jest.fn();
162 | const listener2 = jest.fn();
163 |
164 | const { rerender } = renderHook(
165 | ({ listener }) => useObserver(source$, listener),
166 | { initialProps: { listener: listener1 } },
167 | );
168 |
169 | const observer = source$.observers[0];
170 | expect(observer).toBeDefined();
171 |
172 | rerender({ listener: listener2 });
173 | source$.next(2);
174 |
175 | expect(source$.observers.length).toBe(1);
176 | expect(source$.observers[0]).toBe(observer);
177 |
178 | expect(listener1).toHaveBeenCalledTimes(1);
179 | expect(listener1).toHaveBeenCalledWith(1);
180 |
181 | expect(listener2).toHaveBeenCalledTimes(1);
182 | });
183 | });
184 |
185 | describe('isBrowser()', () => {
186 | it('should return true when the window exists', () => {
187 | const isBrowserFalsy = isBrowser();
188 | expect(isBrowserFalsy).toBeFalsy();
189 |
190 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment
191 | // @ts-ignore
192 | global.window = {};
193 |
194 | const isBrowserTruthy = isBrowser();
195 | expect(isBrowserTruthy).toBeTruthy();
196 |
197 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment
198 | // @ts-ignore
199 | delete global.window;
200 | });
201 | });
202 |
--------------------------------------------------------------------------------
/packages/rx-effects-react/src/useObserver.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useLayoutEffect, useRef } from 'react';
2 | import { Observable, Observer } from 'rxjs';
3 |
4 | /**
5 | * Subscribes the provided observer or `next` handler on `source$` observable.
6 | *
7 | * This hook allows to do fine handling of the source observable.
8 | *
9 | * @param source$ an observable
10 | * @param observerOrNext `Observer` or `next` handler
11 | *
12 | * @example
13 | * ```ts
14 | * useObserver(source$, (nextValue) => {
15 | * logger.log(nextValue);
16 | * });
17 | * ```
18 | */
19 | export function useObserver(
20 | source$: Observable,
21 | observerOrNext: Partial> | ((value: T) => void),
22 | ): void {
23 | const argsRef = useRef>>();
24 |
25 | // Update the latest observable and callbacks
26 | // synchronously after render being committed
27 | useIsomorphicLayoutEffect(() => {
28 | argsRef.current =
29 | typeof observerOrNext === 'function'
30 | ? { next: observerOrNext }
31 | : observerOrNext;
32 | });
33 |
34 | useEffect(() => {
35 | const subscription = source$.subscribe({
36 | next: (value) => argsRef.current?.next?.(value),
37 | error: (error) => argsRef.current?.error?.(error),
38 | complete: () => argsRef.current?.complete?.(),
39 | });
40 |
41 | return () => subscription.unsubscribe();
42 | }, [source$]);
43 | }
44 |
45 | export function isBrowser() {
46 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment
47 | // @ts-ignore
48 | return typeof window !== 'undefined';
49 | }
50 |
51 | /**
52 | * Prevent React warning when using useLayoutEffect on server.
53 | */
54 | export const useIsomorphicLayoutEffect = isBrowser()
55 | ? useLayoutEffect
56 | : /* istanbul ignore next: both are not called on server */
57 | useEffect;
58 |
--------------------------------------------------------------------------------
/packages/rx-effects-react/src/useQuery.test.ts:
--------------------------------------------------------------------------------
1 | import { act, renderHook } from '@testing-library/react-hooks';
2 | import { createStore, Query } from 'rx-effects';
3 | import { monitorSubscriptionCount } from './test/testUtils';
4 | import { useQuery } from './useQuery';
5 |
6 | describe('useQuery()', () => {
7 | it('should render with a current value and watch for value changes', () => {
8 | const store = createStore(1);
9 | let subscriptionCount = 0;
10 |
11 | const query: Query = {
12 | get: store.get,
13 | value$: store.value$.pipe(
14 | monitorSubscriptionCount((count) => (subscriptionCount = count)),
15 | ),
16 | };
17 |
18 | const { result, unmount } = renderHook(() => useQuery(query));
19 | expect(result.current).toBe(1);
20 | expect(subscriptionCount).toBe(1);
21 |
22 | act(() => store.set(2));
23 | expect(result.current).toBe(2);
24 |
25 | unmount();
26 | act(() => store.set(3));
27 | expect(result.current).toBe(2);
28 | expect(subscriptionCount).toBe(0);
29 | });
30 | });
31 |
--------------------------------------------------------------------------------
/packages/rx-effects-react/src/useQuery.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react';
2 | import { Query } from 'rx-effects';
3 |
4 | /**
5 | * Returns a value which is provided by the query.
6 | *
7 | * @param query – a query for a value
8 | */
9 | export function useQuery(query: Query): T {
10 | const [value, setValue] = useState(query.get);
11 |
12 | useEffect(() => {
13 | const subscription = query.value$.subscribe((nextValue) => {
14 | setValue(nextValue);
15 | });
16 |
17 | return () => subscription.unsubscribe();
18 | }, [query.value$]);
19 |
20 | return value;
21 | }
22 |
--------------------------------------------------------------------------------
/packages/rx-effects-react/src/useSelector.test.ts:
--------------------------------------------------------------------------------
1 | import { act, renderHook } from '@testing-library/react-hooks';
2 | import { identity, Subject } from 'rxjs';
3 | import { monitorSubscriptionCount } from './test/testUtils';
4 | import { useSelector } from './useSelector';
5 |
6 | describe('useSelector()', () => {
7 | it('should render with the initial state', () => {
8 | const source$ = new Subject();
9 | const { result } = renderHook(() => useSelector(source$, 1, identity));
10 | expect(result.current).toBe(1);
11 | });
12 |
13 | it('should subscribe on changes and unsubscribe on unmount', () => {
14 | const source$ = new Subject();
15 |
16 | let subscriptionCount = 0;
17 | const monitor$ = source$.pipe(
18 | monitorSubscriptionCount((count) => (subscriptionCount = count)),
19 | );
20 |
21 | const { result, unmount } = renderHook(() =>
22 | useSelector(monitor$, 1, identity),
23 | );
24 | expect(subscriptionCount).toBe(1);
25 |
26 | act(() => source$.next(2));
27 | expect(result.current).toBe(2);
28 |
29 | unmount();
30 | act(() => source$.next(3));
31 | expect(result.current).toBe(2);
32 | expect(subscriptionCount).toBe(0);
33 | });
34 |
35 | it('should map state with the selector', () => {
36 | type State = { value: number };
37 | const source$ = new Subject();
38 | const initialState = { value: 1 };
39 |
40 | const { result } = renderHook(() =>
41 | useSelector(source$, initialState, (state) => state.value),
42 | );
43 | expect(result.current).toBe(1);
44 |
45 | act(() => source$.next({ value: 2 }));
46 | expect(result.current).toBe(2);
47 | });
48 |
49 | it('should update the result only if the state comparator does not match a previous state with a next state', () => {
50 | type State = { key: number; value: number };
51 | const source$ = new Subject();
52 | const initialState = { key: 1, value: 1 };
53 |
54 | const { result } = renderHook(() =>
55 | useSelector(
56 | source$,
57 | initialState,
58 | identity,
59 | (state1, state2) => state1.key === state2.key,
60 | ),
61 | );
62 | expect(result.current).toEqual({ key: 1, value: 1 });
63 |
64 | act(() => source$.next({ key: 1, value: 2 }));
65 | expect(result.current).toEqual({ key: 1, value: 1 });
66 |
67 | act(() => source$.next({ key: 2, value: 3 }));
68 | expect(result.current).toEqual({ key: 2, value: 3 });
69 | });
70 | });
71 |
--------------------------------------------------------------------------------
/packages/rx-effects-react/src/useSelector.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react';
2 | import { Observable } from 'rxjs';
3 | import { DEFAULT_COMPARATOR } from './utils';
4 |
5 | /**
6 | * Returns a value provided by `source$`.
7 | *
8 | * The hook returns the initial value and subscribes on the `source$`. After
9 | * that, the hook returns values which are provided by the source.
10 | *
11 | * @param source$ an observable for values
12 | * @param initialValue th first value which is returned by the hook
13 | * @param selector a transform function for getting a derived value based on
14 | * the source value
15 | * @param comparator a comparator for previous and next values
16 | *
17 | * @example
18 | * ```ts
19 | * const value = useSelector<{data: Record}>(
20 | * source$,
21 | * undefined,
22 | * (state) => state.data,
23 | * (data1, data2) => data1.key === data2.key
24 | * );
25 | * ```
26 | */
27 | export function useSelector(
28 | source$: Observable,
29 | initialValue: S,
30 | selector: (state: S) => R,
31 | comparator: (v1: R, v2: R) => boolean = DEFAULT_COMPARATOR,
32 | ): R {
33 | const [value, setValue] = useState(() => selector(initialValue));
34 |
35 | useEffect(() => {
36 | const subscription = source$.subscribe((state) => {
37 | const value = selector(state);
38 | setValue((prevValue) =>
39 | comparator(value, prevValue) ? prevValue : value,
40 | );
41 | });
42 |
43 | return () => subscription.unsubscribe();
44 | }, [comparator, selector, source$]);
45 |
46 | return value;
47 | }
48 |
--------------------------------------------------------------------------------
/packages/rx-effects-react/src/useStore.test.ts:
--------------------------------------------------------------------------------
1 | import { renderHook } from '@testing-library/react-hooks';
2 | import { act } from 'react-test-renderer';
3 | import { declareStateUpdates } from 'rx-effects';
4 | import { useStore } from './useStore';
5 |
6 | describe('useStore()', () => {
7 | const STATE_UPDATES = declareStateUpdates()({
8 | increase: () => (state) => state + 1,
9 | decrease: () => (state) => state - 1,
10 | });
11 |
12 | it('should render with the initial value, updates and a store', () => {
13 | const { result } = renderHook(() => useStore(0, STATE_UPDATES));
14 |
15 | const [value, updates, store] = result.current;
16 |
17 | expect(value).toBe(0);
18 |
19 | expect(updates).toMatchObject({
20 | increase: expect.any(Function),
21 | decrease: expect.any(Function),
22 | });
23 |
24 | expect(store).toMatchObject(
25 | expect.objectContaining({
26 | value$: expect.any(Object),
27 | get: expect.any(Function),
28 | set: expect.any(Function),
29 | update: expect.any(Function),
30 | }),
31 | );
32 | });
33 |
34 | it('should render a new value when the store is updated', () => {
35 | const { result } = renderHook(() => useStore(0, STATE_UPDATES));
36 |
37 | const [value1, updates1, store1] = result.current;
38 | expect(value1).toBe(0);
39 |
40 | act(() => {
41 | updates1.increase();
42 | });
43 | const [value2, updates2, store2] = result.current;
44 |
45 | expect(value2).toBe(1);
46 | expect(updates2).toBe(updates1);
47 | expect(store2).toBe(store1);
48 | });
49 | });
50 |
--------------------------------------------------------------------------------
/packages/rx-effects-react/src/useStore.ts:
--------------------------------------------------------------------------------
1 | import {
2 | createStore,
3 | StateUpdates,
4 | StoreOptions,
5 | StoreWithUpdates,
6 | withStoreUpdates,
7 | } from 'rx-effects';
8 | import { useController } from './useController';
9 | import { useQuery } from './useQuery';
10 |
11 | export function useStore>(
12 | initialState: State,
13 | updates: Updates,
14 | options?: StoreOptions,
15 | ): [State, Updates, StoreWithUpdates] {
16 | const store: StoreWithUpdates = useController(() => {
17 | return withStoreUpdates(
18 | createStore(initialState, options),
19 | updates,
20 | );
21 | });
22 |
23 | const state = useQuery(store);
24 |
25 | return [state, store.updates, store] as [
26 | State,
27 | Updates,
28 | StoreWithUpdates,
29 | ];
30 | }
31 |
--------------------------------------------------------------------------------
/packages/rx-effects-react/src/utils.ts:
--------------------------------------------------------------------------------
1 | export const DEFAULT_COMPARATOR = (a: unknown, b: unknown): boolean => a === b;
2 |
--------------------------------------------------------------------------------
/packages/rx-effects-react/tsconfig.build.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "files": ["src/index.ts"],
4 | "compilerOptions": {
5 | "noEmit": false
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/packages/rx-effects-react/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../tsconfig.json",
3 | "compilerOptions": {
4 | "jsx": "react-jsx"
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/packages/rx-effects-react/typedoc.json:
--------------------------------------------------------------------------------
1 | {
2 | "entryPoints": ["src/index.ts"],
3 | "exclude": ["**/*+(.test).ts"],
4 | "readme": "none"
5 | }
6 |
--------------------------------------------------------------------------------
/packages/rx-effects/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Change Log
2 |
3 | All notable changes to this project will be documented in this file.
4 | See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5 |
6 | ## [1.1.2](https://github.com/mnasyrov/rx-effects/compare/v1.1.1...v1.1.2) (2024-07-12)
7 |
8 | **Note:** Version bump only for package rx-effects
9 |
10 | ## [1.1.1](https://github.com/mnasyrov/rx-effects/compare/v1.1.0...v1.1.1) (2023-08-05)
11 |
12 | **Note:** Version bump only for package rx-effects
13 |
14 | # [1.1.0](https://github.com/mnasyrov/rx-effects/compare/v1.0.1...v1.1.0) (2023-02-01)
15 |
16 | ### Features
17 |
18 | - Made `ViewController` to accept queries for external parameters (breaking change) ([#17](https://github.com/mnasyrov/rx-effects/issues/17)) ([ad49f8a](https://github.com/mnasyrov/rx-effects/commit/ad49f8a70eda02a415c37de7de320582f4a91d0e))
19 | - new declareStore factory ([#15](https://github.com/mnasyrov/rx-effects/issues/15)) ([824f156](https://github.com/mnasyrov/rx-effects/commit/824f156a00ce9b0e4a6488a201913f3abf82177b))
20 |
21 | ## [1.0.1](https://github.com/mnasyrov/rx-effects/compare/v1.0.0...v1.0.1) (2023-01-23)
22 |
23 | ### Bug Fixes
24 |
25 | - Fixed rerendering by `useObserver()` and reduced excess unsubscribe/subscribe on rerendering a parent component ([#13](https://github.com/mnasyrov/rx-effects/issues/13)) ([469b251](https://github.com/mnasyrov/rx-effects/commit/469b251797980b6280eb98d097e1b24747675879))
26 | - Usage of `declareViewController()` with a controller factory with the single `scope` argument ([#11](https://github.com/mnasyrov/rx-effects/issues/11)) ([08a3ba4](https://github.com/mnasyrov/rx-effects/commit/08a3ba442caae56e58edb6437807d076b54e879b))
27 |
28 | # [1.0.0](https://github.com/mnasyrov/rx-effects/compare/v0.7.2...v1.0.0) (2022-12-20)
29 |
30 | ### Features
31 |
32 | - Updated API for the library. Introduced tooling for ViewControllers with Ditox.js DI container. ([7cffcd0](https://github.com/mnasyrov/rx-effects/commit/7cffcd03f915337fa27e3b55f30fd1ad0c45a087))
33 |
34 | ## [0.7.2](https://github.com/mnasyrov/rx-effects/compare/v0.7.1...v0.7.2) (2022-10-29)
35 |
36 | ### Features
37 |
38 | - Introduced `scope.onDestroy()` and `scope.subscribe()`. Added info about API deprecation. ([#9](https://github.com/mnasyrov/rx-effects/issues/9)) ([4467782](https://github.com/mnasyrov/rx-effects/commit/44677829f889aa4fbca12fb467f149cd0fab6869))
39 |
40 | ## [0.7.1](https://github.com/mnasyrov/rx-effects/compare/v0.7.0...v0.7.1) (2022-10-26)
41 |
42 | ### Bug Fixes
43 |
44 | - Fixed and renamed `scope.subscribe()` to `scope.observe()` ([d3cf291](https://github.com/mnasyrov/rx-effects/commit/d3cf291a10ecc9bac1ebce044c05ed140cd3b601))
45 |
46 | # [0.7.0](https://github.com/mnasyrov/rx-effects/compare/v0.6.0...v0.7.0) (2022-10-26)
47 |
48 | ### Bug Fixes
49 |
50 | - Fixed usage of Effect's options by `handleAction()` and `scope.createEffect()` ([#7](https://github.com/mnasyrov/rx-effects/issues/7)) ([e44bd23](https://github.com/mnasyrov/rx-effects/commit/e44bd23b563f7a61ea1ecfa291b311f52d55e577))
51 |
52 | ### Features
53 |
54 | - New scope's methods: `handleQuery()` and `subscribe()` ([#8](https://github.com/mnasyrov/rx-effects/issues/8)) ([5242c3e](https://github.com/mnasyrov/rx-effects/commit/5242c3e91b042b5eb060a0d1899a018c4b29294a))
55 |
56 | # [0.6.0](https://github.com/mnasyrov/rx-effects/compare/v0.5.2...v0.6.0) (2022-08-28)
57 |
58 | ### Features
59 |
60 | - `EffectOptions.pipeline` for customising processing of event. ([#4](https://github.com/mnasyrov/rx-effects/issues/4)) ([e927bb3](https://github.com/mnasyrov/rx-effects/commit/e927bb31c5fd7fe5c6c1e54b344d95dffc6ffd97))
61 | - Added `ExternalScope` type. ([#3](https://github.com/mnasyrov/rx-effects/issues/3)) ([11c8a9c](https://github.com/mnasyrov/rx-effects/commit/11c8a9cd181869e2f973233efe42c49dc51b5ad3))
62 | - Refactored Query API ([0ba6d12](https://github.com/mnasyrov/rx-effects/commit/0ba6d12df5f99cf98f04f130a89be814c90180f8))
63 | - Track all unhandled errors of Effects ([#5](https://github.com/mnasyrov/rx-effects/issues/5)) ([3c108a4](https://github.com/mnasyrov/rx-effects/commit/3c108a488eae471337cc727461a7a223f7c367f3))
64 |
65 | ## [0.5.2](https://github.com/mnasyrov/rx-effects/compare/v0.5.1...v0.5.2) (2022-01-26)
66 |
67 | **Note:** Version bump only for package rx-effects
68 |
69 | ## [0.5.1](https://github.com/mnasyrov/rx-effects/compare/v0.5.0...v0.5.1) (2022-01-11)
70 |
71 | ### Bug Fixes
72 |
73 | - Do not expose internal stores to extensions ([27420cb](https://github.com/mnasyrov/rx-effects/commit/27420cb152ddfafa48f9d7f75b59e558ba982d64))
74 |
75 | # [0.5.0](https://github.com/mnasyrov/rx-effects/compare/v0.4.1...v0.5.0) (2022-01-11)
76 |
77 | ### Bug Fixes
78 |
79 | - Fixed eslint rules ([6975806](https://github.com/mnasyrov/rx-effects/commit/69758063de4d9f6b7821b439aad054087df249b9))
80 |
81 | ### Features
82 |
83 | - Introduced store actions and `createStoreActions()` factory. ([c51bd07](https://github.com/mnasyrov/rx-effects/commit/c51bd07fa24c6d111567f75ad190a9f9bd987a5e))
84 | - Introduced Store extensions and StoreLoggerExtension ([931b949](https://github.com/mnasyrov/rx-effects/commit/931b949b0c5134d6261eac7f6381a293dab48599))
85 |
86 | ## [0.4.1](https://github.com/mnasyrov/rx-effects/compare/v0.4.0...v0.4.1) (2021-11-10)
87 |
88 | ### Bug Fixes
89 |
90 | - Share and replay mapQuery() and mergeQueries() to subscriptions ([8308310](https://github.com/mnasyrov/rx-effects/commit/830831001630d2b2b7318d2e7126706803eff9ff))
91 |
92 | # [0.4.0](https://github.com/mnasyrov/rx-effects/compare/v0.3.3...v0.4.0) (2021-09-30)
93 |
94 | ### Bug Fixes
95 |
96 | - Concurrent store updates by its subscribers ([bc29bb5](https://github.com/mnasyrov/rx-effects/commit/bc29bb545587c01386b7351e25c5ce4b5036dc9c))
97 |
98 | ## [0.3.3](https://github.com/mnasyrov/rx-effects/compare/v0.3.2...v0.3.3) (2021-09-27)
99 |
100 | ### Features
101 |
102 | - Store.update() can apply an array of mutations ([d778ac9](https://github.com/mnasyrov/rx-effects/commit/d778ac99549a9ac1887ea03ab77d5f0fa6527d1f))
103 |
104 | ## [0.3.1](https://github.com/mnasyrov/rx-effects/compare/v0.3.0...v0.3.1) (2021-09-07)
105 |
106 | ### Bug Fixes
107 |
108 | - `mapQuery()` and `mergeQueries()` produce distinct values by default ([17721af](https://github.com/mnasyrov/rx-effects/commit/17721af837b3a43f047ef67ba475294e58492e80))
109 |
110 | # [0.3.0](https://github.com/mnasyrov/rx-effects/compare/v0.2.2...v0.3.0) (2021-09-07)
111 |
112 | ### Features
113 |
114 | - Introduced `StateQueryOptions` for query mappers. Strict equality === is used by default as value comparators. ([5cc97e0](https://github.com/mnasyrov/rx-effects/commit/5cc97e0f7ab1623ffbdc133e5bfbe63911d68b56))
115 |
116 | ## [0.2.2](https://github.com/mnasyrov/rx-effects/compare/v0.2.1...v0.2.2) (2021-09-02)
117 |
118 | ### Bug Fixes
119 |
120 | - Fixed typings and arguments of `mergeQueries()` ([156abcc](https://github.com/mnasyrov/rx-effects/commit/156abccc4dbe569751c1c79d1dba19e441da91cf))
121 |
122 | ## [0.2.1](https://github.com/mnasyrov/rx-effects/compare/v0.2.0...v0.2.1) (2021-08-15)
123 |
124 | **Note:** Version bump only for package rx-effects
125 |
126 | # [0.2.0](https://github.com/mnasyrov/rx-effects/compare/v0.1.0...v0.2.0) (2021-08-09)
127 |
128 | ### Features
129 |
130 | - Renamed `EffectScope` to `Scope`. Extended `Scope` and `declareState()`. ([21d97be](https://github.com/mnasyrov/rx-effects/commit/21d97be080897f33f674d461397e8f1e86ac8eef))
131 |
132 | # [0.1.0](https://github.com/mnasyrov/rx-effects/compare/v0.0.8...v0.1.0) (2021-08-03)
133 |
134 | ### Features
135 |
136 | - Introduced 'destroy()' method to Store to complete it. ([199cbb7](https://github.com/mnasyrov/rx-effects/commit/199cbb70ab2163f9f8edc8045b988afd2604595b))
137 | - Introduced `Controller`, `useController()` and `mergeQueries()` ([d84a2e2](https://github.com/mnasyrov/rx-effects/commit/d84a2e2b8d1f57ca59e9664004de844a1f8bcf1f))
138 |
139 | ## [0.0.8](https://github.com/mnasyrov/rx-effects/compare/v0.0.7...v0.0.8) (2021-07-26)
140 |
141 | ### Bug Fixes
142 |
143 | - Dropped stateEffects for a while. Added stubs for docs. ([566ab80](https://github.com/mnasyrov/rx-effects/commit/566ab8085b6e493942bf908e3000097561a14724))
144 |
145 | ## [0.0.7](https://github.com/mnasyrov/rx-effects/compare/v0.0.6...v0.0.7) (2021-07-23)
146 |
147 | ### Bug Fixes
148 |
149 | - Types for actions and effects ([49235fe](https://github.com/mnasyrov/rx-effects/commit/49235fe80728a3803a16251d4c163f002b4bb29f))
150 |
151 | ## [0.0.6](https://github.com/mnasyrov/rx-effects/compare/v0.0.5...v0.0.6) (2021-07-12)
152 |
153 | **Note:** Version bump only for package rx-effects
154 |
155 | ## [0.0.5](https://github.com/mnasyrov/rx-effects/compare/v0.0.4...v0.0.5) (2021-07-11)
156 |
157 | **Note:** Version bump only for package rx-effects
158 |
159 | ## [0.0.4](https://github.com/mnasyrov/rx-effects/compare/v0.0.3...v0.0.4) (2021-07-11)
160 |
161 | **Note:** Version bump only for package rx-effects
162 |
163 | ## [0.0.3](https://github.com/mnasyrov/rx-effects/compare/v0.0.2...v0.0.3) (2021-07-11)
164 |
165 | **Note:** Version bump only for package rx-effects
166 |
167 | ## 0.0.2 (2021-07-11)
168 |
169 | **Note:** Version bump only for package rx-effects
170 |
--------------------------------------------------------------------------------
/packages/rx-effects/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2021 Mikhail Nasyrov
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/packages/rx-effects/README.md:
--------------------------------------------------------------------------------
1 | # RxEffects
2 |
3 |
4 |
5 | Reactive state and effect management with RxJS.
6 |
7 | [](https://www.npmjs.com/package/rx-effects)
8 | [](https://www.npmjs.com/package/rx-effects)
9 | [](https://www.npmjs.com/package/rx-effects)
10 | [](https://github.com/mnasyrov/rx-effects/blob/master/LICENSE)
11 | [](https://coveralls.io/github/mnasyrov/rx-effects?branch=main)
12 |
13 | ## Documentation
14 |
15 | - [Main docs](https://github.com/mnasyrov/rx-effects#readme)
16 | - [API docs](docs/README.md)
17 |
18 | ## Installation
19 |
20 | ```
21 | npm install rx-effects --save
22 | ```
23 |
24 | ## Concepts
25 |
26 | The main idea is to use the classic MVC pattern with event-based models (state stores) and reactive controllers (actions
27 | and effects). The view subscribes to model changes (state queries) of the controller and requests the controller to do
28 | some actions.
29 |
30 |
31 |
32 | Main elements:
33 |
34 | - `State` – a data model.
35 | - `Query` – a getter and subscriber for data of the state.
36 | - `StateMutation` – a pure function which changes the state.
37 | - `Store` – a state storage, it provides methods to update and subscribe the state.
38 | - `Action` – an event emitter.
39 | - `Effect` – a business logic which handles the action and makes state changes and side effects.
40 | - `Controller` – a controller type for effects and business logic
41 | - `Scope` – a controller-like boundary for effects and business logic
42 |
43 | ## State and Store
44 |
45 | ### Define State
46 |
47 | A state can be a primitive value or an object, and it is described as a type.
48 |
49 | ```ts
50 | type CartState = { orders: Array };
51 | ```
52 |
53 | ### State Mutations
54 |
55 | After that, it is recommended to declare a set of `StateMutation` functions which can be used to update the state. These
56 | functions should be pure and return a new state or the previous one. For providing an argument use currying functions.
57 |
58 | Actually, `StateMutation` function can change the state in place, but it is responsible for a developer to track state
59 | changes by providing custom `stateCompare` function to a store.
60 |
61 | ```ts
62 | const addPizzaToCart =
63 | (name: string): StateMutation =>
64 | (state) => ({ ...state, orders: [...state.orders, name] });
65 |
66 | const removePizzaFromCart =
67 | (name: string): StateMutation =>
68 | (state) => ({
69 | ...state,
70 | orders: state.orders.filter((order) => order !== name),
71 | });
72 | ```
73 |
74 | ### Creation of Store
75 |
76 | A store is created by `createStore()` function, which takes an initial state:
77 |
78 | ```ts
79 | const INITIAL_STATE: CartState = { orders: [] };
80 | const cartStore: Store = createStore(INITIAL_STATE);
81 | ```
82 |
83 | ### Updating Store
84 |
85 | The store can be updated by `set()` and `update()` methods:
86 |
87 | - `set()` applies the provided `State` value to the store.
88 | - `update()` creates the new state by the provided `StateMutation` and applies it to the store.
89 |
90 | ```ts
91 | function resetCart() {
92 | cartStore.set(INITIAL_STATE);
93 | }
94 |
95 | function addPizza(name: string) {
96 | cartStore.update(addPizzaToCart(name));
97 | }
98 | ```
99 |
100 | `Store.update()` can apply an array of mutations while skipping empty mutation:
101 |
102 | ```ts
103 | function addPizza(name: string) {
104 | cartStore.update([
105 | addPizzaToCart(name),
106 | name === 'Pepperoni' && addPizzaToCart('Bonus Pizza'),
107 | ]);
108 | }
109 | ```
110 |
111 | There is `pipeStateMutations()` helper, which can merge state updates into the single mutation:
112 |
113 | ```ts
114 | import { pipeStateMutations } from './stateMutation';
115 |
116 | const addPizzaToCartWithBonus = (name: string): StateMutation =>
117 | pipeStateMutations([addPizzaToCart(name), addPizzaToCart('Bonus Pizza')]);
118 |
119 | function addPizza(name: string) {
120 | cartStore.update(addPizzaToCartWithBonus(name));
121 | }
122 | ```
123 |
124 | ### Getting State
125 |
126 | The store implements `Query` type for providing the state:
127 |
128 | - `get()` returns the current state.
129 | - `value$` is an observable for the current state and future changes.
130 |
131 | It is allowed to get the current state at any time. However, you should be aware how it is used during async functions,
132 | because the state can be changed after awaiting a promise:
133 |
134 | ```ts
135 | // Not recommended
136 | async function submitForm() {
137 | await validate(formStore.get());
138 | await postData(formStore.get()); // `formStore` can return another data here
139 | }
140 |
141 | // Recommended
142 | async function submitForm() {
143 | const data = formStore.get();
144 | await validate(data);
145 | await postData(data);
146 | }
147 | ```
148 |
149 | ### State Queries
150 |
151 | The store has `select()` and `query()` methods:
152 |
153 | - `select()` returns `Observable` for the part of the state.
154 | - `value$` returns `Query` for the part of the state.
155 |
156 | Both of the methods takes `selector()` and `valueComparator()` arguments:
157 |
158 | - `selector()` takes a state and produce a value based on the state.
159 | - `valueComparator()` is optional and allows change an equality check for the produced value.
160 |
161 | ```ts
162 | const orders$: Observable> = cartStore.select(
163 | (state) => state.orders,
164 | );
165 |
166 | const ordersQuery: Query> = cartStore.query(
167 | (state) => state.orders,
168 | );
169 | ```
170 |
171 | Utility functions:
172 |
173 | - `mapQuery()` takes a query and a value mapper and returns a new query which projects the mapped value.
174 | ```ts
175 | const store = createStore<{ values: Array }>();
176 | const valuesQuery = store.query((state) => state.values);
177 | const top5values = mapQuery(valuesQuery, (values) => values.slice(0, 5));
178 | ```
179 | - `mergeQueries()` takes queries and a value merger and returns a new query which projects the derived value of queries.
180 | ```ts
181 | const store1 = createStore(2);
182 | const store2 = createStore(3);
183 | const sumValueQuery = mergeQueries(
184 | [store1, store2],
185 | (value1, value2) => value1 + value2,
186 | );
187 | ```
188 |
189 | ### Destroying Store
190 |
191 | The store implements `Controller` type and has `destroy()` method.
192 |
193 | `destory()` completes internal `Observable` sources and all derived observables, which are created by `select()` and `query()` methods.
194 |
195 | After calling `destroy()` the store stops sending updates for state changes.
196 |
197 | ### Usage of libraries for immutable state
198 |
199 | Types and factories for states and store are compatible with external libraries for immutable values like Immer.js or Immutable.js. All state changes are encapsulated by `StateMutation` functions so using the API remains the same. The one thing which should be considered is providing the right state comparators to the `Store`.
200 |
201 | #### Immer.js
202 |
203 | Integration of [Immer.js](https://github.com/immerjs/immer) is straightforward: it is enough to use `produce()` function inside `StateMutation` functions:
204 |
205 | Example:
206 |
207 | ```ts
208 | import { produce } from 'immer';
209 | import { StateMutation } from 'rx-effects';
210 |
211 | export type CartState = { orders: Array };
212 |
213 | export const CART_STATE = declareState({ orders: [] });
214 |
215 | export const addPizzaToCart = (name: string): StateMutation =>
216 | produce((state) => {
217 | state.orders.push(name);
218 | });
219 |
220 | export const removePizzaFromCart = (name: string): StateMutation =>
221 | produce((state) => {
222 | state.orders = state.orders.filter((order) => order !== name);
223 | });
224 | ```
225 |
226 | #### Immutable.js
227 |
228 | Integration of [Immutable.js](https://github.com/immutable-js/immutable-js):
229 |
230 | - It is recommended to use `Record` and `RecordOf` for object-list states.
231 | - Use `Immutable.is()` function as a comparator for Immutable's state and values.
232 |
233 | Example:
234 |
235 | ```ts
236 | import { is, List, Record, RecordOf } from 'immutable';
237 | import { declareState, StateMutation } from 'rx-effects';
238 |
239 | export type CartState = RecordOf<{ orders: Immutable.List }>;
240 |
241 | export const CART_STATE = declareState(
242 | Record({ orders: List() }),
243 | is, // State comparator of Immutable.js
244 | );
245 |
246 | export const addPizzaToCart =
247 | (name: string): StateMutation =>
248 | (state) =>
249 | state.set('orders', state.orders.push(name));
250 |
251 | export const removePizzaFromCart =
252 | (name: string): StateMutation =>
253 | (state) =>
254 | state.set(
255 | 'orders',
256 | state.orders.filter((order) => order !== name),
257 | );
258 | ```
259 |
260 | ## Actions and Effects
261 |
262 | ### Action
263 |
264 | `// TODO`
265 |
266 | ### Effect
267 |
268 | `// TODO`
269 |
270 | ### Controller
271 |
272 | `// TODO`
273 |
274 | ### Scope
275 |
276 | `// TODO`
277 |
278 | ---
279 |
280 | © 2021 [Mikhail Nasyrov](https://github.com/mnasyrov), [MIT license](./LICENSE)
281 |
--------------------------------------------------------------------------------
/packages/rx-effects/docs/.nojekyll:
--------------------------------------------------------------------------------
1 | TypeDoc added this file to prevent GitHub Pages from using Jekyll. You can turn off this behavior by setting the `githubPages` option to false.
--------------------------------------------------------------------------------
/packages/rx-effects/index.ts:
--------------------------------------------------------------------------------
1 | export * from './src';
2 |
--------------------------------------------------------------------------------
/packages/rx-effects/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "rx-effects",
3 | "description": "Reactive state and effects management",
4 | "version": "1.1.2",
5 | "license": "MIT",
6 | "author": "Mikhail Nasyrov (https://github.com/mnasyrov)",
7 | "homepage": "https://github.com/mnasyrov/rx-effects",
8 | "bugs": {
9 | "url": "https://github.com/mnasyrov/rx-effects/issues"
10 | },
11 | "repository": {
12 | "type": "git",
13 | "url": "https://github.com/mnasyrov/rx-effects.git"
14 | },
15 | "keywords": [
16 | "state",
17 | "effect",
18 | "management",
19 | "state-management",
20 | "reactive",
21 | "rxjs",
22 | "rx",
23 | "effector"
24 | ],
25 | "engines": {
26 | "node": ">=12"
27 | },
28 | "main": "dist/cjs/index.js",
29 | "module": "dist/esm/index.js",
30 | "types": "dist/esm/index.d.ts",
31 | "sideEffects": false,
32 | "files": [
33 | "dist",
34 | "docs",
35 | "src",
36 | "index.ts",
37 | "LICENSE",
38 | "README.md"
39 | ],
40 | "scripts": {
41 | "clean": "rm -rf dist",
42 | "build": "npm run build:cjs && npm run build:esm",
43 | "build:cjs": "tsc -p tsconfig.build.json --outDir dist/cjs --module commonjs",
44 | "build:esm": "tsc -p tsconfig.build.json --outDir dist/esm --module es2015"
45 | },
46 | "peerDependencies": {
47 | "ditox": ">=2.2 || >=3",
48 | "rxjs": ">=7.0.0"
49 | },
50 | "gitHead": "666d5b8672ebe6cb02174366a799d1da27d387a9"
51 | }
52 |
--------------------------------------------------------------------------------
/packages/rx-effects/src/action.test.ts:
--------------------------------------------------------------------------------
1 | import { firstValueFrom, map } from 'rxjs';
2 | import { createAction } from './action';
3 |
4 | describe('Action', () => {
5 | it('should emit the event', async () => {
6 | const action = createAction();
7 |
8 | const promise = firstValueFrom(action.event$);
9 | action(1);
10 |
11 | expect(await promise).toBe(1);
12 | });
13 |
14 | it('should use void type and undefined value if a generic type is not specified', async () => {
15 | const action = createAction();
16 |
17 | const promise = firstValueFrom(action.event$);
18 | action();
19 |
20 | expect(await promise).toBe(undefined);
21 | });
22 |
23 | it('should use the provided operator in the event pipeline', async () => {
24 | const action = createAction(map((value) => value * 10));
25 |
26 | const promise = firstValueFrom(action.event$);
27 | action(1);
28 |
29 | expect(await promise).toBe(10);
30 | });
31 | });
32 |
--------------------------------------------------------------------------------
/packages/rx-effects/src/action.ts:
--------------------------------------------------------------------------------
1 | import { MonoTypeOperatorFunction, Observable, Subject } from 'rxjs';
2 |
3 | /**
4 | * Action is an event emitter
5 | *
6 | * @param operator Optional transformation or handler for an event
7 | *
8 | * @field event$ - Observable for emitted events.
9 | *
10 | * @example
11 | * ```ts
12 | * // Create the action
13 | * const submitForm = createAction<{login: string, password: string}>();
14 | *
15 | * // Call the action
16 | * submitForm({login: 'foo', password: 'bar'});
17 | *
18 | * // Handle action's events
19 | * submitForm.even$.subscribe((formData) => {
20 | * // Process the formData
21 | * });
22 | * ```
23 | */
24 | export type Action = {
25 | readonly event$: Observable;
26 | (event: Event): void;
27 | } & ([Event] extends [undefined | void]
28 | ? { (event?: Event): void }
29 | : { (event: Event): void });
30 |
31 | export function createAction(
32 | operator?: MonoTypeOperatorFunction,
33 | ): Action {
34 | const source$ = new Subject();
35 |
36 | const emitter = (event: Event): void => source$.next(event);
37 | emitter.event$ = operator ? source$.pipe(operator) : source$.asObservable();
38 |
39 | return emitter as unknown as Action;
40 | }
41 |
--------------------------------------------------------------------------------
/packages/rx-effects/src/controller.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/ban-types */
2 |
3 | import { AnyObject } from './utils';
4 |
5 | /**
6 | * Effects and business logic controller.
7 | *
8 | * Implementation of the controller must provide `destroy()` method. It should
9 | * be used for closing subscriptions and disposing resources.
10 | *
11 | * @example
12 | * ```ts
13 | * type LoggerController = Controller<{
14 | * log: (message: string) => void;
15 | * }>;
16 | * ```
17 | */
18 | export type Controller =
19 | Readonly<
20 | ControllerProps & {
21 | /** Dispose the controller and clean its resources */
22 | destroy: () => void;
23 | }
24 | >;
25 |
--------------------------------------------------------------------------------
/packages/rx-effects/src/declareStore.test.ts:
--------------------------------------------------------------------------------
1 | import { declareStore } from './declareStore';
2 |
3 | describe('createStore()', () => {
4 | type SimpleState = {
5 | value: string;
6 | count: number;
7 | };
8 | const initialState: SimpleState = {
9 | value: 'initial text',
10 | count: 0,
11 | };
12 | const createSimpleStore = declareStore({
13 | initialState,
14 | updates: {
15 | set: (value: number) => (state) => {
16 | return {
17 | ...state,
18 | count: value,
19 | };
20 | },
21 | sum: (value1: number, value2: number) => (state) => {
22 | return {
23 | ...state,
24 | count: value1 + value2,
25 | };
26 | },
27 | increment: () => (state) => {
28 | return {
29 | ...state,
30 | count: state.count + 1,
31 | };
32 | },
33 | decrement: () => (state) => {
34 | return {
35 | ...state,
36 | count: state.count - 1,
37 | };
38 | },
39 | },
40 | });
41 |
42 | it('should allow testing of updates', () => {
43 | expect(initialState.count).toBe(0);
44 |
45 | const result = createSimpleStore.updates.set(10)(initialState);
46 |
47 | expect(result.count).toBe(10);
48 | });
49 |
50 | it('should executing without mutation for initial state', () => {
51 | createSimpleStore.updates.set(10)(initialState);
52 |
53 | expect(initialState.count).toBe(0);
54 | });
55 |
56 | it('should update initial state during initialization', () => {
57 | const simpleStore = createSimpleStore({
58 | count: 12,
59 | value: 'new initial text',
60 | });
61 |
62 | const { get } = simpleStore.asQuery();
63 |
64 | expect(get().count).toBe(12);
65 |
66 | expect(get().value).toBe('new initial text');
67 | });
68 |
69 | it('should initial state to be able to be a function', () => {
70 | const simpleStore = createSimpleStore((state) => ({
71 | ...state,
72 | value: 'updated text',
73 | }));
74 |
75 | const { get } = simpleStore.asQuery();
76 |
77 | expect(get().count).toBe(0);
78 |
79 | expect(get().value).toBe('updated text');
80 | });
81 |
82 | it('should update the state', () => {
83 | const simpleStore = createSimpleStore();
84 | const { get } = simpleStore.asQuery();
85 | expect(get().count).toBe(0);
86 |
87 | simpleStore.updates.set(10);
88 |
89 | expect(get().count).toBe(10);
90 |
91 | simpleStore.updates.increment();
92 |
93 | expect(get().count).toBe(11);
94 |
95 | simpleStore.updates.decrement();
96 | simpleStore.updates.decrement();
97 |
98 | expect(get().count).toBe(9);
99 | });
100 |
101 | it('should execute query selector', () => {
102 | const simpleStore = createSimpleStore();
103 | const { get } = simpleStore.query((state) => state.count);
104 |
105 | simpleStore.updates.set(1);
106 |
107 | expect(get()).toBe(1);
108 | });
109 |
110 | it('should use a lot of arguments in updates', () => {
111 | const simpleStore = createSimpleStore();
112 | const { get } = simpleStore.asQuery();
113 |
114 | simpleStore.updates.sum(1, 11);
115 |
116 | expect(get().count).toBe(12);
117 | });
118 | });
119 |
--------------------------------------------------------------------------------
/packages/rx-effects/src/declareStore.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/ban-types */
2 | import {
3 | createStore,
4 | StateMutation,
5 | StateUpdates,
6 | StoreOptions,
7 | StoreWithUpdates,
8 | withStoreUpdates,
9 | } from './store';
10 |
11 | export type StoreDeclaration<
12 | State,
13 | Updates extends StateUpdates = StateUpdates,
14 | > = Readonly<{
15 | initialState: State;
16 | updates: Updates;
17 | options?: StoreOptions;
18 | }>;
19 |
20 | type FactoryStateArg =
21 | | (State extends Function ? never : State)
22 | | StateMutation;
23 |
24 | export type DeclaredStoreFactory> = {
25 | (
26 | initialState?: FactoryStateArg,
27 | options?: StoreOptions,
28 | ): StoreWithUpdates;
29 |
30 | updates: Updates;
31 | };
32 |
33 | /**
34 | * declare the base interface for create store
35 | * @example
36 | ```ts
37 | type State = {
38 | id: string;
39 | name: string;
40 | isAdmin: boolean
41 | };
42 | const initialState: State = {
43 | id: '',
44 | name: '',
45 | isAdmin: false
46 | };
47 | const createUserStore = declareStore({
48 | initialState,
49 | updates: {
50 | setId: (id: string) => (state) => {
51 | return {
52 | ...state,
53 | id: id,
54 | };
55 | },
56 | setName: (name: string) => (state) => {
57 | return {
58 | ...state,
59 | name: name,
60 | };
61 | },
62 | update: (id: string name: string) => (state) => {
63 | return {
64 | ...state,
65 | id: id,
66 | name: name,
67 | };
68 | },
69 | setIsAdmin: () => (state) => {
70 | return {
71 | ...state,
72 | isAdmin: true,
73 | };
74 | },
75 | },
76 | });
77 |
78 | const userStore1 = createUserStore({ id: '1', name: 'User 1', isAdmin: false });
79 |
80 | const userStore2 = createUserStore({ id: '2', name: 'User 2', isAdmin: true });
81 |
82 | // OR
83 |
84 | const users = [
85 | createUserStore({id: 1, name: 'User 1'}),
86 | createUserStore({id: 2, name: 'User 2'}),
87 | ]
88 |
89 | userStore1.updates.setName('User from store 1');
90 |
91 | assets.isEqual(userStore1.get().name, 'User from store 1')
92 |
93 | assets.isEqual(userStore2.get().name, 'User 2')
94 |
95 | // type of createUserStore
96 | type UserStore = ReturnType;
97 | ```
98 | */
99 | export function declareStore<
100 | State,
101 | Updates extends StateUpdates = StateUpdates,
102 | >(
103 | declaration: StoreDeclaration,
104 | ): DeclaredStoreFactory {
105 | const {
106 | initialState: baseState,
107 | updates,
108 | options: baseOptions,
109 | } = declaration;
110 |
111 | function factory(
112 | initialState?: FactoryStateArg,
113 | options?: StoreOptions,
114 | ) {
115 | const state =
116 | initialState === undefined
117 | ? baseState
118 | : typeof initialState === 'function'
119 | ? (initialState as StateMutation)(baseState)
120 | : initialState;
121 |
122 | const store = createStore(state, {
123 | ...baseOptions,
124 | ...options,
125 | });
126 |
127 | return withStoreUpdates(store, updates);
128 | }
129 |
130 | return Object.assign(factory, { updates });
131 | }
132 |
--------------------------------------------------------------------------------
/packages/rx-effects/src/effect.ts:
--------------------------------------------------------------------------------
1 | import {
2 | defer,
3 | mergeMap,
4 | Observable,
5 | of,
6 | OperatorFunction,
7 | retry,
8 | Subject,
9 | Subscription,
10 | tap,
11 | } from 'rxjs';
12 | import { Action } from './action';
13 | import { Controller } from './controller';
14 | import { createEffectController } from './effectController';
15 | import { EffectState } from './effectState';
16 | import { Query } from './query';
17 |
18 | /**
19 | * Handler for an event. It can be asynchronous.
20 | *
21 | * @result a result, Promise or Observable
22 | */
23 | export type EffectHandler = (
24 | event: Event,
25 | ) => Result | Promise | Observable;
26 |
27 | export type EffectEventProject = (
28 | event: Event,
29 | ) => Observable;
30 |
31 | export type EffectPipeline = (
32 | eventProject: EffectEventProject,
33 | ) => OperatorFunction;
34 |
35 | const DEFAULT_MERGE_MAP_PIPELINE: EffectPipeline = (eventProject) =>
36 | mergeMap(eventProject);
37 |
38 | export type EffectOptions = Readonly<{
39 | /**
40 | * Custom pipeline for processing effect's events.
41 | *
42 | * `mergeMap` pipeline is used by default.
43 | */
44 | pipeline?: EffectPipeline;
45 | }>;
46 |
47 | /**
48 | * Effect encapsulates a handler for Action or Observable.
49 | *
50 | * It provides the state of execution results, which can be used to construct
51 | * a graph of business logic.
52 | *
53 | * Effect collects all internal subscriptions, and provides `destroy()` methods
54 | * unsubscribe from them and deactivate the effect.
55 | */
56 | export type Effect = Controller<
57 | EffectState & {
58 | handle: (
59 | source: Action | Observable | Query,
60 | ) => Subscription;
61 | }
62 | >;
63 |
64 | /**
65 | * Creates `Effect` from the provided handler.
66 | *
67 | * @example
68 | * ```ts
69 | * const sumEffect = createEffect<{a: number, b: number}, number>((event) => {
70 | * return a + b;
71 | * });
72 | * ```
73 | */
74 | export function createEffect(
75 | handler: EffectHandler,
76 | options?: EffectOptions,
77 | ): Effect {
78 | const pipeline: EffectPipeline =
79 | options?.pipeline ?? DEFAULT_MERGE_MAP_PIPELINE;
80 |
81 | const event$: Subject = new Subject();
82 | const controller = createEffectController();
83 |
84 | const subscriptions = new Subscription(() => {
85 | event$.complete();
86 | controller.destroy();
87 | });
88 |
89 | const eventProject: EffectEventProject = (event: Event) => {
90 | return defer(() => {
91 | controller.start();
92 |
93 | const result = handler(event);
94 |
95 | return result instanceof Observable || result instanceof Promise
96 | ? result
97 | : of(result);
98 | }).pipe(
99 | tap({
100 | next: (result) => {
101 | controller.next({ event, result });
102 | },
103 | complete: () => {
104 | controller.complete();
105 | },
106 | error: (error) => {
107 | controller.error({ origin: 'handler', event, error });
108 | },
109 | }),
110 | );
111 | };
112 |
113 | subscriptions.add(event$.pipe(pipeline(eventProject), retry()).subscribe());
114 |
115 | return {
116 | ...controller.state,
117 |
118 | handle(
119 | source: Observable | Action | Query,
120 | ): Subscription {
121 | const observable = getSourceObservable(source);
122 |
123 | const subscription = observable.subscribe({
124 | next: (event) => event$.next(event),
125 | error: (error) => controller.error({ origin: 'source', error }),
126 | });
127 | subscriptions.add(subscription);
128 |
129 | return subscription;
130 | },
131 |
132 | destroy: () => {
133 | subscriptions.unsubscribe();
134 | },
135 | };
136 | }
137 |
138 | function getSourceObservable(
139 | source: Observable | Action | Query,
140 | ): Observable {
141 | const type = typeof source;
142 |
143 | if (type === 'function' && 'event$' in source) {
144 | return source.event$;
145 | }
146 |
147 | if (type === 'object' && 'value$' in source) {
148 | return source.value$;
149 | }
150 |
151 | if (source instanceof Observable) {
152 | return source;
153 | }
154 |
155 | throw new TypeError('Unexpected source type');
156 | }
157 |
--------------------------------------------------------------------------------
/packages/rx-effects/src/effectController.test.ts:
--------------------------------------------------------------------------------
1 | import { createEffectController } from './effectController';
2 |
3 | describe('EffectController', () => {
4 | describe('start()', () => {
5 | it('should increase pendingCount', () => {
6 | const controller = createEffectController();
7 | expect(controller.state.pendingCount.get()).toBe(0);
8 |
9 | controller.start();
10 | expect(controller.state.pendingCount.get()).toBe(1);
11 |
12 | controller.start();
13 | expect(controller.state.pendingCount.get()).toBe(2);
14 | });
15 | });
16 |
17 | describe('complete()', () => {
18 | it('should decrease pendingCount', () => {
19 | const controller = createEffectController();
20 |
21 | controller.start();
22 | controller.start();
23 | expect(controller.state.pendingCount.get()).toBe(2);
24 |
25 | controller.complete();
26 | controller.complete();
27 | expect(controller.state.pendingCount.get()).toBe(0);
28 | });
29 |
30 | it('should not decrease pendingCount below zero', () => {
31 | const controller = createEffectController();
32 |
33 | controller.complete();
34 | expect(controller.state.pendingCount.get()).toBe(0);
35 |
36 | controller.start();
37 | controller.complete();
38 | controller.complete();
39 | expect(controller.state.pendingCount.get()).toBe(0);
40 | });
41 | });
42 | });
43 |
--------------------------------------------------------------------------------
/packages/rx-effects/src/effectController.ts:
--------------------------------------------------------------------------------
1 | import { identity, map, merge, Observable, Subject, Subscription } from 'rxjs';
2 | import { Controller } from './controller';
3 | import {
4 | EffectError,
5 | EffectNotification,
6 | EffectResult,
7 | EffectState,
8 | } from './effectState';
9 | import { createStore, InternalStoreOptions } from './store';
10 |
11 | const GLOBAL_EFFECT_UNHANDLED_ERROR_SUBJECT = new Subject<
12 | EffectError
13 | >();
14 |
15 | export const GLOBAL_EFFECT_UNHANDLED_ERROR$ =
16 | GLOBAL_EFFECT_UNHANDLED_ERROR_SUBJECT.asObservable();
17 |
18 | function emitGlobalUnhandledError(
19 | effectError: EffectError,
20 | ): void {
21 | if (GLOBAL_EFFECT_UNHANDLED_ERROR_SUBJECT.observed) {
22 | GLOBAL_EFFECT_UNHANDLED_ERROR_SUBJECT.next(effectError);
23 | } else {
24 | console.error('Uncaught error in Effect', effectError);
25 | }
26 | }
27 |
28 | export type EffectController = Controller<{
29 | state: EffectState;
30 |
31 | start: () => void;
32 | next: (result: EffectResult) => void;
33 | complete: () => void;
34 | error: (error: EffectError) => void;
35 | }>;
36 |
37 | const increaseCount = (count: number): number => count + 1;
38 | const decreaseCount = (count: number): number => (count > 0 ? count - 1 : 0);
39 |
40 | export function createEffectController<
41 | Event,
42 | Result,
43 | ErrorType = Error,
44 | >(): EffectController {
45 | const subscriptions = new Subscription();
46 |
47 | const event$: Subject = new Subject();
48 | const done$: Subject> = new Subject();
49 | const error$: Subject> = new Subject();
50 | const pendingCount = createStore(0, {
51 | internal: true,
52 | } as InternalStoreOptions);
53 |
54 | subscriptions.add(() => {
55 | event$.complete();
56 | done$.complete();
57 | error$.complete();
58 | pendingCount.destroy();
59 | });
60 |
61 | const notifications$: Observable<
62 | EffectNotification
63 | > = merge(
64 | done$.pipe(
65 | map<
66 | EffectResult,
67 | EffectNotification
68 | >((entry) => ({ type: 'result', ...entry })),
69 | ),
70 |
71 | error$.pipe(
72 | map<
73 | EffectError,
74 | EffectNotification
75 | >((entry) => ({ type: 'error', ...entry })),
76 | ),
77 | );
78 |
79 | return {
80 | state: {
81 | done$: done$.asObservable(),
82 | result$: done$.pipe(map(({ result }) => result)),
83 | error$: error$.asObservable(),
84 | final$: notifications$,
85 | pending: pendingCount.query((count) => count > 0),
86 | pendingCount: pendingCount.query(identity),
87 | },
88 |
89 | start: () => pendingCount.update(increaseCount),
90 |
91 | next: (result) => done$.next(result),
92 |
93 | complete: () => pendingCount.update(decreaseCount),
94 |
95 | error: (effectError) => {
96 | if (effectError.origin === 'handler') {
97 | pendingCount.update(decreaseCount);
98 | }
99 |
100 | if (error$.observed) {
101 | error$.next(effectError);
102 | } else {
103 | emitGlobalUnhandledError(effectError);
104 | }
105 | },
106 |
107 | destroy: () => {
108 | subscriptions.unsubscribe();
109 | },
110 | };
111 | }
112 |
--------------------------------------------------------------------------------
/packages/rx-effects/src/effectState.ts:
--------------------------------------------------------------------------------
1 | import { Observable } from 'rxjs';
2 | import { Query } from './query';
3 |
4 | export type EffectResult = Readonly<{
5 | event: Event;
6 | result: Value;
7 | }>;
8 |
9 | export type EffectErrorOrigin = 'source' | 'handler';
10 |
11 | export type EffectError = Readonly<
12 | | {
13 | origin: 'source';
14 | event?: undefined;
15 | error: any;
16 | }
17 | | {
18 | origin: 'handler';
19 | event: Event;
20 | error: ErrorType;
21 | }
22 | >;
23 |
24 | export type EffectNotification = Readonly<
25 | | ({ type: 'result' } & EffectResult)
26 | | ({ type: 'error' } & EffectError)
27 | >;
28 |
29 | /**
30 | * Details about performing the effect.
31 | */
32 | export type EffectState = Readonly<{
33 | /** Provides a result of successful execution of the handler */
34 | result$: Observable;
35 |
36 | /** Provides a source event and a result of successful execution of the handler */
37 | done$: Observable>;
38 |
39 | /** Provides an error emitter by a source (`event` is `undefined`)
40 | * or by the handler (`event` is not `undefined`) */
41 | error$: Observable>;
42 |
43 | /** Provides a notification after execution of the handler for both success or error result */
44 | final$: Observable>;
45 |
46 | /** Provides `true` if there is any execution of the handler in progress */
47 | pending: Query;
48 |
49 | /** Provides a count of the handler in progress */
50 | pendingCount: Query;
51 | }>;
52 |
--------------------------------------------------------------------------------
/packages/rx-effects/src/index.ts:
--------------------------------------------------------------------------------
1 | export type { Action } from './action';
2 | export { createAction } from './action';
3 |
4 | export type {
5 | EffectError,
6 | EffectNotification,
7 | EffectState,
8 | EffectErrorOrigin,
9 | EffectResult,
10 | } from './effectState';
11 |
12 | export {
13 | createEffectController,
14 | GLOBAL_EFFECT_UNHANDLED_ERROR$,
15 | } from './effectController';
16 | export type { EffectController } from './effectController';
17 |
18 | export type {
19 | Effect,
20 | EffectHandler,
21 | EffectOptions,
22 | EffectPipeline,
23 | EffectEventProject,
24 | } from './effect';
25 | export { createEffect } from './effect';
26 |
27 | export type { Scope, ExternalScope } from './scope';
28 | export { createScope } from './scope';
29 |
30 | export type { Controller } from './controller';
31 |
32 | export * from './mvc';
33 |
34 | export type { Query, QueryOptions } from './query';
35 | export { mapQuery, mergeQueries } from './queryMappers';
36 |
37 | export * from './store';
38 | export { pipeStore, declareStoreWithUpdates } from './storeUtils';
39 | export type { StoreEvent } from './storeEvents';
40 |
41 | export type { StateMutationMetadata } from './storeMetadata';
42 |
43 | export type { StoreExtension } from './storeExtensions';
44 | export { registerStoreExtension } from './storeExtensions';
45 | export { createStoreLoggerExtension } from './storeLoggerExtension';
46 |
47 | export { OBJECT_COMPARATOR } from './utils';
48 |
49 | export type { StoreDeclaration, DeclaredStoreFactory } from './declareStore';
50 | export { declareStore } from './declareStore';
51 |
--------------------------------------------------------------------------------
/packages/rx-effects/src/mvc.test.ts:
--------------------------------------------------------------------------------
1 | import { createContainer, token } from 'ditox';
2 | import {
3 | createController,
4 | declareController,
5 | declareViewController,
6 | InferredService,
7 | } from './mvc';
8 | import { Query } from './query';
9 | import { createStore } from './store';
10 |
11 | describe('createController()', () => {
12 | it('should create a controller', () => {
13 | const onDestroy = jest.fn();
14 |
15 | const controller = createController((scope) => {
16 | const store = scope.createStore(1);
17 |
18 | return {
19 | counter: store.asQuery(),
20 | increase: () => store.update((state) => state + 1),
21 | decrease: () => store.update((state) => state - 1),
22 | destroy: () => onDestroy(),
23 | };
24 | });
25 |
26 | const { counter, increase, decrease, destroy } = controller;
27 | expect(counter.get()).toBe(1);
28 |
29 | increase();
30 | expect(counter.get()).toBe(2);
31 |
32 | decrease();
33 | expect(counter.get()).toBe(1);
34 |
35 | destroy();
36 | expect(onDestroy).toHaveBeenCalled();
37 | });
38 |
39 | it('should defined a scope which is destroyed after destroying a controller', () => {
40 | const onDestroy = jest.fn();
41 |
42 | const controller = createController((scope) => {
43 | scope.add(() => onDestroy());
44 |
45 | return {};
46 | });
47 |
48 | controller.destroy();
49 | expect(onDestroy).toHaveBeenCalledTimes(1);
50 | });
51 |
52 | it('should defined a scope which can be destroyed twice: by a controller and by a proxy of destroy() function', () => {
53 | const onControllerDestroy = jest.fn();
54 | const onScopeDestroy = jest.fn();
55 |
56 | const controller = createController((scope) => {
57 | scope.add(() => onScopeDestroy());
58 |
59 | return {
60 | destroy() {
61 | onControllerDestroy();
62 | scope.destroy();
63 | },
64 | };
65 | });
66 |
67 | controller.destroy();
68 | expect(onControllerDestroy).toHaveBeenCalledTimes(1);
69 | expect(onScopeDestroy).toHaveBeenCalledTimes(1);
70 | });
71 | });
72 |
73 | describe('declareController()', () => {
74 | it('should create a factory which accepts a DI container, resolves dependencies and constructs a controller', () => {
75 | const VALUE_TOKEN = token();
76 |
77 | const controllerFactory = declareController(
78 | { value: VALUE_TOKEN },
79 | ({ value }) => ({
80 | getValue: () => value * 10,
81 | }),
82 | );
83 |
84 | const container = createContainer();
85 | container.bindValue(VALUE_TOKEN, 1);
86 |
87 | const controller = controllerFactory(container);
88 | expect(controller.getValue()).toBe(10);
89 |
90 | // Check inferring of a service type
91 | type Service = InferredService;
92 | const service: Service = controller;
93 | expect(service.getValue()).toBe(10);
94 | });
95 | });
96 |
97 | describe('declareViewController()', () => {
98 | it('should create a factory which accepts a DI container, resolves dependencies and constructs a controller', () => {
99 | const VALUE_TOKEN = token();
100 |
101 | const controllerFactory = declareViewController(
102 | { value: VALUE_TOKEN },
103 | ({ value }) => ({
104 | getValue: () => value * 10,
105 | }),
106 | );
107 |
108 | const container = createContainer();
109 | container.bindValue(VALUE_TOKEN, 1);
110 |
111 | const controller = controllerFactory(container);
112 | expect(controller.getValue()).toBe(10);
113 |
114 | // Check inferring of a service type
115 | type Service = InferredService;
116 | const service: Service = controller;
117 | expect(service.getValue()).toBe(10);
118 | });
119 |
120 | it('should create a factory without DI dependencies', () => {
121 | const controllerFactory = declareViewController((scope) => {
122 | const $value = scope.createStore(10);
123 |
124 | return {
125 | getValue: () => $value.get(),
126 | };
127 | });
128 |
129 | const container = createContainer();
130 |
131 | const controller = controllerFactory(container);
132 | expect(controller.getValue()).toBe(10);
133 |
134 | // Check inferring of a service type
135 | type Service = InferredService;
136 | const service: Service = controller;
137 | expect(service.getValue()).toBe(10);
138 | });
139 |
140 | it('should create a factory which accepts resolved dependencies and parameters as Queries', () => {
141 | const VALUE_TOKEN = token();
142 |
143 | const controllerFactory = declareViewController(
144 | { value: VALUE_TOKEN },
145 | ({ value }) =>
146 | (scope, arg: Query) => {
147 | const $value = scope.createStore(10);
148 | return {
149 | getValue: () => value * $value.get() + arg.get(),
150 | };
151 | },
152 | );
153 |
154 | const container = createContainer();
155 | container.bindValue(VALUE_TOKEN, 1);
156 |
157 | const controller = controllerFactory(container, createStore(2));
158 | expect(controller.getValue()).toBe(12);
159 |
160 | // Check inferring of a service type
161 | type Service = InferredService;
162 | const service: Service = controller;
163 | expect(service.getValue()).toBe(12);
164 | });
165 | });
166 |
--------------------------------------------------------------------------------
/packages/rx-effects/src/mvc.ts:
--------------------------------------------------------------------------------
1 | import { Container, injectable, Token } from 'ditox';
2 | import { Controller } from './controller';
3 | import { Query } from './query';
4 | import { createScope, Scope } from './scope';
5 | import { AnyObject } from './utils';
6 |
7 | export function createController(
8 | factory: (scope: Scope) => Service & { destroy?: () => void },
9 | ): Controller {
10 | const scope = createScope();
11 |
12 | const controller = factory(scope);
13 |
14 | return {
15 | ...controller,
16 |
17 | destroy: () => {
18 | controller.destroy?.();
19 | scope.destroy();
20 | },
21 | };
22 | }
23 |
24 | export type ControllerFactory = (
25 | container: Container,
26 | ) => Controller;
27 |
28 | declare type DependencyProps = {
29 | [key: string]: unknown;
30 | };
31 |
32 | declare type TokenProps = {
33 | [K in keyof Props]: Token;
34 | };
35 |
36 | export function declareController<
37 | Dependencies extends DependencyProps,
38 | Service extends AnyObject,
39 | >(
40 | tokens: TokenProps,
41 | factory: (deps: Dependencies, scope: Scope) => Service,
42 | ): ControllerFactory {
43 | return injectable(
44 | (deps) => createController((scope) => factory(deps as Dependencies, scope)),
45 | tokens,
46 | );
47 | }
48 |
49 | export type ViewControllerFactory<
50 | Service extends AnyObject,
51 | Params extends Query[],
52 | > = (container: Container, ...params: Params) => Controller;
53 |
54 | export type InferredService = Factory extends ViewControllerFactory<
55 | infer Service,
56 | // eslint-disable-next-line @typescript-eslint/no-unused-vars
57 | infer Params
58 | >
59 | ? Service
60 | : never;
61 |
62 | export function declareViewController<
63 | Service extends AnyObject,
64 | Params extends Query[],
65 | >(
66 | factory: (scope: Scope, ...params: Params) => Service,
67 | ): ViewControllerFactory;
68 |
69 | export function declareViewController<
70 | Dependencies extends DependencyProps,
71 | Service extends AnyObject,
72 | Params extends Query[],
73 | >(
74 | tokens: TokenProps,
75 | factory: (
76 | deps: Dependencies,
77 | scope: Scope,
78 | ) => ((scope: Scope, ...params: Params) => Service) | Service,
79 | ): ViewControllerFactory;
80 |
81 | export function declareViewController<
82 | Dependencies extends DependencyProps,
83 | Service extends AnyObject,
84 | Params extends Query[],
85 | Factory extends (scope: Scope, ...params: Params) => Service,
86 | FactoryWithDependencies extends
87 | | ((deps: Dependencies, scope: Scope) => Service)
88 | | ((
89 | deps: Dependencies,
90 | scope: Scope,
91 | ) => (scope: Scope, ...params: Params) => Service),
92 | >(
93 | tokensOrFactory: TokenProps | Factory,
94 | factory?: FactoryWithDependencies,
95 | ): ViewControllerFactory {
96 | return (container: Container, ...params: Params) => {
97 | if (typeof tokensOrFactory === 'function') {
98 | return createController((scope) => {
99 | return tokensOrFactory(scope, ...params);
100 | });
101 | }
102 |
103 | return injectable((dependencies) => {
104 | return createController((scope) => {
105 | const factoryValue = factory as FactoryWithDependencies;
106 |
107 | const result = factoryValue(dependencies as Dependencies, scope);
108 |
109 | if (typeof result === 'function') {
110 | return result(scope, ...params);
111 | }
112 | return result;
113 | });
114 | }, tokensOrFactory)(container);
115 | };
116 | }
117 |
--------------------------------------------------------------------------------
/packages/rx-effects/src/query.test.ts:
--------------------------------------------------------------------------------
1 | import { BehaviorSubject, firstValueFrom } from 'rxjs';
2 | import { Query } from './query';
3 | import { mapQuery, mergeQueries } from './queryMappers';
4 | import { createStore } from './store';
5 |
6 | describe('mapQuery()', () => {
7 | it('should return a new state query with applied mapper which transforms the selected value', async () => {
8 | const sourceValue$ = new BehaviorSubject(0);
9 | const sourceQuery: Query = {
10 | get: () => sourceValue$.getValue(),
11 | value$: sourceValue$,
12 | };
13 |
14 | const query = mapQuery(sourceQuery, (value) => value + 10);
15 |
16 | expect(query.get()).toBe(10);
17 | expect(await firstValueFrom(query.value$)).toBe(10);
18 |
19 | sourceValue$.next(1);
20 | expect(query.get()).toBe(11);
21 | expect(await firstValueFrom(query.value$)).toBe(11);
22 | });
23 |
24 | it('should not produce values for each source emission if distinct is false', () => {
25 | const sourceValue$ = new BehaviorSubject(0);
26 | const sourceQuery: Query = {
27 | get: () => sourceValue$.getValue(),
28 | value$: sourceValue$,
29 | };
30 |
31 | const query = mapQuery(sourceQuery, (value) => value, { distinct: false });
32 | const listener = jest.fn();
33 | query.value$.subscribe(listener);
34 |
35 | sourceValue$.next(1);
36 | sourceValue$.next(1);
37 |
38 | expect(listener).toHaveBeenCalledTimes(3);
39 | expect(listener).toHaveBeenNthCalledWith(1, 0);
40 | expect(listener).toHaveBeenNthCalledWith(2, 1);
41 | expect(listener).toHaveBeenNthCalledWith(3, 1);
42 | });
43 |
44 | it('should produce distinct values by default', () => {
45 | const sourceValue$ = new BehaviorSubject(0);
46 | const sourceQuery: Query = {
47 | get: () => sourceValue$.getValue(),
48 | value$: sourceValue$,
49 | };
50 |
51 | const query = mapQuery(sourceQuery, (value) => value);
52 | const listener = jest.fn();
53 | query.value$.subscribe(listener);
54 |
55 | sourceValue$.next(1);
56 | sourceValue$.next(1);
57 |
58 | expect(listener).toHaveBeenCalledTimes(2);
59 | expect(listener).toHaveBeenNthCalledWith(1, 0);
60 | expect(listener).toHaveBeenNthCalledWith(2, 1);
61 | });
62 |
63 | it('should produce distinct values when distinct = true', () => {
64 | const sourceValue$ = new BehaviorSubject(0);
65 | const sourceQuery: Query = {
66 | get: () => sourceValue$.getValue(),
67 | value$: sourceValue$,
68 | };
69 |
70 | const query = mapQuery(sourceQuery, (value) => value, { distinct: true });
71 | const listener = jest.fn();
72 | query.value$.subscribe(listener);
73 |
74 | sourceValue$.next(1);
75 | sourceValue$.next(1);
76 |
77 | expect(listener).toHaveBeenCalledTimes(2);
78 | expect(listener).toHaveBeenNthCalledWith(1, 0);
79 | expect(listener).toHaveBeenNthCalledWith(2, 1);
80 | });
81 |
82 | it('should produce distinct values with the custom comparator', () => {
83 | type Value = { v: number };
84 | const sourceValue$ = new BehaviorSubject({ v: 0 });
85 | const sourceQuery: Query = {
86 | get: () => sourceValue$.getValue(),
87 | value$: sourceValue$,
88 | };
89 |
90 | const query = mapQuery(sourceQuery, (value) => value, {
91 | distinct: { comparator: (a, b) => a.v === b.v },
92 | });
93 | const listener = jest.fn();
94 | query.value$.subscribe(listener);
95 |
96 | sourceValue$.next({ v: 1 });
97 | sourceValue$.next({ v: 1 });
98 | sourceValue$.next({ v: 2 });
99 |
100 | expect(listener).toHaveBeenCalledTimes(3);
101 | expect(listener).toHaveBeenNthCalledWith(1, { v: 0 });
102 | expect(listener).toHaveBeenNthCalledWith(2, { v: 1 });
103 | expect(listener).toHaveBeenNthCalledWith(3, { v: 2 });
104 | });
105 |
106 | it('should produce distinct values with the custom keySelector', () => {
107 | type Value = { v: number };
108 | const sourceValue$ = new BehaviorSubject({ v: 0 });
109 | const sourceQuery: Query = {
110 | get: () => sourceValue$.getValue(),
111 | value$: sourceValue$,
112 | };
113 |
114 | const query = mapQuery(sourceQuery, (value) => value, {
115 | distinct: { keySelector: (a) => a.v },
116 | });
117 | const listener = jest.fn();
118 | query.value$.subscribe(listener);
119 |
120 | sourceValue$.next({ v: 1 });
121 | sourceValue$.next({ v: 1 });
122 | sourceValue$.next({ v: 2 });
123 |
124 | expect(listener).toHaveBeenCalledTimes(3);
125 | expect(listener).toHaveBeenNthCalledWith(1, { v: 0 });
126 | expect(listener).toHaveBeenNthCalledWith(2, { v: 1 });
127 | expect(listener).toHaveBeenNthCalledWith(3, { v: 2 });
128 | });
129 |
130 | it('should return the same calculated value if there is a subscription and the source was not changed', async () => {
131 | const source = createStore(1);
132 | const result = mapQuery(source, (value) => ({ value }));
133 |
134 | expect(result.get() === result.get()).toBe(false);
135 |
136 | let obj3;
137 | let obj4;
138 | const subscription1 = result.value$.subscribe((value) => (obj3 = value));
139 | const subscription2 = result.value$.subscribe((value) => (obj4 = value));
140 |
141 | expect(obj3 === obj4).toBe(true);
142 |
143 | const obj1 = result.get();
144 | const obj2 = result.get();
145 | expect(obj1 === obj2).toBe(true);
146 |
147 | expect(obj1 === obj3).toBe(true);
148 |
149 | subscription1.unsubscribe();
150 | expect(result.get() === obj1).toBe(true);
151 |
152 | subscription2.unsubscribe();
153 | expect(result.get() === obj1).toBe(false);
154 | });
155 | });
156 |
157 | describe('mergeQueries()', () => {
158 | it('should return a calculated value from source queries', () => {
159 | const store1 = createStore(2);
160 | const store2 = createStore('text');
161 | const query = mergeQueries([store1, store2], (a, b) => ({ a, b }));
162 |
163 | expect(query.get()).toEqual({ a: 2, b: 'text' });
164 |
165 | store1.set(3);
166 | expect(query.get()).toEqual({ a: 3, b: 'text' });
167 |
168 | store2.set('text2');
169 | expect(query.get()).toEqual({ a: 3, b: 'text2' });
170 | });
171 |
172 | it('should return an observable with the calculated value from source queries', async () => {
173 | const store1 = createStore(2);
174 | const store2 = createStore('text');
175 | const query = mergeQueries([store1, store2], (a, b) => ({ a, b }));
176 |
177 | expect(await firstValueFrom(query.value$)).toEqual({ a: 2, b: 'text' });
178 |
179 | store1.set(3);
180 | expect(await firstValueFrom(query.value$)).toEqual({ a: 3, b: 'text' });
181 |
182 | store2.set('text2');
183 | expect(await firstValueFrom(query.value$)).toEqual({ a: 3, b: 'text2' });
184 | });
185 |
186 | it('should infer types for values of the queries', () => {
187 | const store1 = createStore(2);
188 | const store2 = createStore<{ value: number }>({ value: 3 });
189 |
190 | const query: Query = mergeQueries(
191 | [store1, store2],
192 | (value, obj) => value + obj.value,
193 | );
194 |
195 | expect(query.get()).toEqual(5);
196 | });
197 |
198 | it('should produce values for each source emission if distinct is false', () => {
199 | const store1 = createStore(0);
200 | const store2 = createStore({ k: 0, value: 0 });
201 |
202 | const query = mergeQueries(
203 | [store1, store2],
204 | (a, b) => ({ a, b: b.value }),
205 | { distinct: false },
206 | );
207 | const listener = jest.fn();
208 | query.value$.subscribe(listener);
209 |
210 | store2.set({ k: 1, value: 0 });
211 | store2.set({ k: 2, value: 1 });
212 |
213 | expect(listener).toHaveBeenCalledTimes(3);
214 | expect(listener).toHaveBeenNthCalledWith(1, { a: 0, b: 0 });
215 | expect(listener).toHaveBeenNthCalledWith(2, { a: 0, b: 0 });
216 | expect(listener).toHaveBeenNthCalledWith(3, { a: 0, b: 1 });
217 | });
218 |
219 | it('should produce distinct values for each source emission by default', () => {
220 | const store1 = createStore(0);
221 | const store2 = createStore({ k: 0, value: 0 });
222 |
223 | const query = mergeQueries([store1, store2], (a, b) => a + b.value);
224 | const listener = jest.fn();
225 | query.value$.subscribe(listener);
226 |
227 | store2.set({ k: 1, value: 0 });
228 | store2.set({ k: 2, value: 1 });
229 |
230 | expect(listener).toHaveBeenCalledTimes(2);
231 | expect(listener).toHaveBeenNthCalledWith(1, 0);
232 | expect(listener).toHaveBeenNthCalledWith(2, 1);
233 | });
234 |
235 | it('should produce distinct values with the custom comparator', () => {
236 | const store1 = createStore(0);
237 | const store2 = createStore({ k: 0, value: 0 });
238 |
239 | const query = mergeQueries(
240 | [store1, store2],
241 | (a, b) => ({ a, b: b.value }),
242 | { distinct: { comparator: (a, b) => a.a === b.a } },
243 | );
244 | const listener = jest.fn();
245 | query.value$.subscribe(listener);
246 |
247 | store2.set({ k: 1, value: 1 });
248 | store2.set({ k: 2, value: 2 });
249 | store1.set(1);
250 |
251 | expect(listener).toHaveBeenCalledTimes(2);
252 | expect(listener).toHaveBeenNthCalledWith(1, { a: 0, b: 0 });
253 | expect(listener).toHaveBeenNthCalledWith(2, { a: 1, b: 2 });
254 | });
255 |
256 | it('should produce distinct values with the custom keySelector', () => {
257 | const store1 = createStore(0);
258 | const store2 = createStore({ k: 0, value: 0 });
259 |
260 | const query = mergeQueries(
261 | [store1, store2],
262 | (a, b) => ({ a, b: b.value }),
263 | { distinct: { keySelector: (a) => a.a } },
264 | );
265 | const listener = jest.fn();
266 | query.value$.subscribe(listener);
267 |
268 | store2.set({ k: 1, value: 1 });
269 | store2.set({ k: 2, value: 2 });
270 | store1.set(1);
271 |
272 | expect(listener).toHaveBeenCalledTimes(2);
273 | expect(listener).toHaveBeenNthCalledWith(1, { a: 0, b: 0 });
274 | expect(listener).toHaveBeenNthCalledWith(2, { a: 1, b: 2 });
275 | });
276 |
277 | it('should return the same calculated value if there is a subscription and the source was not changed', async () => {
278 | const source1 = createStore(1);
279 | const source2 = createStore(2);
280 | const result = mergeQueries([source1, source2], (value1, value2) => ({
281 | value: value1 + value2,
282 | }));
283 |
284 | expect(result.get() === result.get()).toBe(false);
285 |
286 | let obj3;
287 | let obj4;
288 | const subscription1 = result.value$.subscribe((value) => (obj3 = value));
289 | const subscription2 = result.value$.subscribe((value) => (obj4 = value));
290 |
291 | expect(obj3 === obj4).toBe(true);
292 |
293 | const obj1 = result.get();
294 | const obj2 = result.get();
295 | expect(obj1 === obj2).toBe(true);
296 |
297 | expect(obj1 === obj3).toBe(true);
298 |
299 | subscription1.unsubscribe();
300 | expect(result.get() === obj1).toBe(true);
301 |
302 | subscription2.unsubscribe();
303 | expect(result.get() === obj1).toBe(false);
304 | });
305 | });
306 |
--------------------------------------------------------------------------------
/packages/rx-effects/src/query.ts:
--------------------------------------------------------------------------------
1 | import { Observable } from 'rxjs';
2 |
3 | /**
4 | * Provider for a value of a state.
5 | */
6 | export type Query = Readonly<{
7 | /** Returns the value of a state */
8 | get: () => T;
9 |
10 | /** `Observable` for value changes. */
11 | value$: Observable;
12 | }>;
13 |
14 | /**
15 | * Options for processing the query result
16 | *
17 | * @property distinct Enables distinct results
18 | * @property distinct.comparator Custom comparator for values. Strict equality `===` is used by default.
19 | * @property distinct.keySelector Getter for keys of values to compare. Values itself are used for comparing by default.
20 | */
21 | export type QueryOptions = Readonly<{
22 | distinct?:
23 | | boolean
24 | | {
25 | comparator?: (previous: K, current: K) => boolean;
26 | keySelector?: (value: T) => K;
27 | };
28 | }>;
29 |
--------------------------------------------------------------------------------
/packages/rx-effects/src/queryMappers.ts:
--------------------------------------------------------------------------------
1 | import { combineLatest, identity, Observable, shareReplay } from 'rxjs';
2 | import { distinctUntilChanged, map, tap } from 'rxjs/operators';
3 | import { DEFAULT_COMPARATOR } from './utils';
4 | import { Query, QueryOptions } from './query';
5 |
6 | /**
7 | * Creates a new `Query` which maps a source value by the provided mapping
8 | * function.
9 | *
10 | * @param query source query
11 | * @param mapper value mapper
12 | * @param options options for processing the result value
13 | */
14 | export function mapQuery(
15 | query: Query,
16 | mapper: (value: T) => R,
17 | options?: QueryOptions,
18 | ): Query {
19 | const { shareReplayWithRef, buffer } = createShareReplayWithRef();
20 |
21 | let value$ = query.value$.pipe(map(mapper));
22 | value$ = distinctValue(value$, options?.distinct).pipe(shareReplayWithRef);
23 |
24 | function get(): R {
25 | return buffer.ref ? buffer.ref.value : mapper(query.get());
26 | }
27 |
28 | return { get, value$ };
29 | }
30 |
31 | /**
32 | * Creates a new `Query` which takes the latest values from source queries
33 | * and merges them into a single value.
34 | *
35 | * @param queries source queries
36 | * @param merger value merger
37 | * @param options options for processing the result value
38 | */
39 | export function mergeQueries<
40 | Values extends unknown[],
41 | Result,
42 | ResultKey = Result,
43 | >(
44 | queries: [
45 | ...{
46 | [K in keyof Values]: Query;
47 | },
48 | ],
49 | merger: (...values: Values) => Result,
50 | options?: QueryOptions,
51 | ): Query {
52 | const { shareReplayWithRef, buffer } = createShareReplayWithRef();
53 |
54 | let value$ = combineLatest(queries.map((query) => query.value$)).pipe(
55 | map((values) => merger(...(values as Values))),
56 | );
57 |
58 | value$ = distinctValue(value$, options?.distinct).pipe(shareReplayWithRef);
59 |
60 | function get(): Result {
61 | if (buffer.ref) {
62 | return buffer.ref.value;
63 | }
64 |
65 | return merger(...(queries.map((query) => query.get()) as Values));
66 | }
67 |
68 | return { get, value$ };
69 | }
70 |
71 | function distinctValue(
72 | value$: Observable,
73 | distinct: QueryOptions['distinct'],
74 | ): Observable {
75 | if (distinct === false) {
76 | return value$;
77 | }
78 |
79 | const comparator =
80 | (distinct === true ? undefined : distinct?.comparator) ??
81 | DEFAULT_COMPARATOR;
82 |
83 | const keySelector =
84 | (distinct === true ? undefined : distinct?.keySelector) ??
85 | (identity as (value: T) => K);
86 |
87 | return value$.pipe(distinctUntilChanged(comparator, keySelector));
88 | }
89 |
90 | function createShareReplayWithRef() {
91 | const buffer: { ref?: { value: T } | undefined } = {};
92 |
93 | const shareReplayWithRef = (source$: Observable) =>
94 | source$.pipe(
95 | tap({
96 | next: (value) => (buffer.ref = { value }),
97 | unsubscribe: () => (buffer.ref = undefined),
98 | }),
99 | shareReplay({ bufferSize: 1, refCount: true }),
100 | );
101 |
102 | return { shareReplayWithRef, buffer };
103 | }
104 |
--------------------------------------------------------------------------------
/packages/rx-effects/src/scope.test.ts:
--------------------------------------------------------------------------------
1 | import { firstValueFrom, materialize, Subject, toArray } from 'rxjs';
2 | import { createAction } from './action';
3 | import { createScope } from './scope';
4 | import { createStore } from './store';
5 |
6 | describe('Scope', () => {
7 | describe('destroy()', () => {
8 | it('should unsubscribe all collected subscriptions', () => {
9 | const scope = createScope();
10 | const teardown = jest.fn();
11 |
12 | scope.add(teardown);
13 | scope.destroy();
14 |
15 | expect(teardown).toHaveBeenCalledTimes(1);
16 | });
17 | });
18 |
19 | describe('handleAction()', () => {
20 | it('should be able to unsubscribe the created effect from the action', async () => {
21 | const scope = createScope();
22 |
23 | const action = createAction();
24 | const handler = jest.fn((value) => value * 3);
25 |
26 | const effect = scope.handle(action, handler);
27 | scope.destroy();
28 |
29 | const resultPromise = firstValueFrom(effect.result$.pipe(materialize()));
30 | action(2);
31 |
32 | expect(await resultPromise).toEqual({ hasValue: false, kind: 'C' });
33 | });
34 | });
35 |
36 | describe('createEffect()', () => {
37 | it('should be able to unsubscribe the created effect from the action', async () => {
38 | const scope = createScope();
39 |
40 | const action = createAction();
41 | const handler = jest.fn((value) => value * 3);
42 |
43 | const effect = scope.createEffect(handler);
44 | effect.handle(action);
45 | scope.destroy();
46 |
47 | const resultPromise = firstValueFrom(effect.result$.pipe(materialize()));
48 | action(2);
49 |
50 | expect(await resultPromise).toEqual({ hasValue: false, kind: 'C' });
51 | });
52 | });
53 |
54 | describe('createController()', () => {
55 | it('should be able to unsubscribe the created controller', async () => {
56 | const scope = createScope();
57 |
58 | const destroy = jest.fn();
59 | scope.createController(() => ({ destroy }));
60 |
61 | scope.destroy();
62 | expect(destroy).toHaveBeenCalledTimes(1);
63 | });
64 | });
65 |
66 | describe('createStore()', () => {
67 | it('should be able to unsubscribe the created store', async () => {
68 | const scope = createScope();
69 |
70 | const store = scope.createStore(1);
71 | const valuePromise = firstValueFrom(store.value$.pipe(toArray()));
72 |
73 | store.set(2);
74 | scope.destroy();
75 | store.set(3);
76 | expect(await valuePromise).toEqual([1, 2]);
77 | });
78 | });
79 |
80 | describe('handleQuery()', () => {
81 | it('should be able to unsubscribe the created effect from the query', async () => {
82 | const store = createStore(1);
83 |
84 | const scope = createScope();
85 |
86 | const handler = jest.fn((value) => value * 3);
87 |
88 | const effect = scope.handle(store, handler);
89 |
90 | const resultPromise = firstValueFrom(
91 | effect.result$.pipe(materialize(), toArray()),
92 | );
93 | store.set(2);
94 |
95 | scope.destroy();
96 | store.set(3);
97 |
98 | expect(await resultPromise).toEqual([
99 | { hasValue: true, kind: 'N', value: 6 },
100 | { hasValue: false, kind: 'C' },
101 | ]);
102 | });
103 | });
104 |
105 | describe('subscribe()', () => {
106 | it('should be able to unsubscribe the created subscription from the observable', async () => {
107 | const subject = new Subject();
108 |
109 | const scope = createScope();
110 |
111 | const handler = jest.fn((value) => value * 3);
112 | scope.subscribe(subject, handler);
113 |
114 | subject.next(2);
115 |
116 | scope.destroy();
117 | subject.next(3);
118 |
119 | expect(handler).toHaveBeenCalledTimes(1);
120 | expect(handler).toHaveBeenLastCalledWith(2);
121 | expect(handler).toHaveLastReturnedWith(6);
122 | });
123 |
124 | it('should be subscribe an observer', async () => {
125 | const subject = new Subject();
126 |
127 | const scope = createScope();
128 |
129 | const handler = jest.fn((value) => value * 3);
130 | scope.subscribe(subject, {
131 | next: handler,
132 | });
133 |
134 | subject.next(2);
135 |
136 | scope.destroy();
137 | subject.next(3);
138 |
139 | expect(handler).toHaveBeenCalledTimes(1);
140 | expect(handler).toHaveBeenLastCalledWith(2);
141 | expect(handler).toHaveLastReturnedWith(6);
142 | });
143 | });
144 | });
145 |
--------------------------------------------------------------------------------
/packages/rx-effects/src/scope.ts:
--------------------------------------------------------------------------------
1 | import { Observable, Observer, Subscription, TeardownLogic } from 'rxjs';
2 | import { Action } from './action';
3 | import { Controller } from './controller';
4 | import { createEffect, Effect, EffectHandler, EffectOptions } from './effect';
5 | import { Query } from './query';
6 | import { createStore, Store, StoreOptions } from './store';
7 | import { AnyObject } from './utils';
8 |
9 | /**
10 | * A controller-like boundary for effects and business logic.
11 | *
12 | * `Scope` collects all subscriptions which are made by child entities and provides
13 | * `destroy()` method to unsubscribe from them.
14 | */
15 | export type Scope = Controller<{
16 | /**
17 | * Register subscription-like or teardown function to be called with
18 | * `destroy()` method.
19 | */
20 | add: (teardown: TeardownLogic) => void;
21 |
22 | /**
23 | * Creates a store which will be destroyed with the scope.
24 | *
25 | * @param initialState Initial state
26 | * @param options Parameters for the store
27 | */
28 | createStore(
29 | initialState: State,
30 | options?: StoreOptions,
31 | ): Store;
32 |
33 | /**
34 | * Creates a controller which will be destroyed with the scope.
35 | */
36 | createController: (
37 | factory: () => Controller