├── docs ├── _config.yml ├── api │ ├── index.md │ ├── reactive.ref.current.md │ ├── reactive.readonlyref.current.md │ ├── reactive.effect.md │ ├── reactive.jsx.md │ ├── reactive.jsxs.md │ ├── reactive.createelement.md │ ├── reactive.refcontainer.md │ ├── reactive.subscriptioncontroller.cleanup.md │ ├── reactive.store.md │ ├── reactive.subscriptioncontroller.effect.md │ ├── reactive.subscriptioncontroller.unsubscribe.md │ ├── reactive.readonlyref.md │ ├── reactive.refobject.update.md │ ├── reactive.subscriptioncontroller.subscribe.md │ ├── reactive.refobject.md │ ├── reactive.inject.md │ ├── reactive.r.md │ ├── reactive.subscriptioncontroller._constructor_.md │ ├── reactive.readonly.md │ ├── reactive.torefs.md │ ├── reactive.userefvalue.md │ ├── reactive.fromhook.md │ ├── reactive.ref.md │ ├── reactive.toref.md │ ├── reactive.derived.md │ ├── reactive.memoize.md │ ├── reactive.reactive.md │ ├── reactive.wrap.md │ ├── reactive.subscriptioncontroller.md │ ├── reactive.watcheffect.md │ └── reactive.md ├── adr │ ├── 0002-subscription-controller.md │ └── 0003-no-readable.md └── index.md ├── example ├── .npmignore ├── index.tsx ├── index.html ├── tsconfig.json └── package.json ├── jsx-runtime ├── index.js ├── index.esm.js └── package.json ├── .gitignore ├── examples └── nextjs │ ├── public │ ├── favicon.ico │ └── vercel.svg │ ├── pages │ ├── _app.js │ └── index.js │ ├── .babelrc │ ├── package.json │ ├── components │ └── counter.js │ ├── .gitignore │ └── README.md ├── test ├── util.ts ├── derived.test.ts ├── tag.test.ts ├── utils.test.ts ├── watchEffect.test.ts └── reactive.test.ts ├── .eslintrc.js ├── .storybook ├── preview.js └── main.js ├── .github └── workflows │ ├── size.yml │ └── main.yml ├── src ├── utils.ts ├── index.tsx ├── hooks.ts ├── jsx-runtime.ts ├── component.ts ├── reactive.ts └── tag.ts ├── LICENSE ├── stories ├── Counter.stories.tsx ├── Timer.stories.tsx ├── Context.stories.tsx ├── DynamicContext.stories.tsx ├── AdvancedCounter.stories.tsx ├── NonReactiveCounter.stories.tsx └── Suspense.stories.tsx ├── tsconfig.json ├── package.json ├── etc └── reactive.api.md ├── README.md └── api-extractor.json /docs/_config.yml: -------------------------------------------------------------------------------- 1 | theme: jekyll-theme-cayman -------------------------------------------------------------------------------- /example/.npmignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .cache 3 | dist -------------------------------------------------------------------------------- /jsx-runtime/index.js: -------------------------------------------------------------------------------- 1 | module.exports = require('../'); 2 | -------------------------------------------------------------------------------- /jsx-runtime/index.esm.js: -------------------------------------------------------------------------------- 1 | export { jsx, jsxs } from '../'; 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | .DS_Store 3 | node_modules 4 | .cache 5 | dist 6 | temp 7 | .idea -------------------------------------------------------------------------------- /jsx-runtime/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "main": "./index.js", 3 | "module": "./index.esm.js" 4 | } 5 | -------------------------------------------------------------------------------- /examples/nextjs/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pago/reactive/HEAD/examples/nextjs/public/favicon.ico -------------------------------------------------------------------------------- /examples/nextjs/pages/_app.js: -------------------------------------------------------------------------------- 1 | function App({ Component, pageProps }) { 2 | return ; 3 | } 4 | 5 | export default App; 6 | -------------------------------------------------------------------------------- /test/util.ts: -------------------------------------------------------------------------------- 1 | export function delay() { 2 | let resolve: (value?: T) => void = undefined as any; 3 | const signal = new Promise(res => (resolve = res)); 4 | return { signal, resolve }; 5 | } 6 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "extends": [ 3 | "react-app", 4 | "prettier/@typescript-eslint", 5 | "plugin:prettier/recommended" 6 | ], 7 | "settings": { 8 | "react": { 9 | "version": "detect" 10 | } 11 | } 12 | } -------------------------------------------------------------------------------- /.storybook/preview.js: -------------------------------------------------------------------------------- 1 | // https://storybook.js.org/docs/react/writing-stories/parameters#global-parameters 2 | export const parameters = { 3 | // https://storybook.js.org/docs/react/essentials/actions#automatically-matching-args 4 | actions: { argTypesRegex: '^on.*' }, 5 | }; 6 | -------------------------------------------------------------------------------- /docs/api/index.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | [Home](./index.md) 4 | 5 | ## API Reference 6 | 7 | ## Packages 8 | 9 | | Package | Description | 10 | | --- | --- | 11 | | [@pago/reactive](./reactive.md) | | 12 | 13 | -------------------------------------------------------------------------------- /examples/nextjs/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["next/babel"], 3 | "plugins": [ 4 | [ 5 | "@babel/plugin-transform-react-jsx", 6 | { 7 | "throwIfNamespace": false, 8 | "runtime": "automatic", 9 | "importSource": "@pago/reactive" 10 | } 11 | ] 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /.github/workflows/size.yml: -------------------------------------------------------------------------------- 1 | name: size 2 | on: [pull_request] 3 | jobs: 4 | size: 5 | runs-on: ubuntu-latest 6 | env: 7 | CI_JOB_NUMBER: 1 8 | steps: 9 | - uses: actions/checkout@v1 10 | - uses: andresz1/size-limit-action@v1 11 | with: 12 | github_token: ${{ secrets.GITHUB_TOKEN }} 13 | -------------------------------------------------------------------------------- /.storybook/main.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | stories: ['../stories/**/*.stories.@(ts|tsx|js|jsx)'], 3 | addons: ['@storybook/addon-links', '@storybook/addon-essentials'], 4 | // https://storybook.js.org/docs/react/configure/typescript#mainjs-configuration 5 | typescript: { 6 | check: true, // type-check stories during Storybook build 7 | } 8 | }; 9 | -------------------------------------------------------------------------------- /example/index.tsx: -------------------------------------------------------------------------------- 1 | import 'react-app-polyfill/ie11'; 2 | import * as React from 'react'; 3 | import * as ReactDOM from 'react-dom'; 4 | import { Thing } from '../.'; 5 | 6 | const App = () => { 7 | return ( 8 |
9 | 10 |
11 | ); 12 | }; 13 | 14 | ReactDOM.render(, document.getElementById('root')); 15 | -------------------------------------------------------------------------------- /docs/api/reactive.ref.current.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | [Home](./index.md) > [@pago/reactive](./reactive.md) > [Ref](./reactive.ref.md) > [current](./reactive.ref.current.md) 4 | 5 | ## Ref.current property 6 | 7 | Signature: 8 | 9 | ```typescript 10 | current: T; 11 | ``` 12 | -------------------------------------------------------------------------------- /docs/api/reactive.readonlyref.current.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | [Home](./index.md) > [@pago/reactive](./reactive.md) > [ReadonlyRef](./reactive.readonlyref.md) > [current](./reactive.readonlyref.current.md) 4 | 5 | ## ReadonlyRef.current property 6 | 7 | Signature: 8 | 9 | ```typescript 10 | readonly current: T; 11 | ``` 12 | -------------------------------------------------------------------------------- /example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Playground 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | export function mergePropsIntoReactive(props: T, newProps: T) { 2 | // find all prop names that were present in the old set but are missing in the new 3 | const oldPropNames = new Set(Object.keys(props)); 4 | Object.keys(newProps).forEach(prop => oldPropNames.delete(prop)); 5 | oldPropNames.forEach(prop => delete (props as any)[prop]); 6 | Object.assign(props, newProps); 7 | return props; 8 | } 9 | -------------------------------------------------------------------------------- /docs/api/reactive.effect.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | [Home](./index.md) > [@pago/reactive](./reactive.md) > [Effect](./reactive.effect.md) 4 | 5 | ## Effect interface 6 | 7 | A function that represents a pure side effect with no input and no output. 8 | 9 | Signature: 10 | 11 | ```typescript 12 | export interface Effect 13 | ``` 14 | -------------------------------------------------------------------------------- /docs/api/reactive.jsx.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | [Home](./index.md) > [@pago/reactive](./reactive.md) > [jsx](./reactive.jsx.md) 4 | 5 | ## jsx variable 6 | 7 | An interceptor for the standard React `jsx` function from the `react/jsx-runtime` package. 8 | 9 | Signature: 10 | 11 | ```typescript 12 | jsx: (type: any, ...rest: any[]) => JSX.Element 13 | ``` 14 | -------------------------------------------------------------------------------- /docs/api/reactive.jsxs.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | [Home](./index.md) > [@pago/reactive](./reactive.md) > [jsxs](./reactive.jsxs.md) 4 | 5 | ## jsxs variable 6 | 7 | An interceptor for the standard React `jsxs` function from the `react/jsx-runtime` package. 8 | 9 | Signature: 10 | 11 | ```typescript 12 | jsxs: (type: any, ...rest: any[]) => JSX.Element 13 | ``` 14 | -------------------------------------------------------------------------------- /examples/nextjs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "learn-starter", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start" 9 | }, 10 | "dependencies": { 11 | "@babel/plugin-transform-react-jsx": "^7.12.7", 12 | "@pago/reactive": "file:../../reactive-v0.0.1-2.tgz", 13 | "next": "^10.0.0", 14 | "react": "16.13.1", 15 | "react-dom": "16.13.1" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | export { memoize, watchEffect, SubscriptionController, Effect } from './tag'; 2 | export { 3 | reactive, 4 | ref, 5 | toRefs, 6 | toRef, 7 | derived, 8 | readonly, 9 | ReadonlyRef, 10 | Ref, 11 | RefContainer, 12 | RefObject, 13 | Store, 14 | } from './reactive'; 15 | export { wrap, inject, r, fromHook, effect } from './component'; 16 | export { createElement, jsx, jsxs } from './jsx-runtime'; 17 | export { useRefValue } from './hooks'; 18 | -------------------------------------------------------------------------------- /test/derived.test.ts: -------------------------------------------------------------------------------- 1 | import { derived, ref } from '../src'; 2 | 3 | describe('derived', () => { 4 | test('returns the initial value', () => { 5 | const x = ref(2); 6 | const double = derived(() => x.current * 2); 7 | expect(double.current).toBe(4); 8 | }); 9 | 10 | test('keeps the value up to date', () => { 11 | const x = ref(2); 12 | const double = derived(() => x.current * 2); 13 | x.current = 4; 14 | expect(double.current).toBe(8); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /docs/api/reactive.createelement.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | [Home](./index.md) > [@pago/reactive](./reactive.md) > [createElement](./reactive.createelement.md) 4 | 5 | ## createElement variable 6 | 7 | An interceptor for the standard React `createElement` function from the `react` package. 8 | 9 | Signature: 10 | 11 | ```typescript 12 | createElement: (type: any, ...rest: any[]) => JSX.Element 13 | ``` 14 | -------------------------------------------------------------------------------- /examples/nextjs/components/counter.js: -------------------------------------------------------------------------------- 1 | import { ref } from '@pago/reactive'; 2 | 3 | export function Counter() { 4 | const count = ref(0); 5 | 6 | return () => ( 7 |
8 |

