);
30 | }
31 |
32 | const meta: Meta = {
33 | title: 'Dynamic Context',
34 | component: App,
35 | parameters: {
36 | controls: { expanded: true },
37 | },
38 | };
39 |
40 | export default meta;
41 |
42 | const Template: Story = args => ;
43 |
44 | // By passing using the Args format for exported stories, you can control the props for a component for reuse in a test
45 | // https://storybook.js.org/docs/react/workflows/unit-testing
46 | export const Default = Template.bind({});
47 |
48 | Default.args = {
49 | color: 'blue',
50 | };
51 |
--------------------------------------------------------------------------------
/docs/api/reactive.reactive.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | [Home](./index.md) > [@pago/reactive](./reactive.md) > [reactive](./reactive.reactive.md)
4 |
5 | ## reactive() function
6 |
7 | Transforms an object into a tracked version. Changing the object returned from `reactive` will also change the original. All watchers and derived values will update. Access to `Object.keys` as well as checking for the existance of a key through the `in` operator will also be tracked.
8 |
9 | Signature:
10 |
11 | ```typescript
12 | export declare function reactive(initialValue: T): Store;
13 | ```
14 |
15 | ## Parameters
16 |
17 | | Parameter | Type | Description |
18 | | --- | --- | --- |
19 | | initialValue | T | The underlying object |
20 |
21 | Returns:
22 |
23 | [Store](./reactive.store.md)<T>
24 |
25 | ## Remarks
26 |
27 | When a tracked object is destructed, all tracking information is lost. Instead of destructuring a `reactive` object, you need to first convert it with [toRefs()](./reactive.torefs.md).
28 |
29 | ## Example
30 |
31 | Original object is mutated when the reactive object is mutated.
32 |
33 | ```js
34 | const originalState = { message: 'hello' };
35 | const state = reactive(originalState);
36 | state.message = 'ciao';
37 | console.log(originalState.message); // => 'ciao'
38 |
39 | ```
40 |
41 |
--------------------------------------------------------------------------------
/stories/AdvancedCounter.stories.tsx:
--------------------------------------------------------------------------------
1 | /** @jsxImportSource ../src */
2 | import { Meta, Story } from '@storybook/react';
3 | import {r, ref} from '../src';
4 |
5 | interface Props {
6 | step: number;
7 | }
8 |
9 | function useCounterViewModel(props: Props) {
10 | const count = ref(0);
11 |
12 | return {
13 | get count() {
14 | return count.current;
15 | },
16 | increment() {
17 | count.current += props.step;
18 | },
19 | decrement() {
20 | count.current -= props.step;
21 | },
22 | };
23 | }
24 |
25 | function Counter(props: Props) {
26 | const counterModel = useCounterViewModel(props);
27 |
28 | return r(() => (
29 |
30 |
Count: {counterModel.count}
31 |
32 |
35 |
38 |
39 |
40 | ));
41 | }
42 |
43 | const meta: Meta = {
44 | title: 'Advanced Counter',
45 | component: Counter,
46 | parameters: {
47 | controls: { expanded: true },
48 | },
49 | };
50 |
51 | export default meta;
52 |
53 | const Template: Story = args => ;
54 |
55 | // By passing using the Args format for exported stories, you can control the props for a component for reuse in a test
56 | // https://storybook.js.org/docs/react/workflows/unit-testing
57 | export const Default = Template.bind({});
58 |
59 | Default.args = {
60 | step: 1,
61 | };
62 |
--------------------------------------------------------------------------------
/docs/adr/0002-subscription-controller.md:
--------------------------------------------------------------------------------
1 | # Subscription Controller
2 |
3 | Status: Accepted
4 |
5 | ## Problem definition
6 | React, especially with Concurrent Mode, enforces a pure, side effect free, rendering behaviour from its components.
7 | This conflicts heavily with our need to subscribe to state changes, caused by calls to `observe` or our actual render function. Both of which need to be executed immediately.
8 |
9 | ## Solution
10 | We have introduced a `SubscriptionController` class which is used by both `memoize` and `observe` to avoid registration of any observers on their own. Instead, all used `Tag`s are registered with the `SubscriptionController`. However, while they are registered, they are not actually subscribed to.
11 |
12 | Instead, we leave control over registration to our `ReactiveComponent`, which registers subscriptions during a `useEffect` callback and thus at a safe point in time when the component has been committed already.
13 |
14 | Because the `SubscriptionController` holds a reference to the `Tag` but no the other way around, memory should be freed eventually in case a component is discarded before being committed.
15 |
16 | There is a chance an `observe`d effect has gone stale between the time when we initially executed it, and the actual subscription (example: a sibling of a suspended component) being made during the commit phase. Thus, whenever we `subscribe` on a `SubscriptionController`, it will validate whether the effect needs to be run again and, if so, will execute it.
17 |
18 | ## Summary
19 | - Avoids impure render functions (by avoiding eager subscriptions)
--------------------------------------------------------------------------------
/stories/NonReactiveCounter.stories.tsx:
--------------------------------------------------------------------------------
1 | import { useRef } from 'react';
2 | import { Meta, Story } from '@storybook/react';
3 | import { useRefValue, ref } from '../src';
4 |
5 | interface Props {
6 | step: number;
7 | count: any; // TODO: Need a way to properly export & import types
8 | }
9 |
10 | const Counter = function Counter(props: Props) {
11 | const count = useRefValue(props.count);
12 |
13 | return (
14 |
15 |
Count: {count}
16 |
17 |
23 |
29 |
30 |
31 | );
32 | };
33 |
34 | function App(props: Props) {
35 | const count = useRef(ref(0)); // little bit of inception here... :)
36 | return ;
37 | }
38 |
39 | const meta: Meta = {
40 | title: 'NonReactiveCounter',
41 | component: App,
42 | parameters: {
43 | controls: { expanded: true },
44 | },
45 | };
46 |
47 | export default meta;
48 |
49 | const Template: Story = args => ;
50 |
51 | // By passing using the Args format for exported stories, you can control the props for a component for reuse in a test
52 | // https://storybook.js.org/docs/react/workflows/unit-testing
53 | export const Default = Template.bind({});
54 |
55 | Default.args = {
56 | step: 1,
57 | };
58 |
--------------------------------------------------------------------------------
/docs/api/reactive.wrap.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | [Home](./index.md) > [@pago/reactive](./reactive.md) > [wrap](./reactive.wrap.md)
4 |
5 | ## wrap() function
6 |
7 | Converts a Reactive Function Component into a React Function Component. A Reactive Function Component returns a render function which is automatically tracked. If none of its input values have changed, the `render` function will not execute during consequitive renderings of the component. Instead, the old virtual DOM tree will be returned, enabling frameworks like React and Preact to bail out of rendering early on.
8 |
9 | It is usually a better developer experience to configure your Build tool to use `@pago/reactive` as the `@jsxImportSource` or the `@jsxFactory`.
10 |
11 | Signature:
12 |
13 | ```typescript
14 | export declare function wrap(construct: (props: T) => RenderFunction | RenderResult): {
15 | (props: T): RenderResult;
16 | displayName: any;
17 | };
18 | ```
19 |
20 | ## Parameters
21 |
22 | | Parameter | Type | Description |
23 | | --- | --- | --- |
24 | | construct | (props: T) => RenderFunction \| RenderResult | A Reactive Function Component |
25 |
26 | Returns:
27 |
28 | { (props: T): RenderResult; displayName: any; }
29 |
30 | ## Remarks
31 |
32 | When given a standard React Function component, it will notice that it isn't a Reactive Function Component and bail out without causing significant overhead. Thus you don't really need to care about whether you are using it with a React Function Component or a Reactive Function Component.
33 |
34 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | // see https://www.typescriptlang.org/tsconfig to better understand tsconfigs
3 | "include": ["src", "types"],
4 | "compilerOptions": {
5 | "target": "ES2018",
6 | "module": "esnext",
7 | "lib": ["dom", "esnext"],
8 | "importHelpers": true,
9 | // output .d.ts declaration files for consumers
10 | "declaration": true,
11 | // output .js.map sourcemap files for consumers
12 | "sourceMap": true,
13 | // match output dir to input dir. e.g. dist/index instead of dist/src/index
14 | "rootDir": "./src",
15 | // stricter type-checking for stronger correctness. Recommended by TS
16 | "strict": true,
17 | // linter checks for common issues
18 | "noImplicitReturns": true,
19 | "noFallthroughCasesInSwitch": true,
20 | // noUnused* overlap with @typescript-eslint/no-unused-vars, can disable if duplicative
21 | "noUnusedLocals": true,
22 | "noUnusedParameters": true,
23 | // use Node's module resolution algorithm, instead of the legacy TS one
24 | "moduleResolution": "node",
25 | // transpile JSX to React.createElement
26 | "jsx": "react",
27 | "jsxFactory": "createElement",
28 | // interop between ESM and CJS modules. Recommended by TS
29 | "esModuleInterop": true,
30 | // significant perf increase by skipping checking .d.ts files, particularly those in node_modules. Recommended by TS
31 | "skipLibCheck": true,
32 | // error out if import and file system have a casing mismatch. Recommended by TS
33 | "forceConsistentCasingInFileNames": true,
34 | // `tsdx build` ignores this option, but it is commonly used when type-checking separately with `tsc`
35 | "noEmit": true
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/docs/api/reactive.subscriptioncontroller.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | [Home](./index.md) > [@pago/reactive](./reactive.md) > [SubscriptionController](./reactive.subscriptioncontroller.md)
4 |
5 | ## SubscriptionController class
6 |
7 | Manages the subscription to tracked references and objects within a `memoized` function.
8 |
9 | Signature:
10 |
11 | ```typescript
12 | export declare class SubscriptionController
13 | ```
14 |
15 | ## Constructors
16 |
17 | | Constructor | Modifiers | Description |
18 | | --- | --- | --- |
19 | | [(constructor)(effect)](./reactive.subscriptioncontroller._constructor_.md) | | Creates a new SubscriptionController. |
20 |
21 | ## Properties
22 |
23 | | Property | Modifiers | Type | Description |
24 | | --- | --- | --- | --- |
25 | | [cleanup?](./reactive.subscriptioncontroller.cleanup.md) | | [Effect](./reactive.effect.md) | (Optional) A cleanup effect that should be executed before the effect is executed again or on unsubscribe. |
26 | | [effect](./reactive.subscriptioncontroller.effect.md) | | [Effect](./reactive.effect.md) | The effect that is triggered whenever a tracked value changes after the controller has subscribed to changes. |
27 |
28 | ## Methods
29 |
30 | | Method | Modifiers | Description |
31 | | --- | --- | --- |
32 | | [subscribe()](./reactive.subscriptioncontroller.subscribe.md) | | Subscribes to the set of tracked references and objects. Once subscribed, the [SubscriptionController.effect](./reactive.subscriptioncontroller.effect.md) will be triggered whenever any of the values change. |
33 | | [unsubscribe()](./reactive.subscriptioncontroller.unsubscribe.md) | | Unsubscribes from all tracked values. |
34 |
35 |
--------------------------------------------------------------------------------
/test/utils.test.ts:
--------------------------------------------------------------------------------
1 | import { reactive, memoize } from '../src';
2 | import { mergePropsIntoReactive } from '../src/utils';
3 |
4 | describe('mergePropsIntoReactive', () => {
5 | test('updates all values in props', () => {
6 | const props = reactive({
7 | hello: 'world',
8 | });
9 | const serialize = memoize(() => {
10 | return JSON.stringify(props);
11 | });
12 | expect(serialize()).toEqual(JSON.stringify({ hello: 'world' }));
13 | mergePropsIntoReactive(props, {
14 | hello: 'universe',
15 | });
16 | expect(serialize()).toEqual(JSON.stringify({ hello: 'universe' }));
17 | });
18 |
19 | test('inserts new values into props', () => {
20 | const props = reactive<{ hello: string; message?: string }>({
21 | hello: 'world',
22 | });
23 | const serialize = memoize(() => {
24 | return JSON.stringify(props);
25 | });
26 | expect(serialize()).toEqual(JSON.stringify({ hello: 'world' }));
27 | mergePropsIntoReactive(props, {
28 | hello: 'universe',
29 | message: 'hello',
30 | });
31 | expect(serialize()).toEqual(
32 | JSON.stringify({ hello: 'universe', message: 'hello' })
33 | );
34 | });
35 |
36 | test('removes old values from props', () => {
37 | const props = reactive<{ hello: string; message?: string }>({
38 | hello: 'world',
39 | message: 'hello',
40 | });
41 | const serialize = memoize(() => {
42 | return JSON.stringify(props);
43 | });
44 | expect(serialize()).toEqual(
45 | JSON.stringify({ hello: 'world', message: 'hello' })
46 | );
47 | mergePropsIntoReactive(props, {
48 | hello: 'world',
49 | });
50 | expect(serialize()).toEqual(JSON.stringify({ hello: 'world' }));
51 | });
52 |
53 | test('does not recalculate if values are unchanged', () => {
54 | const props = reactive({
55 | hello: 'world',
56 | });
57 | const fn = memoize(() => {
58 | return { message: props.hello };
59 | });
60 | const firstValue = fn();
61 | const secondValue = fn();
62 | expect(firstValue).toBe(secondValue);
63 | mergePropsIntoReactive(props, {
64 | hello: 'world',
65 | });
66 | const thirdValue = fn();
67 | expect(firstValue).toBe(thirdValue);
68 | });
69 | });
70 |
--------------------------------------------------------------------------------
/docs/api/reactive.watcheffect.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | [Home](./index.md) > [@pago/reactive](./reactive.md) > [watchEffect](./reactive.watcheffect.md)
4 |
5 | ## watchEffect() function
6 |
7 | Executes the given effect immediately and tracks any used values. When any of them change, it will execute the effect again. If a `teardown` function has been registered through the `onInvalidate` param, it will be executed before the effect is executed again, allowing for cleanup.
8 |
9 | Signature:
10 |
11 | ```typescript
12 | export declare function watchEffect(fn: (onInvalidate: (teardown: Effect) => void) => void): Effect;
13 | ```
14 |
15 | ## Parameters
16 |
17 | | Parameter | Type | Description |
18 | | --- | --- | --- |
19 | | fn | (onInvalidate: (teardown: [Effect](./reactive.effect.md)) => void) => void | The effect that should be executed when any of the tracked values change. |
20 |
21 | Returns:
22 |
23 | [Effect](./reactive.effect.md)
24 |
25 | ## Remarks
26 |
27 | When using this function within a Reactive Component, make sure to not rely on any custom `teardown` logic.
28 |
29 | When this function is used within a Reactive Component, the tracking will be bound to the components lifecycle. It is, therefore, save to use and can be considered side effect free (and thus React Concurrent Mode compatible). However, there are circumstances that might cause a custom `teardown` function to not be invoked.
30 |
31 | For example, if your component has been rendered but not committed (written to the DOM) then React reserves the right to throw it away without invoking any cleanup logic.
32 |
33 | ```js
34 | // DO NOT DO THIS
35 | import { watchEffect, ref } from '@pago/reactive';
36 | function Surprise(props) {
37 | const message = ref('Wait for it...');
38 | watchEffect(onInvalidate => {
39 | // This timer will never be cleared
40 | // if the component is not unmounted
41 | // or during server side rendering
42 | // You should use `effect` instead
43 | const token = setTimeout(() => {
44 | message.current = 'Hello World!'
45 | }, props.delay); // props.delay is watched
46 | onInvalidate(() => clearTimeout(token));
47 | });
48 | return () =>
{message.current}
49 | }
50 |
51 | ```
52 |
53 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "0.2.0",
3 | "license": "MIT",
4 | "main": "dist/index.js",
5 | "typings": "dist/index.d.ts",
6 | "files": [
7 | "dist",
8 | "src",
9 | "jsx-runtime"
10 | ],
11 | "engines": {
12 | "node": ">=10"
13 | },
14 | "scripts": {
15 | "start": "tsdx watch",
16 | "build": "tsdx build && yarn docs:extract && yarn docs:generate",
17 | "test": "tsdx test --passWithNoTests",
18 | "lint": "tsdx lint",
19 | "docs:extract": "api-extractor run --local",
20 | "docs:generate": "api-documenter markdown -i ./temp -o ./docs/api",
21 | "prepare": "tsdx build",
22 | "size": "size-limit",
23 | "analyze": "size-limit --why",
24 | "storybook": "start-storybook -p 6006",
25 | "build-storybook": "build-storybook",
26 | "release": "yarn test && yarn build && npm publish --access public"
27 | },
28 | "peerDependencies": {
29 | "react": ">=17"
30 | },
31 | "husky": {
32 | "hooks": {
33 | "pre-commit": "yarn lint"
34 | }
35 | },
36 | "prettier": {
37 | "printWidth": 80,
38 | "semi": true,
39 | "singleQuote": true,
40 | "trailingComma": "es5"
41 | },
42 | "name": "@pago/reactive",
43 | "author": "Patrick Gotthardt",
44 | "module": "dist/reactive.esm.js",
45 | "size-limit": [
46 | {
47 | "path": "dist/reactive.cjs.production.min.js",
48 | "limit": "10 KB"
49 | },
50 | {
51 | "path": "dist/reactive.esm.js",
52 | "limit": "10 KB"
53 | }
54 | ],
55 | "devDependencies": {
56 | "@babel/core": "^7.12.7",
57 | "@microsoft/api-documenter": "^7.11.0",
58 | "@microsoft/api-extractor": "^7.12.0",
59 | "@size-limit/preset-small-lib": "^4.9.0",
60 | "@storybook/addon-essentials": "^6.1.2",
61 | "@storybook/addon-info": "^5.3.21",
62 | "@storybook/addon-links": "^6.1.2",
63 | "@storybook/addons": "^6.1.2",
64 | "@storybook/react": "^6.1.2",
65 | "@types/react": "^17.0.0",
66 | "@types/react-dom": "^17.0.0",
67 | "babel-loader": "^8.2.1",
68 | "husky": "^4.3.0",
69 | "prettier": "^1.19.1",
70 | "react": "^17.0.1",
71 | "react-dom": "^17.0.1",
72 | "react-is": "^17.0.1",
73 | "size-limit": "^4.9.0",
74 | "tsdx": "^0.14.1",
75 | "tslib": "^2.0.3",
76 | "typescript": "^4.1.2"
77 | },
78 | "dependencies": {},
79 | "resolutions": {
80 | "**/typescript": "^4.1.2",
81 | "**/@typescript-eslint/eslint-plugin": "^4.6.1",
82 | "**/@typescript-eslint/parser": "^4.6.1",
83 | "**/ts-jest": "^26.4.4",
84 | "**/jest": "^26.6.3"
85 | }
86 | }
87 |
--------------------------------------------------------------------------------
/docs/adr/0003-no-readable.md:
--------------------------------------------------------------------------------
1 | # No (Svelte-inspired) `readable` store API
2 |
3 | ## Context
4 |
5 | Svelte offers a very interesting `readable` API that could work with this library.
6 | An implementation might look similar to this:
7 |
8 | ```ts
9 | export function readable(initialValue: T, updater?: Updater) {
10 | const value = ref(initialValue);
11 | updater?.((newValue: T) => {
12 | value.current = newValue;
13 | });
14 | return {
15 | get current() {
16 | return value.current;
17 | },
18 | };
19 | }
20 | ```
21 |
22 | The idea behind it is that it would provide a readonly way to having changing content. Similar to what an Observable would provide.
23 |
24 | One of the major questions, however, is whether this API would be beneficial or whether we should aim for something else.
25 |
26 | ## Use Cases
27 |
28 | ### Readonly values
29 |
30 | `readable` restricts the API to allow only readonly access and thus allows the creation of a safer API surface.
31 |
32 | However, the same can be achieved by using a `ReadonlyRef`. It's trivial to implement and we might provide a conversion function for it out of the box.
33 |
34 | ```ts
35 | function readonly(ref: Ref): ReadonlyRef {
36 | return derived(() => ref.current);
37 | }
38 | ```
39 |
40 | A function like this enables the generic conversion of all types of `Ref` values to readonly variants. It offers a similar developer experience but without introducing new concepts:
41 |
42 | ```js
43 | function getImportantValue() {
44 | const v = ref(0);
45 | effect(onInvalidate => {
46 | const handle = setTimeout(() => {
47 | v.current = 42;
48 | }, 1000);
49 | onInvalidate(() => clearTimeout(handle));
50 | });
51 | return readonly(v);
52 | }
53 | ```
54 |
55 | ### Async Values
56 |
57 | We might want to use a `readable` when loading data asynchronous (either once or through polling).
58 |
59 | ```js
60 | const userId = ref(null);
61 | const user = readable(null, set => {
62 | effect(async onInvalidate => {
63 | const controller = new AbortController();
64 | onInvalidate(() => controller.abort());
65 | if (userId.current) {
66 | set(await getCurrentUser(userId.current, controller.signal));
67 | }
68 | });
69 | });
70 | ```
71 |
72 | This usage is problematic for three reasons:
73 |
74 | 1. The canonical way to deal with async resources in React is to leverage Suspense, not `null` values
75 | 2. We need to use a separate `effect` to observe and react to outside values, causing multiple levels of nesting
76 |
77 | ## Decision
78 |
79 | While the `readable` API on its own offers a very nice functionality, it does not add enough to make up for the required learning effort as it does not blend in
80 | well enough with the framework.
81 |
--------------------------------------------------------------------------------
/stories/Suspense.stories.tsx:
--------------------------------------------------------------------------------
1 | /** @jsxImportSource ../src */
2 | import { Suspense } from 'react';
3 | import { Meta, Story } from '@storybook/react';
4 |
5 | import {effect, r, ref, watchEffect} from '../src';
6 |
7 | interface Props {}
8 |
9 | function getSuspendedValue() {
10 | const { resolve, signal } = delay();
11 | let value: string;
12 | let isResolved = false;
13 |
14 | setTimeout(() => {
15 | value = 'Hello World';
16 | isResolved = true;
17 | resolve();
18 | }, 1000);
19 |
20 | return {
21 | get current() {
22 | if (!isResolved) {
23 | throw signal;
24 | }
25 | return value;
26 | },
27 | };
28 | }
29 |
30 | function App() {
31 | const message = getSuspendedValue();
32 | const announcement = ref(`"Random" message of the day...`);
33 |
34 | effect(function startTimer() {
35 | setTimeout(() => {
36 | announcement.current = 'Halfway there...';
37 | }, 500);
38 | });
39 |
40 | return r(() => (
41 |
42 |
{announcement.current}
43 | ...wait for it.}>
44 |
45 |
46 |
47 |
48 | ...wait for the loud version it.}>
49 |
50 |
51 |
212 | );
213 | }
214 |
--------------------------------------------------------------------------------
/src/component.ts:
--------------------------------------------------------------------------------
1 | import {
2 | useEffect,
3 | useRef,
4 | useState,
5 | useContext,
6 | MutableRefObject,
7 | Context,
8 | ReactElement,
9 | } from 'react';
10 | import { reactive, ReadonlyRef, Ref, ref } from './reactive';
11 | import { mergePropsIntoReactive } from './utils';
12 | import {
13 | memoize,
14 | collectSubscriptions,
15 | SubscriptionController,
16 | watchEffect,
17 | } from './tag';
18 |
19 | type RenderResult = ReactElement | null;
20 | type RenderFunction = () => RenderResult;
21 |
22 | type Effect = () => void;
23 |
24 | let currentEffects: undefined | Array;
25 | /**
26 | * Converts a Reactive Function Component into a React Function Component.
27 | * A Reactive Function Component returns a render function which is automatically tracked. If none of its input values have changed,
28 | * the `render` function will not execute during consequitive renderings of the component. Instead, the old virtual DOM tree will be returned,
29 | * enabling frameworks like React and Preact to bail out of rendering early on.
30 | *
31 | * It is usually a better developer experience to configure your Build tool to use `@pago/reactive` as the `@jsxImportSource`
32 | * or the `@jsxFactory`.
33 | *
34 | * @remarks
35 | * When given a standard React Function component, it will notice that it isn't a Reactive Function Component and bail out without causing significant overhead.
36 | * Thus you don't really need to care about whether you are using it with a React Function Component or a Reactive Function Component.
37 | *
38 | * @param construct - A Reactive Function Component
39 | * @public
40 | */
41 | export function wrap(
42 | construct: (props: T) => RenderFunction | RenderResult
43 | ) {
44 | function ReactiveComponent(props: T): RenderResult {
45 | const isReactiveComponent = useRef(true);
46 | const [, forceRender] = useState(0);
47 | const reactiveProps = useRef() as MutableRefObject;
48 | const render = useRef() as MutableRefObject;
49 | const subscriptions = useRef() as MutableRefObject<
50 | Array
51 | >;
52 | const hooks = useRef([] as Array);
53 | const [subscriptionController] = useState(
54 | () =>
55 | new SubscriptionController(function dependenciesInvalidated() {
56 | forceRender(x => x + 1);
57 | })
58 | );
59 |
60 | useEffect(() => {
61 | subscriptionController.subscribe();
62 | subscriptions.current.forEach(controller => controller.subscribe());
63 | return () => {
64 | subscriptionController.unsubscribe();
65 | subscriptions.current.forEach(controller => controller.unsubscribe());
66 | };
67 | }, [subscriptionController, subscriptions]);
68 |
69 | if (!isReactiveComponent.current) {
70 | return construct(props) as RenderResult;
71 | }
72 |
73 | if (!reactiveProps.current) {
74 | reactiveProps.current = reactive(Object.assign({}, props));
75 | } else {
76 | mergePropsIntoReactive(reactiveProps.current, props);
77 | }
78 |
79 | if (!render.current) {
80 | subscriptions.current = collectSubscriptions(() => {
81 | const oldEffects = currentEffects;
82 | currentEffects = hooks.current;
83 | try {
84 | const doRender = construct(reactiveProps.current);
85 | if (typeof doRender !== 'function') {
86 | isReactiveComponent.current = false;
87 | render.current = () => doRender;
88 | } else {
89 | render.current = memoize(doRender, subscriptionController);
90 | }
91 | } finally {
92 | currentEffects = oldEffects;
93 | }
94 | });
95 | } else {
96 | // during initial construction all contexts will have an up to date value anyways
97 | // but when we are re-rendering the context values might be stale
98 | hooks.current.forEach(fn => fn());
99 | }
100 |
101 | return render.current();
102 | }
103 | ReactiveComponent.displayName =
104 | (construct as any).displayName || construct.name;
105 | return ReactiveComponent;
106 | }
107 |
108 | /**
109 | * Sometimes your components will need to initiate side effects to start fetching data, etc.
110 | * This function enables you to implement that behaviour. The provided `effect` will be observed
111 | * and will run automatically whenever any of its tracked values change.
112 | * It will automatically be invalidated when the component is unmounted.
113 | *
114 | * The function passed into `effect` will behave similarly to one that is passed to Reacts `useEffect`
115 | * in that it won't be executed during server side rendering.
116 | *
117 | * @param fn - An effect that should be run after the component has been mounted.
118 | * @public
119 | */
120 | export function effect(fn: (onInvalidate: (teardown: Effect) => void) => void) {
121 | fromHook(function MyEffect() {
122 | useEffect(() => watchEffect(fn), []);
123 | });
124 | }
125 |
126 | /**
127 | * The function passed to `fromHook` will always be executed when rendering the component.
128 | *
129 | * @example
130 | * ```
131 | * const screenSize = fromHook(() => useScreenSize());
132 | * effect(() => console.log(screenSize.current));
133 | * ```
134 | *
135 | * @param fn - A callback that uses React Hooks to calculate an observed value.
136 | * @public
137 | */
138 | export function fromHook(fn: () => T): Ref {
139 | if (!currentEffects) {
140 | throw new Error(`Tried to execute a hook when not within a component.`);
141 | }
142 | const value = ref(fn());
143 | currentEffects.push(() => (value.current = fn()));
144 | return value;
145 | }
146 |
147 | /**
148 | * Injects a React.Context into a Reactive Function Component.
149 | * @param context - The React.Context that should be injected into your component.
150 | * @public
151 | */
152 | export function inject(context: Context): ReadonlyRef {
153 | return fromHook(() => useContext(context));
154 | }
155 |
156 | /**
157 | * This function is a pure type-cast to avoid TypeScript from complaining when using
158 | * a Reactive Function Component without {@link wrap}.
159 | * @param render - The render function of a component
160 | * @public
161 | */
162 | export function r(render: () => JSX.Element) {
163 | return (render as unknown) as JSX.Element;
164 | }
165 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # @pago/reactive
2 |
3 | [Introduction](./docs/index.md) | [API Docs](./docs/api/reactive.md) | [CodeSandbox](https://codesandbox.io/s/pagoreactive-playground-zx34h) | [Next.js Example](./examples/nextjs/) | [Examples](./stories)
4 |
5 | You are using React or Preact but find yourself frustrated by continuous bugs, errors or ceremony caused by
6 | the Hooks API? You thought you could avoid using a separate state management library like Redux, Recoil or MobX
7 | but started to run into unexpected performance issues with the Context API?
8 |
9 | Then this library will eventually be the one for you! A reactive component model on top of React and Preact
10 | with automatic performance optimizations and a simple and predictable API that gets out of your way and supports
11 | you in achieving your goals. Blatantly copied from the fantastic Vue Composition API. But for React / Preact.
12 |
13 | Huh? Eventually? Oh yes, this thing is bleeding cutting edge and likely to cause you all kinds of pain right now.
14 | Please don't use this in production. We are looking for feedback and observations from experiments you run.
15 | We fully expect to change major parts of the API in various different ways while we try to find the right set
16 | of primitives and abstractions to have a good balance between power and ease of learning.
17 |
18 | If you would like to play around with the library:
19 |
20 | - [Read the Introduction](./docs/index.md)
21 | - [CodeSandbox Template](https://codesandbox.io/s/pagoreactive-playground-zx34h)
22 | - [Next.js Integration](./examples/nextjs/)
23 |
24 | ## Project Plan
25 |
26 | We are roughly following planning to go through the following steps:
27 |
28 | - [x] Make it work
29 | - [ ] Make it good (<-- we are here)
30 | - [ ] Stable release
31 | - [ ] Make it fast
32 | - [ ] Make it small
33 |
34 | ## Current State of the Project
35 |
36 | - [x] Works with Preact & React
37 | - [x] Very little boilerplate on top of React (JS: none, TS: minimal `r`)
38 | - [x] Observable values
39 | - [x] Efficient derived values
40 | - [x] Works with Suspense
41 | - [x] Works with React.Context (through `inject`)
42 | - [x] Concurrent Mode Safe (!) (as far as I can see, Expert review would be great)
43 | - [x] Reuse your existing Hooks in a Reactive Component through `fromHook`
44 | - [x] Reuse `ref` values in Hooks components through `useRefValue`
45 | - [x] Doesn't show any wrapper components in React DevTools
46 | - [x] Perfect for incremental adoption into existing projects (use the pragma comment for per-file adoption)
47 | - [ ] TypeScript: Do we really need `r`? Can we adapt the `JSX.Element['type']` property to include our kind of components?
48 | - [ ] Lifecycle callbacks (do we really need them? All can be replicated in user-land if needed)
49 | - [ ] Rx.js interop? Useful? How do we handle subscriptions?
50 | - [ ] Optimized Preact implementation (by tapping into its plugin API)
51 | - [ ] Documentation
52 | - [ ] Consistent naming of things (so far copied Vue API for a lot of things - do the names match & make sense in this context?)
53 | - [ ] Optimization (Performance & Code Size)
54 |
55 | ## Examples
56 |
57 | ### A Counter component
58 |
59 | ```jsx
60 | /** @jsxImportSource @pago/reactive */
61 | import { ref } from '@pago/reactive';
62 |
63 | function Counter(props) {
64 | const count = ref(0);
65 |
66 | return () => (
67 |
109 | ));
110 | }
111 | ```
112 |
113 | ## Setup
114 |
115 | The easiest way to setup `@pago/reactive` for either React or Preact is to leverage the new `jsxImportSource` option and to set it to `@pago/reactive`.
116 |
117 | Requirements:
118 |
119 | - React 17 or later
120 | - or Preact (todo: insert correct version)
121 | - Babel (todo: insert correct version)
122 | - or TypeScript (todo: insert correct version)
123 |
124 | ### Per file
125 |
126 | Specifying `@pago/reactive` as the JSX factory can be done using a comment at the beginning of the file. This should be supported by Babel & TypeScript.
127 |
128 | ```js
129 | /** @jsxImportSource @pago/reactive */
130 | ```
131 |
132 | ### Babel
133 |
134 | As specified in [the babel documentation](https://babeljs.io/docs/en/babel-plugin-transform-react-jsx):
135 |
136 | ```json
137 | {
138 | "plugins": [
139 | [
140 | "@babel/plugin-transform-react-jsx",
141 | {
142 | "runtime": "automatic",
143 | "importSource": "@pago/reactive"
144 | }
145 | ]
146 | ]
147 | }
148 | ```
149 |
150 | ## Q & A
151 |
152 | ### Is this ready for production?
153 |
154 | Not yet.
155 |
156 | ### Why `ref().current` instead of `ref().value`?
157 |
158 | Because it allows us to do this:
159 |
160 | ```jsx
161 | import { ref, effect } from '@pago/reactive';
162 |
163 | function CounterComponent() {
164 | const el = ref();
165 | effect(function updateDOMManually() {
166 | el.current.innerHTML = 'Hello World';
167 | });
168 | return () => ;
169 | }
170 | ```
171 |
172 | ### Why does TypeScript complain about components not being components?
173 |
174 | When you try to use a component like the one below with TypeScript in JSX, it'll inform you that
175 | `() => Element` is not a valid type for a JSX Element.
176 |
177 | ```tsx
178 | import { ref, effect } from '@pago/reactive';
179 |
180 | function CounterComponent() {
181 | const el = ref();
182 | effect(function updateDOMManually() {
183 | el.current.innerHTML = 'Hello World';
184 | });
185 | return () => ;
186 | }
187 | ```
188 |
189 | For the time being we don't have a better solution than to use the provided `r` function, which is basically
190 | a type cast that fakes the right type to make TypeScript happy.
191 |
192 | ```tsx
193 | import { r, ref, observe } from '@pago/reactive';
194 |
195 | function CounterComponent() {
196 | const el = ref();
197 | observe(function updateDOMManually() {
198 | // `observe` is currently invoked immediately, rather than at the next tick
199 | // not sure if that behaviour is better or worse than delaying it a bit
200 | if (!el.current) return;
201 | el.current.innerHTML = 'Hello World';
202 | });
203 | return r(() => );
204 | }
205 | ```
206 |
207 | An alternative would be to use the `wrap` function explicitly.
208 |
209 | ```tsx
210 | import { wrap, ref, effect } from '@pago/reactive';
211 | const CounterComponent = wrap(function CounterComponent() {
212 | const el = ref();
213 | effect(function updateDOMManually() {
214 | el.current.innerHTML = 'Hello World';
215 | });
216 | return () => ;
217 | });
218 | ```
219 |
--------------------------------------------------------------------------------
/src/reactive.ts:
--------------------------------------------------------------------------------
1 | import { createTag, consumeTag, dirtyTag, Tag, memoize } from './tag';
2 |
3 | /**
4 | * A tracked reference to a value. Reading it from it should mark it
5 | * as "read" in the current scope, writing to it should mark it as dirty.
6 | *
7 | * When a `Ref` is marked as dirty, any watcher or derivative will eventually
8 | * be updated to its new value.
9 | *
10 | * Note that it is not possible to read and update a ref within the same tracked scope.
11 | * @public
12 | */
13 | export interface Ref extends ReadonlyRef {
14 | current: T;
15 | }
16 |
17 | /**
18 | * A tracked reference to a value that can't be modified.
19 | * @public
20 | */
21 | export interface ReadonlyRef {
22 | readonly current: T;
23 | }
24 |
25 | /**
26 | * An object with only {@link RefObject} values.
27 | * @public
28 | */
29 | export type RefContainer = {
30 | readonly [P in keyof T]: RefObject;
31 | };
32 |
33 | /**
34 | * An Ref object that supports reading & writing in the same tracked scope
35 | * by providing a specific {@link RefObject.update} method.
36 | * @public
37 | */
38 | export interface RefObject extends Ref {
39 | update(fn: (value: T) => T): void;
40 | }
41 |
42 | /**
43 | * An object that inlines all {@link Ref} values and enables using them transparently.
44 | * @public
45 | */
46 | export type Store = {
47 | [P in keyof T]: T[P] extends Ref ? T[P]['current'] : T[P];
48 | };
49 |
50 | /**
51 | * Creates a new tracked reference value.
52 | * @param initialValue - The initial value of the reference
53 | * @public
54 | */
55 | export function ref(initialValue: T): RefObject {
56 | const tag = createTag();
57 | let value = initialValue;
58 | const self = {
59 | get current() {
60 | consumeTag(tag);
61 | return value;
62 | },
63 | set current(newValue) {
64 | if (!Object.is(value, newValue)) {
65 | dirtyTag(tag);
66 | }
67 | value = newValue;
68 | },
69 | update(fn: (value: T) => T) {
70 | self.current = fn(value);
71 | },
72 | };
73 | return self;
74 | }
75 |
76 | function isRefLike(candidate: any): candidate is RefObject {
77 | return (
78 | candidate &&
79 | typeof candidate === 'object' &&
80 | 'current' in candidate &&
81 | 'update' in candidate
82 | );
83 | }
84 |
85 | const updateProxy = Symbol('updateProxy');
86 | interface UpdateableStore {
87 | [updateProxy](prop: string, deriveValue: (value: T) => T): void;
88 | }
89 |
90 | function isUpdateableStore(store: object): store is UpdateableStore {
91 | return updateProxy in store;
92 | }
93 |
94 | /**
95 | * Transforms an object into a tracked version. Changing the object returned from `reactive` will also change
96 | * the original. All watchers and derived values will update.
97 | * Access to `Object.keys` as well as checking for the existance of a key through the `in` operator will also be tracked.
98 | *
99 | * @example
100 | * Original object is mutated when the reactive object is mutated.
101 | * ```js
102 | * const originalState = { message: 'hello' };
103 | * const state = reactive(originalState);
104 | * state.message = 'ciao';
105 | * console.log(originalState.message); // => 'ciao'
106 | * ```
107 | *
108 | * @remarks
109 | * When a tracked object is destructed, all tracking information is lost.
110 | * Instead of destructuring a `reactive` object, you need to first convert it with {@link toRefs}.
111 | *
112 | * @param initialValue - The underlying object
113 | * @public
114 | */
115 | export function reactive(initialValue: T): Store {
116 | const tagMap: Record = {};
117 | const keyTag = createTag();
118 | function update(
119 | prop: K,
120 | deriveValue: (value: T[K]) => T[K]
121 | ) {
122 | const r = initialValue[prop];
123 | if (isRefLike(r)) {
124 | r.update(deriveValue);
125 | } else {
126 | (proxy as T)[prop] = deriveValue(r);
127 | }
128 | }
129 | const proxy = new Proxy(initialValue, {
130 | get(target: T, prop: string | number, receiver: any) {
131 | if ((prop as unknown) === updateProxy) {
132 | return update;
133 | }
134 | if (!tagMap[prop]) {
135 | tagMap[prop] = createTag();
136 | }
137 | consumeTag(tagMap[prop]);
138 | const r = Reflect.get(target, prop, receiver);
139 | return isRefLike(r) ? r.current : r;
140 | },
141 | set(target: T, prop: string | number, value: any, receiver: any) {
142 | if (!tagMap[prop]) {
143 | tagMap[prop] = createTag();
144 | dirtyTag(keyTag);
145 | }
146 | const r = Reflect.get(target, prop, receiver);
147 | const isRef = isRefLike(r);
148 | const oldValue = isRef ? r.current : r;
149 | if (!Object.is(oldValue, value)) {
150 | // TODO: Do I really need to dirty a tag that was just created? When would that be necessary?
151 | dirtyTag(tagMap[prop]);
152 | }
153 | if (isRef) {
154 | r.current = value;
155 | return true;
156 | }
157 | return Reflect.set(target, prop, value, receiver);
158 | },
159 | ownKeys(target: T) {
160 | consumeTag(keyTag);
161 | return Reflect.ownKeys(target);
162 | },
163 | deleteProperty(target: T, prop: string | number) {
164 | dirtyTag(keyTag);
165 | if (tagMap[prop]) {
166 | dirtyTag(tagMap[prop]);
167 | }
168 | return Reflect.deleteProperty(target, prop);
169 | },
170 | has(target: T, prop: string | number) {
171 | if ((prop as unknown) === updateProxy) {
172 | return true;
173 | }
174 | if (!tagMap[prop]) {
175 | tagMap[prop] = createTag();
176 | }
177 | consumeTag(tagMap[prop]);
178 | return Reflect.has(target, prop);
179 | },
180 | }) as Store;
181 |
182 | return proxy;
183 | }
184 |
185 | /**
186 | * Converts a tracked object into an object of {@link Ref} instances.
187 | * @param store - A tracked object created through {@link reactive}.
188 | *
189 | * @public
190 | */
191 | export function toRefs(
192 | store: Store
193 | ): RefContainer {
194 | return Object.keys(store).reduce((obj: RefContainer, prop: string) => {
195 | const value = toRef(store, prop);
196 | Object.defineProperty(obj, prop, {
197 | configurable: false,
198 | enumerable: true,
199 | writable: false,
200 | value,
201 | });
202 | return obj;
203 | }, {} as RefContainer);
204 | }
205 |
206 | /**
207 | * Extracts a single property from a tracked object into a {@link RefObject}.
208 | *
209 | * @param store - A tracked object that was created through {@link reactive}.
210 | * @param prop - The name of the property that should be extracted into a {@link RefObject}
211 | *
212 | * @example
213 | * ```js
214 | * const state = reactive({ message: 'hello' });
215 | * const message = toRef(state, 'message');
216 | * console.log(message.current);
217 | * ```
218 | *
219 | * @public
220 | */
221 | export function toRef(
222 | store: T,
223 | prop: K
224 | ): RefObject {
225 | return {
226 | get current() {
227 | return store[prop];
228 | },
229 | set current(value: T[K]) {
230 | store[prop] = value;
231 | },
232 | update(fn: (value: T[K]) => T[K]) {
233 | if (isUpdateableStore(store)) {
234 | store[updateProxy](prop as string, fn);
235 | }
236 | },
237 | };
238 | }
239 |
240 | /**
241 | * Returns a {@link ReadonlyRef} whose value will always point to the latest result of the given function.
242 | * The function will only be executed once per set of values.
243 | *
244 | * @param fn - A function which returns a derivation of tracked objects or references.
245 | *
246 | * @example
247 | * ```js
248 | * const name = ref('Preact');
249 | * const greet = derived(() => `Hello ${name.current}!`);
250 | * console.log(greet.current); // => 'Hello Preact'
251 | * name.current = 'React';
252 | * console.log(greet.current); // => 'Hello React'
253 | * ```
254 | *
255 | * @public
256 | */
257 | export function derived(fn: () => T): ReadonlyRef {
258 | const calculator = memoize(fn);
259 | return {
260 | get current() {
261 | return calculator();
262 | },
263 | };
264 | }
265 |
266 | /**
267 | * Converts a mutable {@link Ref} to a {@link ReadonlyRef}.
268 | * @param ref - A mutable tracked reference
269 | * @public
270 | */
271 | export function readonly(ref: Ref): ReadonlyRef {
272 | return derived(() => ref.current);
273 | }
274 |
--------------------------------------------------------------------------------
/src/tag.ts:
--------------------------------------------------------------------------------
1 | // Based on https://www.pzuraq.com/how-autotracking-works/
2 |
3 | type Revision = number;
4 |
5 | let CURRENT_REVISION: Revision = 0;
6 |
7 | //////////
8 |
9 | const REVISION = Symbol('REVISION');
10 |
11 | const scheduledTags = new Set();
12 | let nextTick: Promise | null = null;
13 | function schedule(tag: Tag) {
14 | scheduledTags.add(tag);
15 | if (!nextTick) {
16 | nextTick = Promise.resolve().then(drainQueue);
17 | }
18 | }
19 |
20 | function drainQueue() {
21 | nextTick = null;
22 | const scheduledEffects = new Set();
23 | scheduledTags.forEach(tag => {
24 | tag.subscriptions.forEach(effect => scheduledEffects.add(effect));
25 | });
26 | scheduledTags.clear();
27 | scheduledEffects.forEach(effect => effect());
28 | }
29 |
30 | /**
31 | * A function that represents a pure side effect with no input and no output.
32 | * @public
33 | */
34 | export interface Effect {
35 | (): void;
36 | }
37 |
38 | class Tag {
39 | [REVISION] = CURRENT_REVISION;
40 | subscriptions = new Set();
41 |
42 | subscribe(effect: Effect) {
43 | this.subscriptions.add(effect);
44 | }
45 |
46 | unsubscribe(effect: Effect) {
47 | this.subscriptions.delete(effect);
48 | }
49 | }
50 |
51 | export function createTag() {
52 | return new Tag();
53 | }
54 |
55 | export { Tag };
56 |
57 | //////////
58 |
59 | export function dirtyTag(tag: Tag) {
60 | if (currentComputation && currentComputation.has(tag)) {
61 | throw new Error('Cannot dirty tag that has been used during a computation');
62 | }
63 |
64 | tag[REVISION] = ++CURRENT_REVISION;
65 | if (tag.subscriptions.size > 0) {
66 | schedule(tag);
67 | }
68 | }
69 |
70 | //////////
71 |
72 | let currentComputation: null | Set = null;
73 |
74 | export function consumeTag(tag: Tag) {
75 | if (currentComputation !== null) {
76 | currentComputation.add(tag);
77 | }
78 | }
79 |
80 | function getMax(tags: Tag[]) {
81 | return Math.max(...tags.map(t => t[REVISION]));
82 | }
83 |
84 | /**
85 | * Manages the subscription to tracked references and objects within a `memoized` function.
86 | * @public
87 | */
88 | export class SubscriptionController {
89 | private tags: Array = [];
90 | private isSubscribed = false;
91 | private lastRevision: Revision = 0;
92 | /**
93 | * The effect that is triggered whenever a tracked value changes after the controller
94 | * has subscribed to changes.
95 | */
96 | effect: Effect;
97 | /**
98 | * A cleanup effect that should be executed before the effect is executed again or
99 | * on unsubscribe.
100 | */
101 | cleanup?: Effect;
102 | /**
103 | * Creates a new SubscriptionController.
104 | * @param effect - The effect that should be executed whenever a tracked reference was changed.
105 | */
106 | constructor(effect: Effect) {
107 | this.effect = effect;
108 | }
109 |
110 | /**
111 | * @internal
112 | * @param tags - The new tags
113 | * @param lastRevision - The last revision of the tags
114 | */
115 | setObservedTags(tags: Array, lastRevision: Revision) {
116 | if (this.isSubscribed) {
117 | this.unsubscribeFromTags();
118 | this.tags = tags;
119 | this.subscribeToTags();
120 | } else {
121 | this.tags = tags;
122 | }
123 | this.lastRevision = lastRevision;
124 | }
125 |
126 | private subscribeToTags() {
127 | this.tags.forEach(tag => tag.subscribe(this.effect));
128 | }
129 | private unsubscribeFromTags() {
130 | this.tags.forEach(tag => tag.unsubscribe(this.effect));
131 | }
132 |
133 | /**
134 | * Subscribes to the set of tracked references and objects.
135 | * Once subscribed, the {@link SubscriptionController.effect} will be triggered whenever
136 | * any of the values change.
137 | */
138 | subscribe() {
139 | if (this.isSubscribed) {
140 | return;
141 | }
142 | this.subscribeToTags();
143 | this.isSubscribed = true;
144 | // there is a chance that a tag has been updated in between
145 | // us starting to observe it and subscribing to it
146 | // if that is the case, we will trigger the effect on subscription
147 | // to make sure we're always up to date
148 | if (getMax(this.tags) > this.lastRevision) {
149 | // TODO: Architectural Decision needed (or very good documentation)
150 | // I am not 100% whether it is a good idea to run this synchronously
151 | // In our Library-usage that would be preferable since `subscribe` is called
152 | // during `useEffect` and additional scheduling would just cause unnecessary delays
153 | // but in user-land this might be confusing as all other parts of the library trigger
154 | // effects async.
155 | this.effect();
156 | }
157 | }
158 |
159 | /**
160 | * Unsubscribes from all tracked values.
161 | */
162 | unsubscribe() {
163 | if (!this.isSubscribed) {
164 | return;
165 | }
166 | this.unsubscribeFromTags();
167 | if (this.cleanup) this.cleanup();
168 | this.isSubscribed = false;
169 | }
170 | }
171 |
172 | /**
173 | * Returns a function that is only executed again if any of its tracked values have changed.
174 | * The `controller` can be used to establish a notification system and is largely irrelevant to end users of the API.
175 | *
176 | * @example
177 | * ```
178 | * const person = ref('Preact');
179 | * const message = memoize(() => `Hello ${person.current}`);
180 | *
181 | * console.log(message()); // => 'Hello Preact'
182 | * console.log(message()); // => 'Hello Preact', but this time the memoized function was not executed at all
183 | * ```
184 | *
185 | * @param fn - A memoized function.
186 | * @param controller - A controller that can be used to manage subscribing to tracked values.
187 | * @public
188 | */
189 | export function memoize(
190 | fn: () => T,
191 | controller?: SubscriptionController
192 | ): () => T {
193 | let lastValue: T | undefined;
194 | let lastRevision: Revision | undefined;
195 | let lastTags: Tag[] | undefined;
196 |
197 | return () => {
198 | if (lastTags && getMax(lastTags) === lastRevision) {
199 | if (currentComputation && lastTags.length > 0) {
200 | lastTags.forEach(tag => currentComputation!.add(tag));
201 | }
202 |
203 | return lastValue as T;
204 | }
205 |
206 | let previousComputation = currentComputation;
207 | currentComputation = new Set();
208 |
209 | try {
210 | lastValue = fn();
211 | } finally {
212 | lastTags = Array.from(currentComputation);
213 | lastRevision = getMax(lastTags);
214 |
215 | if (lastTags.length > 0 && previousComputation) {
216 | lastTags.forEach(tag => previousComputation!.add(tag));
217 | }
218 | if (controller) controller.setObservedTags(lastTags, lastRevision);
219 |
220 | currentComputation = previousComputation;
221 | }
222 |
223 | return lastValue;
224 | };
225 | }
226 |
227 | let subscriptions: Array | undefined;
228 | export function collectSubscriptions(
229 | fn: () => T
230 | ): Array {
231 | const oldSubscriptions = subscriptions;
232 | let subs = (subscriptions = [] as Array);
233 | try {
234 | fn();
235 | } finally {
236 | subscriptions = oldSubscriptions;
237 | }
238 | return subs;
239 | }
240 |
241 | /**
242 | * Executes the given effect immediately and tracks any used values.
243 | * When any of them change, it will execute the effect again.
244 | * If a `teardown` function has been registered through the `onInvalidate` param,
245 | * it will be executed before the effect is executed again, allowing for cleanup.
246 | *
247 | * @remarks
248 | * When using this function within a Reactive Component, make sure to not rely on any custom `teardown` logic.
249 | *
250 | * When this function is used within a Reactive Component, the tracking will be bound to the components lifecycle.
251 | * It is, therefore, save to use and can be considered side effect free (and thus React Concurrent Mode compatible).
252 | * However, there are circumstances that might cause a custom `teardown` function to not be invoked.
253 | *
254 | * For example, if your component has been rendered but not committed (written to the DOM) then React reserves the right to throw it away without
255 | * invoking any cleanup logic.
256 | *
257 | * ```js
258 | * // DO NOT DO THIS
259 | * import { watchEffect, ref } from '@pago/reactive';
260 | * function Surprise(props) {
261 | * const message = ref('Wait for it...');
262 | * watchEffect(onInvalidate => {
263 | * // This timer will never be cleared
264 | * // if the component is not unmounted
265 | * // or during server side rendering
266 | * // You should use `effect` instead
267 | * const token = setTimeout(() => {
268 | * message.current = 'Hello World!'
269 | * }, props.delay); // props.delay is watched
270 | * onInvalidate(() => clearTimeout(token));
271 | * });
272 | * return () =>
{message.current}
273 | * }
274 | * ```
275 | *
276 | * @param fn - The effect that should be executed when any of the tracked values change.
277 | * @public
278 | */
279 | export function watchEffect(
280 | fn: (onInvalidate: (teardown: Effect) => void) => void
281 | ): Effect {
282 | const controller = new SubscriptionController(effect);
283 | function onInvalidate(teardown: Effect) {
284 | controller.cleanup = () => {
285 | teardown();
286 | controller.cleanup = undefined;
287 | };
288 | }
289 | const run = memoize(() => fn(onInvalidate), controller);
290 | effect();
291 |
292 | if (subscriptions) {
293 | subscriptions.push(controller);
294 | } else {
295 | controller.subscribe();
296 | }
297 |
298 | function effect() {
299 | if (controller.cleanup) controller.cleanup();
300 | run();
301 | // TODO: Architectural Decision
302 | // At the moment if `fn` succeeds at least once in execution
303 | // we will setup a subscription and every subsequent update of a tag
304 | // will cause it to be re-evaluated.
305 | // Q: Should it instead unsubscribe? Or is ok to retry once new data is in?
306 | }
307 |
308 | return () => controller.unsubscribe();
309 | }
310 |
--------------------------------------------------------------------------------
/api-extractor.json:
--------------------------------------------------------------------------------
1 | /**
2 | * Config file for API Extractor. For more info, please visit: https://api-extractor.com
3 | */
4 | {
5 | "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json",
6 |
7 | /**
8 | * Optionally specifies another JSON config file that this file extends from. This provides a way for
9 | * standard settings to be shared across multiple projects.
10 | *
11 | * If the path starts with "./" or "../", the path is resolved relative to the folder of the file that contains
12 | * the "extends" field. Otherwise, the first path segment is interpreted as an NPM package name, and will be
13 | * resolved using NodeJS require().
14 | *
15 | * SUPPORTED TOKENS: none
16 | * DEFAULT VALUE: ""
17 | */
18 | // "extends": "./shared/api-extractor-base.json"
19 | // "extends": "my-package/include/api-extractor-base.json"
20 |
21 | /**
22 | * Determines the "" token that can be used with other config file settings. The project folder
23 | * typically contains the tsconfig.json and package.json config files, but the path is user-defined.
24 | *
25 | * The path is resolved relative to the folder of the config file that contains the setting.
26 | *
27 | * The default value for "projectFolder" is the token "", which means the folder is determined by traversing
28 | * parent folders, starting from the folder containing api-extractor.json, and stopping at the first folder
29 | * that contains a tsconfig.json file. If a tsconfig.json file cannot be found in this way, then an error
30 | * will be reported.
31 | *
32 | * SUPPORTED TOKENS:
33 | * DEFAULT VALUE: ""
34 | */
35 | // "projectFolder": "..",
36 |
37 | /**
38 | * (REQUIRED) Specifies the .d.ts file to be used as the starting point for analysis. API Extractor
39 | * analyzes the symbols exported by this module.
40 | *
41 | * The file extension must be ".d.ts" and not ".ts".
42 | *
43 | * The path is resolved relative to the folder of the config file that contains the setting; to change this,
44 | * prepend a folder token such as "".
45 | *
46 | * SUPPORTED TOKENS: , ,
47 | */
48 | "mainEntryPointFilePath": "/dist/index.d.ts",
49 |
50 | /**
51 | * A list of NPM package names whose exports should be treated as part of this package.
52 | *
53 | * For example, suppose that Webpack is used to generate a distributed bundle for the project "library1",
54 | * and another NPM package "library2" is embedded in this bundle. Some types from library2 may become part
55 | * of the exported API for library1, but by default API Extractor would generate a .d.ts rollup that explicitly
56 | * imports library2. To avoid this, we can specify:
57 | *
58 | * "bundledPackages": [ "library2" ],
59 | *
60 | * This would direct API Extractor to embed those types directly in the .d.ts rollup, as if they had been
61 | * local files for library1.
62 | */
63 | "bundledPackages": [],
64 |
65 | /**
66 | * Determines how the TypeScript compiler engine will be invoked by API Extractor.
67 | */
68 | "compiler": {
69 | /**
70 | * Specifies the path to the tsconfig.json file to be used by API Extractor when analyzing the project.
71 | *
72 | * The path is resolved relative to the folder of the config file that contains the setting; to change this,
73 | * prepend a folder token such as "".
74 | *
75 | * Note: This setting will be ignored if "overrideTsconfig" is used.
76 | *
77 | * SUPPORTED TOKENS: , ,
78 | * DEFAULT VALUE: "/tsconfig.json"
79 | */
80 | // "tsconfigFilePath": "/tsconfig.json",
81 | /**
82 | * Provides a compiler configuration that will be used instead of reading the tsconfig.json file from disk.
83 | * The object must conform to the TypeScript tsconfig schema:
84 | *
85 | * http://json.schemastore.org/tsconfig
86 | *
87 | * If omitted, then the tsconfig.json file will be read from the "projectFolder".
88 | *
89 | * DEFAULT VALUE: no overrideTsconfig section
90 | */
91 | // "overrideTsconfig": {
92 | // . . .
93 | // }
94 | /**
95 | * This option causes the compiler to be invoked with the --skipLibCheck option. This option is not recommended
96 | * and may cause API Extractor to produce incomplete or incorrect declarations, but it may be required when
97 | * dependencies contain declarations that are incompatible with the TypeScript engine that API Extractor uses
98 | * for its analysis. Where possible, the underlying issue should be fixed rather than relying on skipLibCheck.
99 | *
100 | * DEFAULT VALUE: false
101 | */
102 | // "skipLibCheck": true,
103 | },
104 |
105 | /**
106 | * Configures how the API report file (*.api.md) will be generated.
107 | */
108 | "apiReport": {
109 | /**
110 | * (REQUIRED) Whether to generate an API report.
111 | */
112 | "enabled": true
113 |
114 | /**
115 | * The filename for the API report files. It will be combined with "reportFolder" or "reportTempFolder" to produce
116 | * a full file path.
117 | *
118 | * The file extension should be ".api.md", and the string should not contain a path separator such as "\" or "/".
119 | *
120 | * SUPPORTED TOKENS: ,
121 | * DEFAULT VALUE: ".api.md"
122 | */
123 | // "reportFileName": ".api.md",
124 |
125 | /**
126 | * Specifies the folder where the API report file is written. The file name portion is determined by
127 | * the "reportFileName" setting.
128 | *
129 | * The API report file is normally tracked by Git. Changes to it can be used to trigger a branch policy,
130 | * e.g. for an API review.
131 | *
132 | * The path is resolved relative to the folder of the config file that contains the setting; to change this,
133 | * prepend a folder token such as "".
134 | *
135 | * SUPPORTED TOKENS: , ,
136 | * DEFAULT VALUE: "/etc/"
137 | */
138 | // "reportFolder": "/etc/",
139 |
140 | /**
141 | * Specifies the folder where the temporary report file is written. The file name portion is determined by
142 | * the "reportFileName" setting.
143 | *
144 | * After the temporary file is written to disk, it is compared with the file in the "reportFolder".
145 | * If they are different, a production build will fail.
146 | *
147 | * The path is resolved relative to the folder of the config file that contains the setting; to change this,
148 | * prepend a folder token such as "".
149 | *
150 | * SUPPORTED TOKENS: , ,
151 | * DEFAULT VALUE: "/temp/"
152 | */
153 | // "reportTempFolder": "/temp/"
154 | },
155 |
156 | /**
157 | * Configures how the doc model file (*.api.json) will be generated.
158 | */
159 | "docModel": {
160 | /**
161 | * (REQUIRED) Whether to generate a doc model file.
162 | */
163 | "enabled": true
164 |
165 | /**
166 | * The output path for the doc model file. The file extension should be ".api.json".
167 | *
168 | * The path is resolved relative to the folder of the config file that contains the setting; to change this,
169 | * prepend a folder token such as "".
170 | *
171 | * SUPPORTED TOKENS: , ,
172 | * DEFAULT VALUE: "/temp/.api.json"
173 | */
174 | // "apiJsonFilePath": "/temp/.api.json"
175 | },
176 |
177 | /**
178 | * Configures how the .d.ts rollup file will be generated.
179 | */
180 | "dtsRollup": {
181 | /**
182 | * (REQUIRED) Whether to generate the .d.ts rollup file.
183 | */
184 | "enabled": true,
185 |
186 | /**
187 | * Specifies the output path for a .d.ts rollup file to be generated without any trimming.
188 | * This file will include all declarations that are exported by the main entry point.
189 | *
190 | * If the path is an empty string, then this file will not be written.
191 | *
192 | * The path is resolved relative to the folder of the config file that contains the setting; to change this,
193 | * prepend a folder token such as "".
194 | *
195 | * SUPPORTED TOKENS: , ,
196 | * DEFAULT VALUE: "/dist/.d.ts"
197 | */
198 | "untrimmedFilePath": "/dist/-private.d.ts",
199 |
200 | /**
201 | * Specifies the output path for a .d.ts rollup file to be generated with trimming for a "beta" release.
202 | * This file will include only declarations that are marked as "@public" or "@beta".
203 | *
204 | * The path is resolved relative to the folder of the config file that contains the setting; to change this,
205 | * prepend a folder token such as "".
206 | *
207 | * SUPPORTED TOKENS: , ,
208 | * DEFAULT VALUE: ""
209 | */
210 | "betaTrimmedFilePath": "/dist/-beta.d.ts",
211 |
212 | /**
213 | * Specifies the output path for a .d.ts rollup file to be generated with trimming for a "public" release.
214 | * This file will include only declarations that are marked as "@public".
215 | *
216 | * If the path is an empty string, then this file will not be written.
217 | *
218 | * The path is resolved relative to the folder of the config file that contains the setting; to change this,
219 | * prepend a folder token such as "".
220 | *
221 | * SUPPORTED TOKENS: , ,
222 | * DEFAULT VALUE: ""
223 | */
224 | "publicTrimmedFilePath": "/dist/.d.ts"
225 |
226 | /**
227 | * When a declaration is trimmed, by default it will be replaced by a code comment such as
228 | * "Excluded from this release type: exampleMember". Set "omitTrimmingComments" to true to remove the
229 | * declaration completely.
230 | *
231 | * DEFAULT VALUE: false
232 | */
233 | // "omitTrimmingComments": true
234 | },
235 |
236 | /**
237 | * Configures how the tsdoc-metadata.json file will be generated.
238 | */
239 | "tsdocMetadata": {
240 | /**
241 | * Whether to generate the tsdoc-metadata.json file.
242 | *
243 | * DEFAULT VALUE: true
244 | */
245 | // "enabled": true,
246 | /**
247 | * Specifies where the TSDoc metadata file should be written.
248 | *
249 | * The path is resolved relative to the folder of the config file that contains the setting; to change this,
250 | * prepend a folder token such as "".
251 | *
252 | * The default value is "", which causes the path to be automatically inferred from the "tsdocMetadata",
253 | * "typings" or "main" fields of the project's package.json. If none of these fields are set, the lookup
254 | * falls back to "tsdoc-metadata.json" in the package folder.
255 | *
256 | * SUPPORTED TOKENS: , ,
257 | * DEFAULT VALUE: ""
258 | */
259 | // "tsdocMetadataFilePath": "/dist/tsdoc-metadata.json"
260 | },
261 |
262 | /**
263 | * Specifies what type of newlines API Extractor should use when writing output files. By default, the output files
264 | * will be written with Windows-style newlines. To use POSIX-style newlines, specify "lf" instead.
265 | * To use the OS's default newline kind, specify "os".
266 | *
267 | * DEFAULT VALUE: "crlf"
268 | */
269 | // "newlineKind": "crlf",
270 |
271 | /**
272 | * Configures how API Extractor reports error and warning messages produced during analysis.
273 | *
274 | * There are three sources of messages: compiler messages, API Extractor messages, and TSDoc messages.
275 | */
276 | "messages": {
277 | /**
278 | * Configures handling of diagnostic messages reported by the TypeScript compiler engine while analyzing
279 | * the input .d.ts files.
280 | *
281 | * TypeScript message identifiers start with "TS" followed by an integer. For example: "TS2551"
282 | *
283 | * DEFAULT VALUE: A single "default" entry with logLevel=warning.
284 | */
285 | "compilerMessageReporting": {
286 | /**
287 | * Configures the default routing for messages that don't match an explicit rule in this table.
288 | */
289 | "default": {
290 | /**
291 | * Specifies whether the message should be written to the the tool's output log. Note that
292 | * the "addToApiReportFile" property may supersede this option.
293 | *
294 | * Possible values: "error", "warning", "none"
295 | *
296 | * Errors cause the build to fail and return a nonzero exit code. Warnings cause a production build fail
297 | * and return a nonzero exit code. For a non-production build (e.g. when "api-extractor run" includes
298 | * the "--local" option), the warning is displayed but the build will not fail.
299 | *
300 | * DEFAULT VALUE: "warning"
301 | */
302 | "logLevel": "warning"
303 |
304 | /**
305 | * When addToApiReportFile is true: If API Extractor is configured to write an API report file (.api.md),
306 | * then the message will be written inside that file; otherwise, the message is instead logged according to
307 | * the "logLevel" option.
308 | *
309 | * DEFAULT VALUE: false
310 | */
311 | // "addToApiReportFile": false
312 | }
313 |
314 | // "TS2551": {
315 | // "logLevel": "warning",
316 | // "addToApiReportFile": true
317 | // },
318 | //
319 | // . . .
320 | },
321 |
322 | /**
323 | * Configures handling of messages reported by API Extractor during its analysis.
324 | *
325 | * API Extractor message identifiers start with "ae-". For example: "ae-extra-release-tag"
326 | *
327 | * DEFAULT VALUE: See api-extractor-defaults.json for the complete table of extractorMessageReporting mappings
328 | */
329 | "extractorMessageReporting": {
330 | "default": {
331 | "logLevel": "warning"
332 | // "addToApiReportFile": false
333 | }
334 |
335 | // "ae-extra-release-tag": {
336 | // "logLevel": "warning",
337 | // "addToApiReportFile": true
338 | // },
339 | //
340 | // . . .
341 | },
342 |
343 | /**
344 | * Configures handling of messages reported by the TSDoc parser when analyzing code comments.
345 | *
346 | * TSDoc message identifiers start with "tsdoc-". For example: "tsdoc-link-tag-unescaped-text"
347 | *
348 | * DEFAULT VALUE: A single "default" entry with logLevel=warning.
349 | */
350 | "tsdocMessageReporting": {
351 | "default": {
352 | "logLevel": "warning"
353 | // "addToApiReportFile": false
354 | }
355 |
356 | // "tsdoc-link-tag-unescaped-text": {
357 | // "logLevel": "warning",
358 | // "addToApiReportFile": true
359 | // },
360 | //
361 | // . . .
362 | }
363 | }
364 | }
365 |
--------------------------------------------------------------------------------
/docs/index.md:
--------------------------------------------------------------------------------
1 | # @pago/reactive - An Introduction
2 |
3 | To get started with `@pago/reactive`, you will need to configure Babel, TypeScript or any other compiler to use `@pago/reactive` as the `jsxImportSource`.
4 | However, we have prepared a [CodeSandbox](https://codesandbox.io/s/pagoreactive-playground-zx34h) for you so that you can just focus on testing the library,
5 | rather than having to go through setting it up for your environment. When you are ready to integrate it into your setup, you can take a look at the [integration examples](https://github.com/pago/reactive/tree/main/examples/).
6 |
7 | So please open up [CodeSandbox](https://codesandbox.io/s/pagoreactive-playground-zx34h) to get started with `@pago/reactive`.
8 |
9 | Or, if you're already familiar with it, skip to the [API Documentation](./api/reactive.md).
10 |
11 | ## A first look at a Reactive Component
12 |
13 | When you open up the [CodeSandbox](https://codesandbox.io/s/pagoreactive-playground-zx34h), you will find yourself looking at the `App.js` with a component similar to this:
14 |
15 | ```js
16 | export default function App() {
17 | const count = ref(0);
18 | effect(() => {
19 | console.log(`The count is now ${count.current}!`);
20 | });
21 | return () => (
22 |
23 |
Hello CodeSandbox
24 |
Start editing to see some magic happen!
25 |
Your current count is {count.current}
26 |
27 |
30 |
33 |
34 |
35 | );
36 | }
37 | ```
38 |
39 | We will want to replace that component with a simple standard React Component so that we can work ourselves towards that version. That could look something like this:
40 |
41 | ```js
42 | export default function App() {
43 | return (
44 |
45 |
Hello CodeSandbox
46 |
Start editing to see some magic happen!
47 |
Your current count is {0}
48 |
49 |
52 |
55 |
56 |
57 | );
58 | }
59 | ```
60 |
61 | This a regular old React Function Component. The very curious people will recognize that we are still using `@pago/reactive` to render JSX. That's fine, `@pago/reactive` is fully compatible with standard React Components and does not interfere with their execution.
62 |
63 | ## From React to Reactive
64 |
65 | In our first step towards leveraging the power of `@pago/reactive` we want to convert our standard React Component into a Reactive Component.
66 | The one thing we need to do to make that happen is to return a `render` function instead of the JSX.
67 |
68 | ```js
69 | export default function App() {
70 | return () =>
{/* same as before */}
;
71 | }
72 | ```
73 |
74 | This change converts our standard React Component into a Reactive Component. And it already yields a benefit: **Improved performance**.
75 | By converting a React Component into a Reactive Component, we have optimized the rendering of the component in the same way that `React.memo` optimizes your React Components: It will always return the same Virtual DOM tree unless any given property that you are actually using within the `render` function changes.
76 | In many ways this is actually even better than the optimization offered by `React.memo` because it only tracks properties that you are actually using. If somebody passes in a new property that your component doesn't even accept, that will not cause your component to bail out from optimization.
77 |
78 | ## The four phases of a Reactive Component Lifecycle
79 |
80 | The `@pago/reactive` library has been build with Reacts Concurrent Mode in mind and makes it easy for your code to fit into that execution model.
81 | Because of that, a Reactive Component has a well defined Lifecycle that consists of four stages:
82 |
83 | 1. Creation Phase
84 | 2. Render Phase
85 | 3. Effects Phase
86 | 4. Teardown Phase
87 |
88 | Let's look at an example of those phases and where they live in our Reactive Component:
89 |
90 | ```js
91 | import { effect } from '@pago/reactive';
92 |
93 | export default function App() {
94 | // PHASE 1: Creation Phase
95 | // Any code placed here will only execute once during the component creation
96 | effect(onInvalidate => {
97 | // PHASE 3: Effects Phase
98 | // An effect will run after the component has been commited (i.e. rendered to DOM nodes).
99 | // It will also be invoked whenever any tracked state changes (more details later).
100 | onInvalidate(() => {
101 | // PHASE 4: Teardown
102 | // This callback is invoked when the component is unmounted
103 | // or before the effect is run again due to tracked state changes
104 | });
105 | });
106 | return () => (
107 | {/* PHASE 2: Render Phase
108 | Code placed here will run whenever the component is rendered. */}
109 |
110 | {/* same as before */}
111 |
112 | );
113 | }
114 | ```
115 |
116 | This clear separation of phases makes it easy to write code that conforms with the fundamental React principle of side-effects free rendering.
117 | It allows a Reactive Component to optimize itself and avoid running unnecessary code. But more importantly, it allows you to write simple and
118 | straightforward code for your component without having to think about a clever combination of `useEffect`, `useRef` and `useState` that might
119 | yield the desired behaviour.
120 |
121 | You might be wondering why `effect` passes in an `onInvalidate` function rather than expecting you to return the teardown function like
122 | Reacts `useEffect` does. This way, you can make your effect `async` and leverage `async` / `await` in a straightforward way without requiring
123 | any tricks on your side.
124 |
125 | ## Tracked State
126 |
127 | We have already hinted that properties passed to a Reactive Component are `tracked` and that a change to them will cause the component to render again.
128 | But `@pago/reactive` wouldn't be very useful if that was all it offered. Instead, it offers ways to create your own `tracked` state through the `ref` and the `reactive` functions.
129 |
130 | When using the `reactive` function, we can turn any object into a `tracked` object with minimal fuss. Let's look at what that might look like:
131 |
132 | ```js
133 | export default function App() {
134 | const state = reactive({
135 | count: 0,
136 | });
137 | return () => (
138 |
139 |
Hello CodeSandbox
140 |
Start editing to see some magic happen!
141 |
Your current count is {state.count}
142 |
143 |
146 |
149 |
150 |
151 | );
152 | }
153 | ```
154 |
155 | We can just access the `count` property of the `tracked` object `state`, reading from it and mutating it however we want.
156 | When the user clicks on either the "Increment" or the "Decrement" buttons, we mutate the `state` object, causing the component to be rendered again.
157 |
158 | The other type of state is something that has been part of React for a long time: a `ref`. It offers the exact same shape as a `ref` created by `useRef`.
159 | However, its value is `tracked` and changes to it will trigger effects and rendering when and where necessary.
160 |
161 | ```js
162 | export default function App() {
163 | const state = reactive({
164 | count: 0,
165 | });
166 | const h1 = ref();
167 | effect(() => {
168 | h1.current.style.color = 'blue';
169 | });
170 | return () => (
171 |
172 |
Hello CodeSandbox
173 | {/* same as before */}
174 |
175 | );
176 | }
177 | ```
178 |
179 | Whenever the `h1` ref changes its current value, the effect will be triggered.
180 | Besides for tracking DOM elements, we can also use `ref` to manage our state if we want to store a single value, rather than a full object.
181 |
182 | ```js
183 | function createCounter() {
184 | const count = ref(0);
185 |
186 | return {
187 | get count() {
188 | return count.current;
189 | },
190 | increment() {
191 | count.current++;
192 | },
193 | decrement() {
194 | count.current--;
195 | },
196 | };
197 | }
198 | ```
199 |
200 | We could now use this function in any of our Reactive Components and it would just work.
201 |
202 | ## Beware: Destructuring
203 |
204 | When you use destructuring on a `reactive` object, it will loose its reactivity and its values won't be tracked anymore. Thus, you need to first
205 | convert the object into a `RefContainer` by using the `toRefs` functions.
206 |
207 | ```js
208 | function Timer(props) {
209 | const { step, delay } = toRefs(props);
210 | const count = ref(0);
211 | effect(onInvalidate => {
212 | const t = setInterval(() => {
213 | count.current += step.current;
214 | }, delay.current);
215 | onInvalidate(() => clearInterval(t));
216 | });
217 | return () => Timer: {count.current};
218 | }
219 | ```
220 |
221 | ## Global Tracked State
222 |
223 | When you are building a client side only application without server side rendering, you can deal with your
224 | global state needs by using a global `tracked` state variable.
225 | Both `ref` and `reactive` can be used for creating them and using and mutating them works as expected.
226 |
227 | ```js
228 | const globalCount = ref(0);
229 |
230 | export default function App() {
231 | return () => (
232 |
233 |
Hello CodeSandbox
234 |
Start editing to see some magic happen!
235 |
Your current count is {globalCount.current}
236 |
237 |
240 |
243 |
244 |
245 | );
246 | }
247 | ```
248 |
249 | Every component that uses `globalCount` will now be kept in sync automatically.
250 |
251 | ## State Management with React.Context
252 |
253 | When Server Side Rendering is a concern for you or you would like to avoid global state for reasons of testability of your code,
254 | you might want to leverage the React.Context API instead. `@pago/reactive` makes it very easy to use React Context for state management
255 | in your application by making it easy to avoid bugs and performance issues.
256 |
257 | Let's go back to our `getCounter` function that we've defined previously and let's put an instance of it into a Context so that can be used elsewhere:
258 |
259 | ```js
260 | import { ref, inject } from '@pago/reactive';
261 | import { createContext } from 'react';
262 | // a fancy counter model
263 | function createCounter() {
264 | const count = ref(0);
265 |
266 | return {
267 | get count() {
268 | return count.current;
269 | },
270 | increment() {
271 | count.current++;
272 | },
273 | decrement() {
274 | count.current--;
275 | },
276 | };
277 | }
278 | // creating the React Context to store it
279 | const CounterContext = createContext();
280 |
281 | // A provider component that makes the context available
282 | function CounterStateProvider(props) {
283 | const model = createCounter();
284 | return () => (
285 |
286 | {props.children}
287 |
288 | );
289 | }
290 |
291 | function Counter() {
292 | const model = inject(CounterContext);
293 | return () => The current count is {model.count};
294 | }
295 | ```
296 |
297 | Because of the Lifecycle Phases of a Reactive Component, the value stored within the context will always be the same, avoiding unnecessary renderings.
298 | However, whenever the state of the model changes, all components and effects using it will be triggered automatically.
299 |
300 | To gain access to our Context within a Reactive Component, we use the `inject` function provided by `@pago/reactive` to inject it into our component.
301 |
302 | Together with the various utility functions in `@pago/reactive`, such as `derived`, `readonly` or `watchEffect`, you might find less of a need to
303 | reach for libraries like MobX or Recoil in your application.
304 |
305 | ## Compatibility with Hooks
306 |
307 | There are many useful React Hooks out there that you might want to use in your application. Maybe you are not even writing a new one but have to integrate `@pago/reactive` into your current codebase that is full of existing Hooks.
308 | As we've discovered right at the beginning, React and Reactive Components can live next to each other without any problems. But can they interact? Can you leverage existing Hooks? Of course you can!
309 |
310 | ### Using existing Hooks in Reactive Components
311 |
312 | Let's assume that you have a wonderful `useScreenSize` Hook that you would like to use within your Reactive Component.
313 | All you'll need to do is to pass it to `fromHook`:
314 |
315 | ```js
316 | import { fromHook } from '@pago/reactive';
317 | import { useScreenSize } from 'somewhere';
318 |
319 | function ScreenSizePrinter() {
320 | const screenSize = fromHook(useScreenSize);
321 |
322 | return () =>
The current screen size is {screenSize.current}
;
323 | }
324 | ```
325 |
326 | `@pago/reactive` will automatically execute the Hook on every rendering of the Reactive Component, giving it a chance to modify
327 | the `screenSize` `ref` value and thus potentially causing a rerendering. You can pass any kind of function to `fromHook` and can
328 | use all existing React Hooks to do its work. It does not have to result in a new value.
329 |
330 | ```js
331 | function Timer() {
332 | const timer = fromHook(function useTimer() {
333 | const [timer, setTimer] = useState(0);
334 | useEffect(() => {
335 | const t = setInterval(() => {
336 | setTimer(current => current + 1);
337 | }, 1000);
338 | return () => clearInterval(t);
339 | }, []);
340 | return timer;
341 | });
342 | return () => Timer: {timer.current};
343 | }
344 | ```
345 |
346 | We pass a named function expression `useTimer` to `fromHook` to signal to eslint that we are within a React Hook and that it should apply
347 | all of its usual logic to the function scope. As mentioned before, the function you pass to `fromHook` does not have to return a value.
348 |
349 | ```js
350 | function Timer() {
351 | const timer = ref(0);
352 |
353 | fromHook(function useTimer() {
354 | useEffect(() => {
355 | const t = setInterval(() => {
356 | timer.current++;
357 | }, 1000);
358 | return () => clearInterval(t);
359 | }, []);
360 | });
361 | return () => Timer: {timer.current};
362 | }
363 | ```
364 |
365 | `@pago/reactive` offers another automatic performance improvement over React Components when using Hooks:
366 | In React, when a Hook signals that it needs to be executed again, the entire component will re-render. In a Reactive Component,
367 | all registered Hooks will be triggered but if that doesn't result in an actual change of the state that is `tracked` by
368 | the `render` function, then no rendering will happen and the old Virtual DOM tree will be reused.
369 |
370 | ### Using tracked ref objects in a Hook
371 |
372 | The example above, compared to its previous purely Reactive Component versions, no longer accepts properties to
373 | control the delay or the incrementation step. The function passed to `fromHook` is not tracked by default. Instead,
374 | you are asked to leverage the `useRefValue` Hook to mark a value as tracked within your custom Hook.
375 |
376 | ```js
377 | import { toRefs, useRefValue } from '@pago/reactive';
378 | import { useEffect, useState } from 'react';
379 |
380 | function Timer(props) {
381 | const { step, delay } = toRefs(props);
382 |
383 | const timer = fromHook(function useTimer() {
384 | const [timer, setTimer] = useState(0);
385 | const currentStep = useRefValue(step);
386 | const currentDelay = useRefValue(delay);
387 | useEffect(() => {
388 | const t = setInterval(() => {
389 | setTimer(current => current + currentStep);
390 | }, currentDelay);
391 | return () => clearInterval(t);
392 | }, [currentStep, currentDelay]);
393 | return timer;
394 | });
395 | return () => Timer: {timer.current};
396 | }
397 | ```
398 |
399 | By using `useRefValue` to extract a value from a `ref`, we mark it as read. Thus, any changes to it will cause the component using the Hook to
400 | be invalidated and updated.
401 |
402 | The `useRefValue` function can be used in any React Function Component or React Hook
403 | and enables React applications to manage their state through `@pago/reactive`.
404 |
405 | ## Next Steps
406 |
407 | If you've enjoying reading this introduction, please give it a try in [CodeSandbox](https://codesandbox.io/s/pagoreactive-playground-zx34h)
408 | or look through the [examples](https://github.com/pago/reactive/tree/main/examples) to see how to setup a [Next.js](https://github.com/pago/reactive/tree/main/examples/nextjs) project. More examples will follow
409 | over time.
410 |
411 | This project is still early on and bugs and issues should be expected. When you encounter anything strange or counter-intuitive, please
412 | open an [report the issue](https://github.com/pago/reactive/issues) on GitHub. That will help us to make the library better and to reach
413 | production quality.
414 |
415 | You can also take a look at the [API Documentation](./api/reactive.md) to learn more about the API offered by `@pago/reactive`.
416 |
--------------------------------------------------------------------------------