Count: {count.current}

9 |
10 | 13 | 16 |
17 |
18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /examples/nextjs/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | 21 | # debug 22 | npm-debug.log* 23 | yarn-debug.log* 24 | yarn-error.log* 25 | 26 | # local env files 27 | .env.local 28 | .env.development.local 29 | .env.test.local 30 | .env.production.local 31 | -------------------------------------------------------------------------------- /example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": false, 4 | "target": "es5", 5 | "module": "commonjs", 6 | "jsx": "react", 7 | "moduleResolution": "node", 8 | "noImplicitAny": false, 9 | "noUnusedLocals": false, 10 | "noUnusedParameters": false, 11 | "removeComments": true, 12 | "strictNullChecks": true, 13 | "preserveConstEnums": true, 14 | "sourceMap": true, 15 | "lib": ["es2015", "es2016", "dom"], 16 | "types": ["node"] 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /docs/api/reactive.refcontainer.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | [Home](./index.md) > [@pago/reactive](./reactive.md) > [RefContainer](./reactive.refcontainer.md) 4 | 5 | ## RefContainer type 6 | 7 | An object with only [RefObject](./reactive.refobject.md) values. 8 | 9 | Signature: 10 | 11 | ```typescript 12 | export declare type RefContainer = { 13 | readonly [P in keyof T]: RefObject; 14 | }; 15 | ``` 16 | References: [RefObject](./reactive.refobject.md) 17 | 18 | -------------------------------------------------------------------------------- /docs/api/reactive.subscriptioncontroller.cleanup.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | [Home](./index.md) > [@pago/reactive](./reactive.md) > [SubscriptionController](./reactive.subscriptioncontroller.md) > [cleanup](./reactive.subscriptioncontroller.cleanup.md) 4 | 5 | ## SubscriptionController.cleanup property 6 | 7 | A cleanup effect that should be executed before the effect is executed again or on unsubscribe. 8 | 9 | Signature: 10 | 11 | ```typescript 12 | cleanup?: Effect; 13 | ``` 14 | -------------------------------------------------------------------------------- /docs/api/reactive.store.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | [Home](./index.md) > [@pago/reactive](./reactive.md) > [Store](./reactive.store.md) 4 | 5 | ## Store type 6 | 7 | An object that inlines all [Ref](./reactive.ref.md) values and enables using them transparently. 8 | 9 | Signature: 10 | 11 | ```typescript 12 | export declare type Store = { 13 | [P in keyof T]: T[P] extends Ref ? T[P]['current'] : T[P]; 14 | }; 15 | ``` 16 | References: [Ref](./reactive.ref.md) 17 | 18 | -------------------------------------------------------------------------------- /docs/api/reactive.subscriptioncontroller.effect.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | [Home](./index.md) > [@pago/reactive](./reactive.md) > [SubscriptionController](./reactive.subscriptioncontroller.md) > [effect](./reactive.subscriptioncontroller.effect.md) 4 | 5 | ## SubscriptionController.effect property 6 | 7 | The effect that is triggered whenever a tracked value changes after the controller has subscribed to changes. 8 | 9 | Signature: 10 | 11 | ```typescript 12 | effect: Effect; 13 | ``` 14 | -------------------------------------------------------------------------------- /docs/api/reactive.subscriptioncontroller.unsubscribe.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | [Home](./index.md) > [@pago/reactive](./reactive.md) > [SubscriptionController](./reactive.subscriptioncontroller.md) > [unsubscribe](./reactive.subscriptioncontroller.unsubscribe.md) 4 | 5 | ## SubscriptionController.unsubscribe() method 6 | 7 | Unsubscribes from all tracked values. 8 | 9 | Signature: 10 | 11 | ```typescript 12 | unsubscribe(): void; 13 | ``` 14 | Returns: 15 | 16 | void 17 | 18 | -------------------------------------------------------------------------------- /docs/api/reactive.readonlyref.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | [Home](./index.md) > [@pago/reactive](./reactive.md) > [ReadonlyRef](./reactive.readonlyref.md) 4 | 5 | ## ReadonlyRef interface 6 | 7 | A tracked reference to a value that can't be modified. 8 | 9 | Signature: 10 | 11 | ```typescript 12 | export interface ReadonlyRef 13 | ``` 14 | 15 | ## Properties 16 | 17 | | Property | Type | Description | 18 | | --- | --- | --- | 19 | | [current](./reactive.readonlyref.current.md) | T | | 20 | 21 | -------------------------------------------------------------------------------- /docs/api/reactive.refobject.update.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | [Home](./index.md) > [@pago/reactive](./reactive.md) > [RefObject](./reactive.refobject.md) > [update](./reactive.refobject.update.md) 4 | 5 | ## RefObject.update() method 6 | 7 | Signature: 8 | 9 | ```typescript 10 | update(fn: (value: T) => T): void; 11 | ``` 12 | 13 | ## Parameters 14 | 15 | | Parameter | Type | Description | 16 | | --- | --- | --- | 17 | | fn | (value: T) => T | | 18 | 19 | Returns: 20 | 21 | void 22 | 23 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "license": "MIT", 6 | "scripts": { 7 | "start": "parcel index.html", 8 | "build": "parcel build index.html" 9 | }, 10 | "dependencies": { 11 | "react-app-polyfill": "^1.0.0" 12 | }, 13 | "alias": { 14 | "react": "../node_modules/react", 15 | "react-dom": "../node_modules/react-dom/profiling", 16 | "scheduler/tracing": "../node_modules/scheduler/tracing-profiling" 17 | }, 18 | "devDependencies": { 19 | "@types/react": "^16.9.11", 20 | "@types/react-dom": "^16.8.4", 21 | "parcel": "^1.12.3", 22 | "typescript": "^3.4.5" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /docs/api/reactive.subscriptioncontroller.subscribe.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | [Home](./index.md) > [@pago/reactive](./reactive.md) > [SubscriptionController](./reactive.subscriptioncontroller.md) > [subscribe](./reactive.subscriptioncontroller.subscribe.md) 4 | 5 | ## SubscriptionController.subscribe() method 6 | 7 | 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. 8 | 9 | Signature: 10 | 11 | ```typescript 12 | subscribe(): void; 13 | ``` 14 | Returns: 15 | 16 | void 17 | 18 | -------------------------------------------------------------------------------- /docs/api/reactive.refobject.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | [Home](./index.md) > [@pago/reactive](./reactive.md) > [RefObject](./reactive.refobject.md) 4 | 5 | ## RefObject interface 6 | 7 | An Ref object that supports reading & writing in the same tracked scope by providing a specific [RefObject.update()](./reactive.refobject.update.md) method. 8 | 9 | Signature: 10 | 11 | ```typescript 12 | export interface RefObject extends Ref 13 | ``` 14 | Extends: [Ref](./reactive.ref.md)<T> 15 | 16 | ## Methods 17 | 18 | | Method | Description | 19 | | --- | --- | 20 | | [update(fn)](./reactive.refobject.update.md) | | 21 | 22 | -------------------------------------------------------------------------------- /docs/api/reactive.inject.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | [Home](./index.md) > [@pago/reactive](./reactive.md) > [inject](./reactive.inject.md) 4 | 5 | ## inject() function 6 | 7 | Injects a React.Context into a Reactive Function Component. 8 | 9 | Signature: 10 | 11 | ```typescript 12 | export declare function inject(context: Context): ReadonlyRef; 13 | ``` 14 | 15 | ## Parameters 16 | 17 | | Parameter | Type | Description | 18 | | --- | --- | --- | 19 | | context | Context<T> | The React.Context that should be injected into your component. | 20 | 21 | Returns: 22 | 23 | [ReadonlyRef](./reactive.readonlyref.md)<T> 24 | 25 | -------------------------------------------------------------------------------- /docs/api/reactive.r.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | [Home](./index.md) > [@pago/reactive](./reactive.md) > [r](./reactive.r.md) 4 | 5 | ## r() function 6 | 7 | This function is a pure type-cast to avoid TypeScript from complaining when using a Reactive Function Component without [wrap()](./reactive.wrap.md). 8 | 9 | Signature: 10 | 11 | ```typescript 12 | export declare function r(render: () => JSX.Element): JSX.Element; 13 | ``` 14 | 15 | ## Parameters 16 | 17 | | Parameter | Type | Description | 18 | | --- | --- | --- | 19 | | render | () => JSX.Element | The render function of a component | 20 | 21 | Returns: 22 | 23 | JSX.Element 24 | 25 | -------------------------------------------------------------------------------- /docs/api/reactive.subscriptioncontroller._constructor_.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | [Home](./index.md) > [@pago/reactive](./reactive.md) > [SubscriptionController](./reactive.subscriptioncontroller.md) > [(constructor)](./reactive.subscriptioncontroller._constructor_.md) 4 | 5 | ## SubscriptionController.(constructor) 6 | 7 | Creates a new SubscriptionController. 8 | 9 | Signature: 10 | 11 | ```typescript 12 | constructor(effect: Effect); 13 | ``` 14 | 15 | ## Parameters 16 | 17 | | Parameter | Type | Description | 18 | | --- | --- | --- | 19 | | effect | [Effect](./reactive.effect.md) | The effect that should be executed whenever a tracked reference was changed. | 20 | 21 | -------------------------------------------------------------------------------- /docs/api/reactive.readonly.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | [Home](./index.md) > [@pago/reactive](./reactive.md) > [readonly](./reactive.readonly.md) 4 | 5 | ## readonly() function 6 | 7 | Converts a mutable [Ref](./reactive.ref.md) to a [ReadonlyRef](./reactive.readonlyref.md). 8 | 9 | Signature: 10 | 11 | ```typescript 12 | export declare function readonly(ref: Ref): ReadonlyRef; 13 | ``` 14 | 15 | ## Parameters 16 | 17 | | Parameter | Type | Description | 18 | | --- | --- | --- | 19 | | ref | [Ref](./reactive.ref.md)<T> | A mutable tracked reference | 20 | 21 | Returns: 22 | 23 | [ReadonlyRef](./reactive.readonlyref.md)<T> 24 | 25 | -------------------------------------------------------------------------------- /src/hooks.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | import { RefObject } from './reactive'; 3 | import { watchEffect } from './tag'; 4 | 5 | /** 6 | * Returns the current value of a {@link RefObject} and starts 7 | * to track its value once the component has been mounted. 8 | * 9 | * An update will be scheduled if the value of the reference has changed 10 | * between the first render of the component and mounting it. 11 | * 12 | * @param ref - A tracked reference object. 13 | * @public 14 | */ 15 | export function useRefValue(ref: RefObject) { 16 | const [state, setState] = useState(ref.current); 17 | useEffect(() => { 18 | return watchEffect(() => { 19 | if (state !== ref.current) { 20 | setState(ref.current); 21 | } 22 | }); 23 | }, [ref, state]); 24 | return state; 25 | } 26 | -------------------------------------------------------------------------------- /docs/api/reactive.torefs.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | [Home](./index.md) > [@pago/reactive](./reactive.md) > [toRefs](./reactive.torefs.md) 4 | 5 | ## toRefs() function 6 | 7 | Converts a tracked object into an object of [Ref](./reactive.ref.md) instances. 8 | 9 | Signature: 10 | 11 | ```typescript 12 | export declare function toRefs(store: Store): RefContainer; 15 | ``` 16 | 17 | ## Parameters 18 | 19 | | Parameter | Type | Description | 20 | | --- | --- | --- | 21 | | store | [Store](./reactive.store.md)<T> | A tracked object created through [reactive()](./reactive.reactive.md). | 22 | 23 | Returns: 24 | 25 | [RefContainer](./reactive.refcontainer.md)<T> 26 | 27 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [push] 3 | jobs: 4 | build: 5 | name: Build, lint, and test on Node ${{ matrix.node }} and ${{ matrix.os }} 6 | 7 | runs-on: ${{ matrix.os }} 8 | strategy: 9 | matrix: 10 | node: ['10.x', '12.x', '14.x'] 11 | os: [ubuntu-latest, windows-latest, macOS-latest] 12 | 13 | steps: 14 | - name: Checkout repo 15 | uses: actions/checkout@v2 16 | 17 | - name: Use Node ${{ matrix.node }} 18 | uses: actions/setup-node@v1 19 | with: 20 | node-version: ${{ matrix.node }} 21 | 22 | - name: Install deps and build (with cache) 23 | uses: bahmutov/npm-install@v1 24 | 25 | - name: Lint 26 | run: yarn lint 27 | 28 | - name: Test 29 | run: yarn test --ci --coverage --maxWorkers=2 30 | 31 | - name: Build 32 | run: yarn build 33 | -------------------------------------------------------------------------------- /docs/api/reactive.userefvalue.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | [Home](./index.md) > [@pago/reactive](./reactive.md) > [useRefValue](./reactive.userefvalue.md) 4 | 5 | ## useRefValue() function 6 | 7 | Returns the current value of a [RefObject](./reactive.refobject.md) and starts to track its value once the component has been mounted. 8 | 9 | An update will be scheduled if the value of the reference has changed between the first render of the component and mounting it. 10 | 11 | Signature: 12 | 13 | ```typescript 14 | export declare function useRefValue(ref: RefObject): T; 15 | ``` 16 | 17 | ## Parameters 18 | 19 | | Parameter | Type | Description | 20 | | --- | --- | --- | 21 | | ref | [RefObject](./reactive.refobject.md)<T> | A tracked reference object. | 22 | 23 | Returns: 24 | 25 | T 26 | 27 | -------------------------------------------------------------------------------- /docs/api/reactive.fromhook.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | [Home](./index.md) > [@pago/reactive](./reactive.md) > [fromHook](./reactive.fromhook.md) 4 | 5 | ## fromHook() function 6 | 7 | The function passed to `fromHook` will always be executed when rendering the component. 8 | 9 | Signature: 10 | 11 | ```typescript 12 | export declare function fromHook(fn: () => T): Ref; 13 | ``` 14 | 15 | ## Parameters 16 | 17 | | Parameter | Type | Description | 18 | | --- | --- | --- | 19 | | fn | () => T | A callback that uses React Hooks to calculate an observed value. | 20 | 21 | Returns: 22 | 23 | [Ref](./reactive.ref.md)<T> 24 | 25 | ## Example 26 | 27 | 28 | ``` 29 | const screenSize = fromHook(() => useScreenSize()); 30 | effect(() => console.log(screenSize.current)); 31 | 32 | ``` 33 | 34 | -------------------------------------------------------------------------------- /docs/api/reactive.ref.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | [Home](./index.md) > [@pago/reactive](./reactive.md) > [Ref](./reactive.ref.md) 4 | 5 | ## Ref interface 6 | 7 | A tracked reference to a value. Reading it from it should mark it as "read" in the current scope, writing to it should mark it as dirty. 8 | 9 | When a `Ref` is marked as dirty, any watcher or derivative will eventually be updated to its new value. 10 | 11 | Note that it is not possible to read and update a ref within the same tracked scope. 12 | 13 | Signature: 14 | 15 | ```typescript 16 | export interface Ref extends ReadonlyRef 17 | ``` 18 | Extends: [ReadonlyRef](./reactive.readonlyref.md)<T> 19 | 20 | ## Properties 21 | 22 | | Property | Type | Description | 23 | | --- | --- | --- | 24 | | [current](./reactive.ref.current.md) | T | | 25 | 26 | -------------------------------------------------------------------------------- /test/tag.test.ts: -------------------------------------------------------------------------------- 1 | import { consumeTag, dirtyTag, createTag, memoize } from '../src/tag'; 2 | 3 | describe('tag', () => { 4 | test('it memoizes the function', () => { 5 | const fn = memoize(() => { 6 | return {}; 7 | }); 8 | const firstValue = fn(); 9 | const secondValue = fn(); 10 | expect(firstValue).toBe(secondValue); 11 | }); 12 | 13 | test('when an unused tag is dirtied the return value is still cached', () => { 14 | const tag = createTag(); 15 | const fn = memoize(() => { 16 | return {}; 17 | }); 18 | const firstValue = fn(); 19 | dirtyTag(tag); 20 | const secondValue = fn(); 21 | expect(firstValue).toBe(secondValue); 22 | }); 23 | 24 | test('it executes the function again when a dirty tag is consumed', () => { 25 | const tag = createTag(); 26 | const fn = memoize(() => { 27 | consumeTag(tag); 28 | return {}; 29 | }); 30 | const firstValue = fn(); 31 | dirtyTag(tag); 32 | const secondValue = fn(); 33 | expect(firstValue).not.toBe(secondValue); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /examples/nextjs/public/vercel.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Patrick Gotthardt 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. -------------------------------------------------------------------------------- /docs/api/reactive.toref.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | [Home](./index.md) > [@pago/reactive](./reactive.md) > [toRef](./reactive.toref.md) 4 | 5 | ## toRef() function 6 | 7 | Extracts a single property from a tracked object into a [RefObject](./reactive.refobject.md). 8 | 9 | Signature: 10 | 11 | ```typescript 12 | export declare function toRef(store: T, prop: K): RefObject; 13 | ``` 14 | 15 | ## Parameters 16 | 17 | | Parameter | Type | Description | 18 | | --- | --- | --- | 19 | | store | T | A tracked object that was created through [reactive()](./reactive.reactive.md). | 20 | | prop | K | The name of the property that should be extracted into a [RefObject](./reactive.refobject.md) | 21 | 22 | Returns: 23 | 24 | [RefObject](./reactive.refobject.md)<T\[K\]> 25 | 26 | ## Example 27 | 28 | 29 | ```js 30 | const state = reactive({ message: 'hello' }); 31 | const message = toRef(state, 'message'); 32 | console.log(message.current); 33 | 34 | ``` 35 | 36 | -------------------------------------------------------------------------------- /docs/api/reactive.derived.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | [Home](./index.md) > [@pago/reactive](./reactive.md) > [derived](./reactive.derived.md) 4 | 5 | ## derived() function 6 | 7 | Returns a [ReadonlyRef](./reactive.readonlyref.md) whose value will always point to the latest result of the given function. The function will only be executed once per set of values. 8 | 9 | Signature: 10 | 11 | ```typescript 12 | export declare function derived(fn: () => T): ReadonlyRef; 13 | ``` 14 | 15 | ## Parameters 16 | 17 | | Parameter | Type | Description | 18 | | --- | --- | --- | 19 | | fn | () => T | A function which returns a derivation of tracked objects or references. | 20 | 21 | Returns: 22 | 23 | [ReadonlyRef](./reactive.readonlyref.md)<T> 24 | 25 | ## Example 26 | 27 | 28 | ```js 29 | const name = ref('Preact'); 30 | const greet = derived(() => `Hello ${name.current}!`); 31 | console.log(greet.current); // => 'Hello Preact' 32 | name.current = 'React'; 33 | console.log(greet.current); // => 'Hello React' 34 | 35 | ``` 36 | 37 | -------------------------------------------------------------------------------- /stories/Counter.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 Counter(props: Props) { 10 | const count = ref(0); 11 | 12 | return r(() => ( 13 |
14 |
Count: {count.current}
15 |
16 | 19 | 22 |
23 |
24 | )); 25 | } 26 | 27 | const meta: Meta = { 28 | title: 'Counter', 29 | component: Counter, 30 | parameters: { 31 | controls: { expanded: true }, 32 | }, 33 | }; 34 | 35 | export default meta; 36 | 37 | const Template: Story = args => ; 38 | 39 | // By passing using the Args format for exported stories, you can control the props for a component for reuse in a test 40 | // https://storybook.js.org/docs/react/workflows/unit-testing 41 | export const Default = Template.bind({}); 42 | 43 | Default.args = { 44 | step: 1, 45 | }; 46 | -------------------------------------------------------------------------------- /examples/nextjs/README.md: -------------------------------------------------------------------------------- 1 | # Next.js with `@pago/reactive` 2 | 3 | ## 1. Install `@pago/reactive` through npm/yarn 4 | 5 | ```sh 6 | yarn add @pago/reactive 7 | ``` 8 | 9 | ## 2. Configure Babel to use `@pago/reactive/jsx-runtime` 10 | 11 | Install the plugin: 12 | 13 | ```sh 14 | yarn add @babel/plugin-transform-react-jsx 15 | ``` 16 | 17 | Then add the `.babelrc` configuration file: 18 | 19 | ```json 20 | { 21 | "presets": ["next/babel"], 22 | "plugins": [ 23 | [ 24 | "@babel/plugin-transform-react-jsx", 25 | { 26 | "throwIfNamespace": false, 27 | "runtime": "automatic", 28 | "importSource": "@pago/reactive" 29 | } 30 | ] 31 | ] 32 | } 33 | ``` 34 | 35 | ## 3. Custom App 36 | 37 | Because Next.js is controlling the components that are mounted and we don't control the initial mounting ourselves, we need to have a top-level component (either `App` or `Document`) that is a standard React component instead of a Reactive Component. From there on, all our components can just be Reactive Components. 38 | 39 | In this case we decided to implement an `./pages/_app.js`. 40 | 41 | ```js 42 | function App({ Component, pageProps }) { 43 | return ; 44 | } 45 | 46 | export default App; 47 | ``` 48 | -------------------------------------------------------------------------------- /stories/Timer.stories.tsx: -------------------------------------------------------------------------------- 1 | /** @jsxImportSource ../src */ 2 | import { Meta, Story } from '@storybook/react'; 3 | import {ref, effect, r} from '../src'; 4 | 5 | interface Props { 6 | step: number; 7 | delay: number; 8 | } 9 | 10 | const Timer = function Timer(props: Props) { 11 | const count = ref(0); 12 | 13 | effect(onInvalidate => { 14 | const timer = setInterval(() => { 15 | // update is needed because we are reading from and writing to count 16 | count.update(current => current + props.step); 17 | }, props.delay); 18 | 19 | onInvalidate(() => clearInterval(timer)); 20 | }); 21 | 22 | return r(() => ( 23 |
24 |
Count: {count.current}
25 |
26 | )); 27 | }; 28 | 29 | const meta: Meta = { 30 | title: 'Timer', 31 | component: Timer, 32 | parameters: { 33 | controls: { expanded: true }, 34 | }, 35 | }; 36 | 37 | export default meta; 38 | 39 | const Template: Story = args => ; 40 | 41 | // By passing using the Args format for exported stories, you can control the props for a component for reuse in a test 42 | // https://storybook.js.org/docs/react/workflows/unit-testing 43 | export const Default = Template.bind({}); 44 | 45 | Default.args = { 46 | step: 1, 47 | delay: 1000, 48 | }; 49 | -------------------------------------------------------------------------------- /docs/api/reactive.memoize.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | [Home](./index.md) > [@pago/reactive](./reactive.md) > [memoize](./reactive.memoize.md) 4 | 5 | ## memoize() function 6 | 7 | Returns a function that is only executed again if any of its tracked values have changed. The `controller` can be used to establish a notification system and is largely irrelevant to end users of the API. 8 | 9 | Signature: 10 | 11 | ```typescript 12 | export declare function memoize(fn: () => T, controller?: SubscriptionController): () => T; 13 | ``` 14 | 15 | ## Parameters 16 | 17 | | Parameter | Type | Description | 18 | | --- | --- | --- | 19 | | fn | () => T | A memoized function. | 20 | | controller | [SubscriptionController](./reactive.subscriptioncontroller.md) | A controller that can be used to manage subscribing to tracked values. | 21 | 22 | Returns: 23 | 24 | () => T 25 | 26 | ## Example 27 | 28 | 29 | ``` 30 | const person = ref('Preact'); 31 | const message = memoize(() => `Hello ${person.current}`); 32 | 33 | console.log(message()); // => 'Hello Preact' 34 | console.log(message()); // => 'Hello Preact', but this time the memoized function was not executed at all 35 | 36 | ``` 37 | 38 | -------------------------------------------------------------------------------- /src/jsx-runtime.ts: -------------------------------------------------------------------------------- 1 | // For some reason the project seems to think that `jsx` and `jsxs` don't exist. Yet, they work fine... 2 | // @ts-expect-error 3 | import { jsx as _jsx, jsxs as _jsxs } from 'react/jsx-runtime'; 4 | import { wrap } from './component'; 5 | import { createElement as _createElement } from 'react'; 6 | 7 | const map = new WeakMap(); 8 | const withFactory = (factory: (...args: any) => JSX.Element) => ( 9 | type: any, 10 | ...rest: any[] 11 | ) => { 12 | if ( 13 | typeof type === 'function' && 14 | !('prototype' in type && type.prototype.render) 15 | ) { 16 | // it's a function component 17 | if (!map.has(type)) { 18 | map.set(type, wrap(type)); 19 | } 20 | type = map.get(type); 21 | } 22 | return factory(type, ...rest); 23 | }; 24 | 25 | /** 26 | * An interceptor for the standard React `jsx` function from the `react/jsx-runtime` package. 27 | * @public 28 | */ 29 | export const jsx = withFactory(_jsx); 30 | /** 31 | * An interceptor for the standard React `jsxs` function from the `react/jsx-runtime` package. 32 | * @public 33 | */ 34 | export const jsxs = withFactory(_jsxs); 35 | /** 36 | * An interceptor for the standard React `createElement` function from the `react` package. 37 | * @public 38 | */ 39 | export const createElement = withFactory(_createElement); 40 | -------------------------------------------------------------------------------- /stories/Context.stories.tsx: -------------------------------------------------------------------------------- 1 | /** @jsxImportSource ../src */ 2 | import { createContext } from 'react'; 3 | import { Meta, Story } from '@storybook/react'; 4 | 5 | import {inject, r, watchEffect} from '../src'; 6 | 7 | const ColorContext = createContext('red'); 8 | 9 | interface Props { 10 | color: string; 11 | } 12 | 13 | function App(props: Props) { 14 | watchEffect(() => { 15 | console.log(`New color: "${props.color}"`); 16 | }); 17 | return r(() => ( 18 |
19 |

Current color is "{props.color}"

20 | 21 | 22 | 23 |
24 | )); 25 | } 26 | 27 | function Text() { 28 | const color = inject(ColorContext); 29 | return r(() =>

Hello World!

); 30 | } 31 | 32 | const meta: Meta = { 33 | title: 'Static 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 | -------------------------------------------------------------------------------- /stories/DynamicContext.stories.tsx: -------------------------------------------------------------------------------- 1 | /** @jsxImportSource ../src */ 2 | import { createContext, useContext } from 'react'; 3 | import { Meta, Story } from '@storybook/react'; 4 | import { toRefs, r, watchEffect } from '../src'; 5 | 6 | const ColorContext = createContext({ current: 'red' }); 7 | 8 | interface Props { 9 | color: string; 10 | } 11 | 12 | function App(props: Props) { 13 | watchEffect(() => { 14 | console.log(`New color: "${props.color}"`); 15 | }); 16 | const { color } = toRefs(props); 17 | return r(() => ( 18 |
19 |

"Random" message of the day...

20 | 21 | 22 | 23 |
24 | )); 25 | } 26 | 27 | function Text() { 28 | const color = useContext(ColorContext); 29 | return r(() =>

Hello World!

); 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 |
52 | )); 53 | } 54 | 55 | interface TextModel { 56 | message: { current: string }; 57 | } 58 | function Text({ message }: TextModel) { 59 | console.log('Text: Mounting component'); 60 | watchEffect((onInvalidate) => { 61 | console.log('Text: Starting expensive process...'); 62 | onInvalidate(() => console.log('Text: Expensive process cleaned up')); 63 | }); 64 | return r(() =>

{message.current}

); 65 | } 66 | 67 | function LoudText({ message }: TextModel) { 68 | console.log(`LoudText: Mounting component`); 69 | watchEffect((onInvalidate) => { 70 | console.log('LoudText: Starting expensive process...'); 71 | onInvalidate(() => console.log('LoudText: Expensive process cleaned up')); 72 | }); 73 | const loudMessage = message.current + '!'; 74 | return r(() =>

{loudMessage}

); 75 | } 76 | 77 | function Placeholder() { 78 | watchEffect(function logMounting(onInvalidate) { 79 | console.log('Placeholder: Start'); 80 | onInvalidate(() => console.log(`Placeholder: Cleanup`)); 81 | }); 82 | return r(() => ( 83 |

Just some text

84 | )); 85 | } 86 | 87 | function delay() { 88 | let resolve: (value?: T) => void = undefined as any; 89 | const signal = new Promise(res => (resolve = res)); 90 | return { signal, resolve }; 91 | } 92 | 93 | const meta: Meta = { 94 | title: 'Suspense', 95 | component: App, 96 | parameters: { 97 | controls: { expanded: true }, 98 | }, 99 | }; 100 | 101 | export default meta; 102 | 103 | const Template: Story = args => ; 104 | 105 | // By passing using the Args format for exported stories, you can control the props for a component for reuse in a test 106 | // https://storybook.js.org/docs/react/workflows/unit-testing 107 | export const Default = Template.bind({}); 108 | 109 | Default.args = {}; 110 | -------------------------------------------------------------------------------- /etc/reactive.api.md: -------------------------------------------------------------------------------- 1 | ## API Report File for "@pago/reactive" 2 | 3 | > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). 4 | 5 | ```ts 6 | 7 | import { Context } from 'react'; 8 | import { ReactElement } from 'react'; 9 | 10 | // @public 11 | export const createElement: (type: any, ...rest: any[]) => JSX.Element; 12 | 13 | // @public 14 | export function derived(fn: () => T): ReadonlyRef; 15 | 16 | // @public 17 | export interface Effect { 18 | // (undocumented) 19 | (): void; 20 | } 21 | 22 | // Warning: (ae-forgotten-export) The symbol "Effect" needs to be exported by the entry point index.d.ts 23 | // 24 | // @public 25 | export function effect(fn: (onInvalidate: (teardown: Effect_2) => void) => void): void; 26 | 27 | // @public 28 | export function fromHook(fn: () => T): Ref; 29 | 30 | // @public 31 | export function inject(context: Context): ReadonlyRef; 32 | 33 | // @public 34 | export const jsx: (type: any, ...rest: any[]) => JSX.Element; 35 | 36 | // @public 37 | export const jsxs: (type: any, ...rest: any[]) => JSX.Element; 38 | 39 | // @public 40 | export function memoize(fn: () => T, controller?: SubscriptionController): () => T; 41 | 42 | // @public 43 | export function r(render: () => JSX.Element): JSX.Element; 44 | 45 | // @public 46 | export function reactive(initialValue: T): Store; 47 | 48 | // @public 49 | export function readonly(ref: Ref): ReadonlyRef; 50 | 51 | // @public 52 | export interface ReadonlyRef { 53 | // (undocumented) 54 | readonly current: T; 55 | } 56 | 57 | // @public 58 | export interface Ref extends ReadonlyRef { 59 | // (undocumented) 60 | current: T; 61 | } 62 | 63 | // @public 64 | export function ref(initialValue: T): RefObject; 65 | 66 | // @public 67 | export type RefContainer = { 68 | readonly [P in keyof T]: RefObject; 69 | }; 70 | 71 | // @public 72 | export interface RefObject extends Ref { 73 | // (undocumented) 74 | update(fn: (value: T) => T): void; 75 | } 76 | 77 | // @public 78 | export type Store = { 79 | [P in keyof T]: T[P] extends Ref ? T[P]['current'] : T[P]; 80 | }; 81 | 82 | // @public 83 | export class SubscriptionController { 84 | constructor(effect: Effect); 85 | cleanup?: Effect; 86 | effect: Effect; 87 | // Warning: (ae-forgotten-export) The symbol "Tag" needs to be exported by the entry point index.d.ts 88 | // Warning: (ae-forgotten-export) The symbol "Revision" needs to be exported by the entry point index.d.ts 89 | // 90 | // @internal (undocumented) 91 | setObservedTags(tags: Array, lastRevision: Revision): void; 92 | subscribe(): void; 93 | unsubscribe(): void; 94 | } 95 | 96 | // @public 97 | export function toRef(store: T, prop: K): RefObject; 98 | 99 | // @public 100 | export function toRefs(store: Store): RefContainer; 103 | 104 | // @public 105 | export function useRefValue(ref: RefObject): T; 106 | 107 | // @public 108 | export function watchEffect(fn: (onInvalidate: (teardown: Effect) => void) => void): Effect; 109 | 110 | // Warning: (ae-forgotten-export) The symbol "RenderFunction" needs to be exported by the entry point index.d.ts 111 | // Warning: (ae-forgotten-export) The symbol "RenderResult" needs to be exported by the entry point index.d.ts 112 | // 113 | // @public 114 | export function wrap(construct: (props: T) => RenderFunction | RenderResult): { 115 | (props: T): RenderResult; 116 | displayName: any; 117 | }; 118 | 119 | 120 | // (No @packageDocumentation comment for this package) 121 | 122 | ``` 123 | -------------------------------------------------------------------------------- /test/watchEffect.test.ts: -------------------------------------------------------------------------------- 1 | import { watchEffect, ref, reactive, toRef } from '../src'; 2 | import { delay } from './util'; 3 | 4 | describe('watchEffect', () => { 5 | test('it executes', () => { 6 | const x = ref(0); 7 | let currentValue = -1; 8 | watchEffect(() => { 9 | currentValue = x.current; 10 | }); 11 | expect(currentValue).toBe(0); 12 | }); 13 | 14 | test('it executes when a ref changes', async () => { 15 | const { signal, resolve } = delay(); 16 | const x = ref(0); 17 | const y = ref(0); 18 | let currentValue = 0; 19 | watchEffect(() => { 20 | currentValue += x.current + y.current; 21 | if (currentValue !== 0) { 22 | resolve(); 23 | } 24 | }); 25 | expect(currentValue).toBe(0); 26 | setTimeout(() => { 27 | x.current = 1; 28 | y.current = 2; 29 | }, 0); 30 | await signal; 31 | expect(currentValue).toBe(3); 32 | }); 33 | 34 | test('it executes when a ref changes with update', async () => { 35 | const { signal, resolve } = delay(); 36 | const x = ref(0); 37 | const y = ref(0); 38 | const currentValue = ref(0); 39 | watchEffect(() => { 40 | currentValue.update(curr => curr + x.current + y.current); 41 | }); 42 | expect(currentValue.current).toBe(0); 43 | setTimeout(() => { 44 | x.current = 1; 45 | y.current = 2; 46 | resolve(); 47 | }, 0); 48 | await signal; 49 | expect(currentValue.current).toBe(3); 50 | }); 51 | 52 | test('it executes when a ref from a proxied ref changes with update', async () => { 53 | const { signal, resolve } = delay(); 54 | const x = ref(0); 55 | const y = ref(0); 56 | const currentValue = ref(0); 57 | const state = reactive({ currentValue }); 58 | const v = toRef(state, 'currentValue'); 59 | watchEffect(() => { 60 | v.update(curr => curr + x.current + y.current); 61 | }); 62 | expect(currentValue.current).toBe(0); 63 | setTimeout(() => { 64 | x.current = 1; 65 | y.current = 2; 66 | resolve(); 67 | }, 0); 68 | await signal; 69 | expect(currentValue.current).toBe(3); 70 | }); 71 | 72 | test('can be unsubscribed', async () => { 73 | const { signal, resolve } = delay(); 74 | const x = ref(0); 75 | const y = ref(0); 76 | let currentValue = 0; 77 | const unsubscribe = watchEffect(() => { 78 | currentValue += x.current + y.current; 79 | }); 80 | expect(currentValue).toBe(0); 81 | setTimeout(() => { 82 | x.current = 1; 83 | y.current = 2; 84 | setTimeout(() => { 85 | unsubscribe(); 86 | x.current = 2; 87 | y.current = 4; 88 | resolve(); 89 | }, 0); 90 | }, 0); 91 | await signal; 92 | expect(currentValue).toBe(3); 93 | }); 94 | 95 | test('runs a cleanup before effect', async () => { 96 | const firstTimerStarted = delay(); 97 | const timerExecuted = delay(); 98 | const x = ref(1); 99 | let currentValue = 0; 100 | let cleanupInvoked = false; 101 | watchEffect(onInvalidate => { 102 | firstTimerStarted.resolve(); 103 | let val = x.current; 104 | const timer = setTimeout(() => { 105 | currentValue = val; 106 | timerExecuted.resolve(); 107 | }, val); 108 | onInvalidate(() => { 109 | cleanupInvoked = true; 110 | clearTimeout(timer); 111 | }); 112 | }); 113 | await firstTimerStarted.signal; 114 | x.current = 0; 115 | await timerExecuted.signal; 116 | expect(currentValue).toBe(0); 117 | expect(cleanupInvoked).toBe(true); 118 | }); 119 | 120 | test('runs cleanup when unsubscribed', async () => { 121 | const firstTimerStarted = delay(); 122 | let cleanupInvoked = false; 123 | const unsubscribe = watchEffect(onInvalidate => { 124 | firstTimerStarted.resolve(); 125 | const timer = setTimeout(() => { 126 | fail('Timer has not been cancelled!'); 127 | }, 1); 128 | onInvalidate(() => { 129 | cleanupInvoked = true; 130 | clearTimeout(timer); 131 | }); 132 | }); 133 | await firstTimerStarted.signal; 134 | unsubscribe(); 135 | expect(cleanupInvoked).toBe(true); 136 | }); 137 | }); 138 | -------------------------------------------------------------------------------- /test/reactive.test.ts: -------------------------------------------------------------------------------- 1 | import { memoize, ref, reactive, toRefs, readonly } from '../src/'; 2 | 3 | describe('memoize', () => { 4 | test('it executes the function again when a ref is modified', () => { 5 | const state = ref('World'); 6 | const fn = memoize(() => { 7 | return `Hello ${state.current}!`; 8 | }); 9 | 10 | expect(fn()).toEqual('Hello World!'); 11 | state.current = 'Universe'; 12 | expect(fn()).toEqual('Hello Universe!'); 13 | }); 14 | 15 | test('it avoids execution of the function when a ref is modified but its value is unchanged', () => { 16 | const state = ref('World'); 17 | const fn = memoize(() => { 18 | return { message: state.current }; 19 | }); 20 | 21 | const firstResult = fn(); 22 | state.current = 'World'; 23 | const secondResult = fn(); 24 | 25 | expect(firstResult).toBe(secondResult); 26 | state.current = 'Universe'; 27 | const thirdResult = fn(); 28 | expect(firstResult).not.toBe(thirdResult); 29 | }); 30 | 31 | test('it executes the function again when a reactive is modified', () => { 32 | const state = reactive({ 33 | target: 'World', 34 | }); 35 | const fn = memoize(() => { 36 | return `Hello ${state.target}!`; 37 | }); 38 | 39 | expect(fn()).toEqual('Hello World!'); 40 | state.target = 'Universe'; 41 | expect(fn()).toEqual('Hello Universe!'); 42 | }); 43 | 44 | test('it executes the function again when a reactive gains a new property', () => { 45 | const state = reactive<{ target: string; greeting?: string }>({ 46 | target: 'World', 47 | }); 48 | const fn = memoize(() => { 49 | return Object.keys(state); 50 | }); 51 | 52 | expect(fn()).toEqual(['target']); 53 | state.greeting = 'Hello'; 54 | expect(fn()).toEqual(['target', 'greeting']); 55 | 56 | delete state.greeting; 57 | expect(fn()).toEqual(['target']); 58 | }); 59 | 60 | test('it executes the function again when a reactive gains a new property that is tested with "in"', () => { 61 | const state = reactive<{ target: string; greeting?: string }>({ 62 | target: 'World', 63 | }); 64 | const fn = memoize(() => { 65 | return `${'greeting' in state ? state.greeting : 'Hello'} ${ 66 | state.target 67 | }`; 68 | }); 69 | 70 | expect(fn()).toEqual('Hello World'); 71 | state.greeting = 'Good night'; 72 | expect(fn()).toEqual('Good night World'); 73 | 74 | delete state.greeting; 75 | expect(fn()).toEqual('Hello World'); 76 | }); 77 | 78 | test('it can use a ref in a reactive', () => { 79 | const target = ref('World'); 80 | const state = reactive<{ target: string; greeting?: string }>({ 81 | get target() { 82 | return target.current; 83 | }, 84 | }); 85 | const fn = memoize(() => { 86 | return `${'greeting' in state ? state.greeting : 'Hello'} ${ 87 | state.target 88 | }`; 89 | }); 90 | 91 | expect(fn()).toEqual('Hello World'); 92 | state.greeting = 'Good night'; 93 | expect(fn()).toEqual('Good night World'); 94 | 95 | delete state.greeting; 96 | expect(fn()).toEqual('Hello World'); 97 | }); 98 | 99 | test('it can convert a reactive to refs', () => { 100 | const state = reactive({ 101 | target: 'World', 102 | greeting: 'Hello', 103 | }); 104 | const { target, greeting } = toRefs(state); 105 | const fn = memoize(() => { 106 | return `${greeting.current} ${target.current}`; 107 | }); 108 | 109 | expect(fn()).toEqual('Hello World'); 110 | state.greeting = 'Good night'; 111 | expect(fn()).toEqual('Good night World'); 112 | 113 | greeting.current = 'Hello'; 114 | expect(fn()).toEqual('Hello World'); 115 | expect(state.greeting).toEqual('Hello'); 116 | }); 117 | 118 | test('reactive converts refs to object', () => { 119 | // this test validates that `reactive` will pass changes through to the embedded `ref` objects 120 | const value = ref('hello'); 121 | const state = reactive({ 122 | value, 123 | }); 124 | const print = readonly(value); 125 | expect(print.current).toBe('hello'); 126 | state.value = 'ciao'; 127 | expect(print.current).toBe('ciao'); 128 | }); 129 | }); 130 | -------------------------------------------------------------------------------- /docs/api/reactive.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | [Home](./index.md) > [@pago/reactive](./reactive.md) 4 | 5 | ## reactive package 6 | 7 | ## Classes 8 | 9 | | Class | Description | 10 | | --- | --- | 11 | | [SubscriptionController](./reactive.subscriptioncontroller.md) | Manages the subscription to tracked references and objects within a memoized function. | 12 | 13 | ## Functions 14 | 15 | | Function | Description | 16 | | --- | --- | 17 | | [derived(fn)](./reactive.derived.md) | Returns a [ReadonlyRef](./reactive.readonlyref.md) whose value will always point to the latest result of the given function. The function will only be executed once per set of values. | 18 | | [effect(fn)](./reactive.effect.md) | Sometimes your components will need to initiate side effects to start fetching data, etc. This function enables you to implement that behaviour. The provided effect will be observed and will run automatically whenever any of its tracked values change. It will automatically be invalidated when the component is unmounted.The function passed into effect will behave similarly to one that is passed to Reacts useEffect in that it won't be executed during server side rendering. | 19 | | [fromHook(fn)](./reactive.fromhook.md) | The function passed to fromHook will always be executed when rendering the component. | 20 | | [inject(context)](./reactive.inject.md) | Injects a React.Context into a Reactive Function Component. | 21 | | [memoize(fn, controller)](./reactive.memoize.md) | Returns a function that is only executed again if any of its tracked values have changed. The controller can be used to establish a notification system and is largely irrelevant to end users of the API. | 22 | | [r(render)](./reactive.r.md) | This function is a pure type-cast to avoid TypeScript from complaining when using a Reactive Function Component without [wrap()](./reactive.wrap.md). | 23 | | [reactive(initialValue)](./reactive.reactive.md) | 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. | 24 | | [readonly(ref)](./reactive.readonly.md) | Converts a mutable [Ref](./reactive.ref.md) to a [ReadonlyRef](./reactive.readonlyref.md). | 25 | | [ref(initialValue)](./reactive.ref.md) | Creates a new tracked reference value. | 26 | | [toRef(store, prop)](./reactive.toref.md) | Extracts a single property from a tracked object into a [RefObject](./reactive.refobject.md). | 27 | | [toRefs(store)](./reactive.torefs.md) | Converts a tracked object into an object of [Ref](./reactive.ref.md) instances. | 28 | | [useRefValue(ref)](./reactive.userefvalue.md) | Returns the current value of a [RefObject](./reactive.refobject.md) and starts to track its value once the component has been mounted.An update will be scheduled if the value of the reference has changed between the first render of the component and mounting it. | 29 | | [watchEffect(fn)](./reactive.watcheffect.md) | 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. | 30 | | [wrap(construct)](./reactive.wrap.md) | 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.It is usually a better developer experience to configure your Build tool to use @pago/reactive as the @jsxImportSource or the @jsxFactory. | 31 | 32 | ## Interfaces 33 | 34 | | Interface | Description | 35 | | --- | --- | 36 | | [Effect](./reactive.effect.md) | A function that represents a pure side effect with no input and no output. | 37 | | [ReadonlyRef](./reactive.readonlyref.md) | A tracked reference to a value that can't be modified. | 38 | | [Ref](./reactive.ref.md) | A tracked reference to a value. Reading it from it should mark it as "read" in the current scope, writing to it should mark it as dirty.When a Ref is marked as dirty, any watcher or derivative will eventually be updated to its new value.Note that it is not possible to read and update a ref within the same tracked scope. | 39 | | [RefObject](./reactive.refobject.md) | An Ref object that supports reading & writing in the same tracked scope by providing a specific [RefObject.update()](./reactive.refobject.update.md) method. | 40 | 41 | ## Variables 42 | 43 | | Variable | Description | 44 | | --- | --- | 45 | | [createElement](./reactive.createelement.md) | An interceptor for the standard React createElement function from the react package. | 46 | | [jsx](./reactive.jsx.md) | An interceptor for the standard React jsx function from the react/jsx-runtime package. | 47 | | [jsxs](./reactive.jsxs.md) | An interceptor for the standard React jsxs function from the react/jsx-runtime package. | 48 | 49 | ## Type Aliases 50 | 51 | | Type Alias | Description | 52 | | --- | --- | 53 | | [RefContainer](./reactive.refcontainer.md) | An object with only [RefObject](./reactive.refobject.md) values. | 54 | | [Store](./reactive.store.md) | An object that inlines all [Ref](./reactive.ref.md) values and enables using them transparently. | 55 | 56 | -------------------------------------------------------------------------------- /examples/nextjs/pages/index.js: -------------------------------------------------------------------------------- 1 | import Head from 'next/head'; 2 | import { Counter } from '../components/counter'; 3 | 4 | export default function Home() { 5 | return () => ( 6 |
7 | 8 | Create Next App 9 | 10 | 11 | 12 |
13 |

14 | Welcome to Next.js! 15 |

16 | 17 |

18 | Get started by editing pages/index.js 19 |

20 | 21 | 53 |
54 | 55 | 65 | 66 | 196 | 197 | 211 |
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 |
68 |
Count: {count.current}
69 |
70 | 73 | 76 |
77 |
78 | ); 79 | } 80 | ``` 81 | 82 | ### A Timer component 83 | 84 | ```tsx 85 | /** @jsxImportSource @pago/reactive */ 86 | import { r, ref, effect } from '@pago/reactive'; 87 | 88 | interface Props { 89 | step: number; 90 | delay: number; 91 | } 92 | 93 | function Timer(props: Props) { 94 | const count = ref(0); 95 | 96 | effect(onInvalidate => { 97 | const timer = setInterval(() => { 98 | // update is needed because we are reading from and writing to count 99 | count.update(current => current + props.step); 100 | }, props.delay); 101 | 102 | onInvalidate(() => clearInterval(timer)); 103 | }); 104 | 105 | return r(() => ( 106 |
107 |
Count: {count.current}
108 |
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 | --------------------------------------------------------------------------------