├── .travis.yml ├── src ├── react-app-env.d.ts ├── .eslintrc ├── index.ts ├── context.ts ├── observable.ts ├── test-utils.ts ├── utils │ ├── memoize.ts │ └── subscription.ts ├── batched.ts ├── transaction.ts ├── observe.ts ├── observe.spec.ts ├── derived.spec.ts ├── atom.ts ├── molecule.spec.ts ├── derived.ts ├── atom.spec.ts └── molecule.ts ├── .eslintignore ├── example ├── src │ ├── react-app-env.d.ts │ ├── index.tsx │ ├── setupTests.ts │ ├── App.test.tsx │ ├── theme.ts │ ├── react │ │ ├── usePrevious.ts │ │ ├── useConstant.ts │ │ ├── useObservable.ts │ │ └── useConstructor.ts │ ├── index.css │ ├── Loader.tsx │ ├── state │ │ ├── interval.ts │ │ ├── window-size.ts │ │ ├── pagination.ts │ │ ├── dialog.ts │ │ └── request.ts │ ├── ListItem.tsx │ ├── App.tsx │ ├── Folder.tsx │ ├── Folders.tsx │ ├── Note.tsx │ └── api.ts ├── public │ ├── favicon.ico │ ├── manifest.json │ ├── index.html │ └── empty.svg ├── README.md ├── tsconfig.json └── package.json ├── tsconfig.test.json ├── .editorconfig ├── .prettierrc ├── .gitignore ├── .eslintrc ├── tsconfig.json ├── package.json └── README.md /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 12 4 | - 10 5 | -------------------------------------------------------------------------------- /src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "jest": true 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | build/ 2 | dist/ 3 | node_modules/ 4 | .snapshots/ 5 | *.min.js -------------------------------------------------------------------------------- /example/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /example/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/malerba118/elementos/HEAD/example/public/favicon.ico -------------------------------------------------------------------------------- /tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "commonjs" 5 | } 6 | } -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /example/src/index.tsx: -------------------------------------------------------------------------------- 1 | import './index.css' 2 | 3 | import React from 'react' 4 | import ReactDOM from 'react-dom' 5 | import App from './App' 6 | 7 | ReactDOM.render(, document.getElementById('root')) 8 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './atom' 2 | export * from './molecule' 3 | export * from './derived' 4 | export * from './observe' 5 | export * from './observable' 6 | export * from './batched' 7 | export * from './context' 8 | export * from './transaction' 9 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "jsxSingleQuote": true, 4 | "semi": false, 5 | "tabWidth": 2, 6 | "bracketSpacing": true, 7 | "jsxBracketSameLine": false, 8 | "arrowParens": "always", 9 | "trailingComma": "none" 10 | } 11 | -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | This example was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 2 | 3 | It is linked to the elementos package in the parent directory for development purposes. 4 | 5 | You can run `npm install` and then `npm start` to test your package. 6 | -------------------------------------------------------------------------------- /example/src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom/extend-expect'; 6 | -------------------------------------------------------------------------------- /example/src/App.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | import App from './App' 4 | 5 | it('renders without crashing', () => { 6 | const div = document.createElement('div') 7 | ReactDOM.render(, div) 8 | ReactDOM.unmountComponentAtNode(div) 9 | }) 10 | -------------------------------------------------------------------------------- /example/src/theme.ts: -------------------------------------------------------------------------------- 1 | import { extendTheme } from '@chakra-ui/react' 2 | 3 | // 2. Call `extendTheme` and pass your custom values 4 | export const theme = extendTheme({ 5 | fonts: { 6 | body: 'Poppins, sans-serif', 7 | heading: 'Poppins, serif', 8 | mono: 'Menlo, monospace' 9 | } 10 | }) 11 | -------------------------------------------------------------------------------- /src/context.ts: -------------------------------------------------------------------------------- 1 | import { Transaction } from './transaction' 2 | 3 | let currentTransaction: Transaction | undefined 4 | 5 | export const getCurrentTransaction = () => currentTransaction 6 | export const setCurrentTransaction = (transaction: Transaction | undefined) => { 7 | currentTransaction = transaction 8 | } 9 | -------------------------------------------------------------------------------- /example/src/react/usePrevious.ts: -------------------------------------------------------------------------------- 1 | import { useRef, useEffect } from 'react' 2 | 3 | function usePrevious(value: T): T | undefined { 4 | const ref = useRef() 5 | useEffect(() => { 6 | ref.current = value 7 | }, [value]) 8 | return ref.current 9 | } 10 | 11 | export default usePrevious 12 | -------------------------------------------------------------------------------- /example/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "elementos", 3 | "name": "elementos", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": ".", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # See https://help.github.com/ignore-files/ for more about ignoring files. 3 | 4 | # dependencies 5 | node_modules 6 | 7 | # builds 8 | build 9 | dist 10 | .rpt2_cache 11 | 12 | # misc 13 | .DS_Store 14 | .env 15 | .env.local 16 | .env.development.local 17 | .env.test.local 18 | .env.production.local 19 | 20 | npm-debug.log* 21 | yarn-debug.log* 22 | yarn-error.log* 23 | -------------------------------------------------------------------------------- /example/src/react/useConstant.ts: -------------------------------------------------------------------------------- 1 | import { useRef } from 'react' 2 | 3 | const useConstant = (fn: () => T): T => { 4 | const ref = useRef(null) 5 | if (ref.current == null) { 6 | // we instantiate { value } to not conflict with returned null 7 | ref.current = { value: fn() } 8 | } 9 | return ref.current.value 10 | } 11 | 12 | export default useConstant 13 | -------------------------------------------------------------------------------- /example/src/index.css: -------------------------------------------------------------------------------- 1 | html, body, #root { 2 | height: 100%; 3 | } 4 | 5 | body { 6 | margin: 0; 7 | padding: 0; 8 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 9 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 10 | sans-serif; 11 | -webkit-font-smoothing: antialiased; 12 | -moz-osx-font-smoothing: grayscale; 13 | } 14 | 15 | code { 16 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 17 | monospace; 18 | } 19 | -------------------------------------------------------------------------------- /example/src/react/useObservable.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react' 2 | import { Observable, observe, ExtractObservableType } from 'elementos' 3 | 4 | export const useObservable = >( 5 | observable: T 6 | ): ExtractObservableType => { 7 | const [state, setState] = useState>(observable.get()) 8 | 9 | useEffect(() => { 10 | return observe(observable, (value) => { 11 | setState(value) 12 | }) 13 | }, [observable]) 14 | 15 | return state 16 | } 17 | -------------------------------------------------------------------------------- /example/src/Loader.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react' 2 | import { Spinner, Flex } from '@chakra-ui/react' 3 | 4 | interface LoaderProps { 5 | active: boolean 6 | } 7 | 8 | const Loader: FC = ({ active }) => { 9 | if (!active) { 10 | return null 11 | } 12 | return ( 13 | 21 | 22 | 23 | ) 24 | } 25 | 26 | export default Loader 27 | -------------------------------------------------------------------------------- /example/src/state/interval.ts: -------------------------------------------------------------------------------- 1 | import { atom, observe } from 'elementos' 2 | 3 | export const createInterval = ( 4 | initialCallback: () => void, 5 | interval: number 6 | ) => { 7 | const interval$ = atom(interval) 8 | let callback = initialCallback 9 | 10 | const dispose = observe(interval$, (ms) => { 11 | const id = setInterval(() => { 12 | callback() 13 | }, ms) 14 | return () => { 15 | clearInterval(id) 16 | } 17 | }) 18 | 19 | return { 20 | setInterval: (milliseconds: number) => { 21 | interval$.actions.set(milliseconds) 22 | }, 23 | setCallback: (nextCallback: () => void) => { 24 | callback = nextCallback 25 | }, 26 | dispose 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "extends": [ 4 | "standard", 5 | "standard-react", 6 | "plugin:prettier/recommended", 7 | "prettier/standard", 8 | "prettier/react", 9 | "plugin:@typescript-eslint/eslint-recommended" 10 | ], 11 | "env": { 12 | "node": true 13 | }, 14 | "parserOptions": { 15 | "ecmaVersion": 2020, 16 | "ecmaFeatures": { 17 | "legacyDecorators": true, 18 | "jsx": true 19 | } 20 | }, 21 | "settings": { 22 | "react": { 23 | "version": "16" 24 | } 25 | }, 26 | "rules": { 27 | "space-before-function-paren": 0, 28 | "react/prop-types": 0, 29 | "react/jsx-handler-names": 0, 30 | "react/jsx-fragments": 0, 31 | "react/no-unused-prop-types": 0, 32 | "import/export": 0 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /example/src/state/window-size.ts: -------------------------------------------------------------------------------- 1 | import { atom } from 'elementos' 2 | 3 | type Size = { 4 | width: number 5 | height: number 6 | } 7 | 8 | export const createWindowSize$ = () => { 9 | const size$ = atom(null) 10 | 11 | let listener: EventListener 12 | size$.onObserverChange(({ count }) => { 13 | // if there are no observers, remove listener 14 | if (count === 0 && listener) { 15 | window.removeEventListener('resize', listener) 16 | } else if (count > 0 && !listener) { 17 | // if there are observers, add listener 18 | listener = () => { 19 | size$.actions.set({ 20 | height: window.innerHeight, 21 | width: window.innerWidth 22 | }) 23 | } 24 | window.addEventListener('resize', listener) 25 | } 26 | }) 27 | 28 | return size$ 29 | } 30 | -------------------------------------------------------------------------------- /src/observable.ts: -------------------------------------------------------------------------------- 1 | import { Transaction } from './transaction' 2 | 3 | export type ObservableUnsubscriber = () => void 4 | export type ObservableSubscriber = (transaction: Transaction) => void 5 | export type ObserverChangeSubscriber = (params: { count: number }) => void 6 | 7 | export interface Observable { 8 | get: ( 9 | selector?: (val: State) => Selection, 10 | transaction?: Transaction 11 | ) => Selection 12 | subscribe: (subscriber: ObservableSubscriber) => ObservableUnsubscriber 13 | onObserverChange: ( 14 | subscriber: ObserverChangeSubscriber 15 | ) => ObservableUnsubscriber 16 | } 17 | 18 | export type ExtractObservableType = Type extends Observable 19 | ? X 20 | : never 21 | 22 | export type ExtractObservableTypes = { 23 | [K in keyof Map]: ExtractObservableType 24 | } 25 | 26 | export type ObservableMap = Record> 27 | -------------------------------------------------------------------------------- /src/test-utils.ts: -------------------------------------------------------------------------------- 1 | import { atom } from './index' 2 | 3 | export type User = { 4 | firstName: string 5 | lastName: string 6 | } 7 | 8 | export const USER: User = { 9 | firstName: 'austin', 10 | lastName: 'malerba' 11 | } 12 | 13 | export const USER_2: User = { 14 | firstName: 'frostin', 15 | lastName: 'malaria' 16 | } 17 | 18 | export const createUser$ = (user: User) => { 19 | return atom(user, { 20 | actions: (set) => ({ 21 | setFirstName: (firstName: string) => { 22 | set((prev) => ({ 23 | ...prev, 24 | firstName 25 | })) 26 | }, 27 | set 28 | }) 29 | }) 30 | } 31 | 32 | export const createEffectSpy = () => { 33 | const effect = jest.fn() 34 | const cleanup = jest.fn() 35 | return { 36 | effect: (...args: any) => { 37 | effect(...args) 38 | return cleanup 39 | }, 40 | spies: { 41 | effect, 42 | cleanup 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "dist", 4 | "module": "esnext", 5 | "lib": [ 6 | "dom", 7 | "esnext" 8 | ], 9 | "moduleResolution": "node", 10 | "jsx": "react", 11 | "sourceMap": true, 12 | "declaration": true, 13 | "esModuleInterop": true, 14 | "noImplicitReturns": true, 15 | "noImplicitThis": true, 16 | "noImplicitAny": true, 17 | "strictNullChecks": true, 18 | "suppressImplicitAnyIndexErrors": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "allowSyntheticDefaultImports": true, 22 | "target": "es5", 23 | "allowJs": true, 24 | "skipLibCheck": true, 25 | "strict": true, 26 | "forceConsistentCasingInFileNames": true, 27 | "resolveJsonModule": true, 28 | "isolatedModules": true, 29 | "noEmit": true 30 | }, 31 | "include": [ 32 | "src" 33 | ], 34 | "exclude": [ 35 | "node_modules", 36 | "build" 37 | ] 38 | } 39 | -------------------------------------------------------------------------------- /src/utils/memoize.ts: -------------------------------------------------------------------------------- 1 | export const defaultParamsEqual = ( 2 | params1: any[] | undefined, 3 | params2: any[] 4 | ) => { 5 | if (params1 === undefined || params1.length !== params2.length) { 6 | return false 7 | } 8 | for (const i in params1) { 9 | if (!Object.is(params1[i], params2[i])) { 10 | return false 11 | } 12 | } 13 | return true 14 | } 15 | 16 | export interface MemoizedOptions { 17 | paramsEqual?: (prevParams: Params | undefined, currParams: Params) => boolean 18 | } 19 | 20 | export const memoized = ( 21 | fn: (...args: Params) => Return, 22 | { paramsEqual = defaultParamsEqual }: MemoizedOptions = {} 23 | ): ((...args: Params) => Return) => { 24 | let prevArgs: Params | undefined 25 | let prevReturn: any 26 | 27 | return (...args) => { 28 | if (!paramsEqual(prevArgs, args)) { 29 | prevArgs = args 30 | prevReturn = fn(...args) 31 | } 32 | return prevReturn 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "dist", 4 | "module": "esnext", 5 | "lib": [ 6 | "dom", 7 | "esnext" 8 | ], 9 | "target": "esnext", 10 | "moduleResolution": "node", 11 | "sourceMap": true, 12 | "declaration": true, 13 | "esModuleInterop": true, 14 | "noImplicitReturns": true, 15 | "noImplicitThis": true, 16 | "noImplicitAny": true, 17 | "strictNullChecks": true, 18 | "suppressImplicitAnyIndexErrors": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "allowSyntheticDefaultImports": true, 22 | "allowJs": true, 23 | "skipLibCheck": true, 24 | "strict": true, 25 | "forceConsistentCasingInFileNames": true, 26 | "resolveJsonModule": true, 27 | "isolatedModules": true, 28 | "noEmit": true, 29 | "jsx": "react" 30 | }, 31 | "include": [ 32 | "src" 33 | ], 34 | "exclude": [ 35 | "node_modules", 36 | "dist", 37 | "example", 38 | "src/**/*.spec.ts" 39 | ] 40 | } 41 | -------------------------------------------------------------------------------- /example/src/ListItem.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react' 2 | import { 3 | ListItem as ChakraListItem, 4 | ListItemProps as ChakraListItemProps, 5 | Text 6 | } from '@chakra-ui/react' 7 | 8 | interface ListItemProps extends ChakraListItemProps { 9 | title: string 10 | description?: string 11 | active?: boolean 12 | } 13 | 14 | const ListItem: FC = ({ 15 | title, 16 | description, 17 | active, 18 | ...otherProps 19 | }) => { 20 | return ( 21 | 31 | 32 | {title} 33 | 34 | {description && ( 35 | 36 | {description} 37 | 38 | )} 39 | 40 | ) 41 | } 42 | 43 | export default ListItem 44 | -------------------------------------------------------------------------------- /src/batched.ts: -------------------------------------------------------------------------------- 1 | import { transaction } from './transaction' 2 | import { getCurrentTransaction, setCurrentTransaction } from './context' 3 | 4 | export const batched = ( 5 | executor: (...args: ExecutorParams) => ExecutorReturn 6 | ) => { 7 | return (...args: ExecutorParams): ExecutorReturn => { 8 | // nested batch calls should be ignored in favor of the outermost 9 | let currentTransaction = getCurrentTransaction() 10 | if (currentTransaction) { 11 | //no-op 12 | return executor(...args) 13 | } else { 14 | currentTransaction = transaction() 15 | setCurrentTransaction(currentTransaction) 16 | try { 17 | let returnVal = executor(...args) 18 | setCurrentTransaction(undefined) 19 | currentTransaction.commit() 20 | return returnVal 21 | } catch (err) { 22 | setCurrentTransaction(undefined) 23 | if (currentTransaction) { 24 | currentTransaction.rollback() 25 | } 26 | throw err 27 | } 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/utils/subscription.ts: -------------------------------------------------------------------------------- 1 | interface CreateSubscriptionManagerOptions { 2 | onSubscriberChange?: (params: { count: number }) => void 3 | reverse?: boolean 4 | } 5 | 6 | export type Unsubscriber = () => void 7 | 8 | export const createSubscriptionManager = ({ 9 | onSubscriberChange, 10 | reverse = false 11 | }: CreateSubscriptionManagerOptions = {}) => { 12 | type Subscriber = (...args: SubscriberArgs) => void 13 | let subscribers: Subscriber[] = [] 14 | return { 15 | subscribe: (subscriber: Subscriber): Unsubscriber => { 16 | subscribers = reverse 17 | ? [subscriber, ...subscribers] 18 | : [...subscribers, subscriber] 19 | onSubscriberChange && onSubscriberChange({ count: subscribers.length }) 20 | return () => { 21 | subscribers = subscribers.filter((s) => s !== subscriber) 22 | onSubscriberChange && onSubscriberChange({ count: subscribers.length }) 23 | } 24 | }, 25 | notifySubscribers: (...args: SubscriberArgs) => { 26 | subscribers.forEach((subscriber) => { 27 | subscriber(...args) 28 | }) 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /example/src/state/pagination.ts: -------------------------------------------------------------------------------- 1 | import { atom, molecule, batched } from 'elementos' 2 | 3 | export const createPagination = ({ 4 | page, 5 | totalPages 6 | }: { 7 | page: number 8 | totalPages: number | null 9 | }) => { 10 | const page$ = atom(page, { 11 | actions: (set) => ({ 12 | next: () => set((prev) => prev + 1), 13 | prev: () => set((prev) => prev - 1), 14 | jumpTo: (page: number) => set(page) 15 | }) 16 | }) 17 | 18 | const totalPages$ = atom(totalPages) 19 | 20 | const pagination$ = molecule( 21 | { 22 | page: page$, 23 | totalPages: totalPages$ 24 | }, 25 | { 26 | actions: ({ page, totalPages }) => ({ 27 | nextPage: () => { 28 | let nextPage = page.get() + 1 29 | const total = totalPages.get() 30 | if (total && nextPage <= total) { 31 | page.actions.next() 32 | } 33 | }, 34 | prevPage: () => { 35 | let nextPage = page.get() - 1 36 | if (nextPage > 0) { 37 | page.actions.prev() 38 | } 39 | }, 40 | setTotalPages: (n: number) => { 41 | if (page.get() > n) { 42 | batched(() => { 43 | totalPages.actions.set(n) 44 | page.actions.jumpTo(n) 45 | })() 46 | } else { 47 | totalPages.actions.set(n) 48 | } 49 | } 50 | }) 51 | } 52 | ) 53 | 54 | return pagination$ 55 | } 56 | -------------------------------------------------------------------------------- /src/transaction.ts: -------------------------------------------------------------------------------- 1 | import { createSubscriptionManager } from './utils/subscription' 2 | 3 | export type TransactionSubscriber = () => void 4 | export type TransactionUnsubscriber = () => void 5 | 6 | export interface Transaction { 7 | commit: () => void 8 | rollback: () => void 9 | onCommitPhaseOne: ( 10 | subscriber: TransactionSubscriber 11 | ) => TransactionUnsubscriber 12 | onCommitPhaseTwo: ( 13 | subscriber: TransactionSubscriber 14 | ) => TransactionUnsubscriber 15 | onRollback: (subscriber: TransactionSubscriber) => TransactionUnsubscriber 16 | } 17 | 18 | export const transaction = (): Transaction => { 19 | const managers = { 20 | commitPhaseOne: createSubscriptionManager(), 21 | commitPhaseTwo: createSubscriptionManager(), 22 | rollback: createSubscriptionManager() 23 | } 24 | 25 | const commit = () => { 26 | managers.commitPhaseOne.notifySubscribers() 27 | managers.commitPhaseTwo.notifySubscribers() 28 | } 29 | const rollback = () => { 30 | managers.rollback.notifySubscribers() 31 | } 32 | const onCommitPhaseOne = (subscriber: () => void) => { 33 | return managers.commitPhaseOne.subscribe(subscriber) 34 | } 35 | const onCommitPhaseTwo = (subscriber: () => void) => { 36 | return managers.commitPhaseTwo.subscribe(subscriber) 37 | } 38 | const onRollback = (subscriber: () => void) => { 39 | return managers.rollback.subscribe(subscriber) 40 | } 41 | return { 42 | commit, 43 | rollback, 44 | onCommitPhaseOne, 45 | onCommitPhaseTwo, 46 | onRollback 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/observe.ts: -------------------------------------------------------------------------------- 1 | import { Transaction } from './transaction' 2 | import { Observable } from './observable' 3 | 4 | export type EffectCleanup = () => void 5 | export type Effect = (value: State) => EffectCleanup | void 6 | export type Dispose = () => void 7 | 8 | export const observe = ( 9 | observable: Observable, 10 | effect: Effect 11 | ): Dispose => { 12 | let cleanup: EffectCleanup | void 13 | let prevState: State 14 | let firstInvocation = true 15 | const transactions = new Set() 16 | const runCleanup = () => { 17 | if (typeof cleanup === 'function') { 18 | cleanup() 19 | } 20 | } 21 | const runEffect = () => { 22 | let state = observable.get() 23 | if (firstInvocation || !Object.is(prevState, state)) { 24 | runCleanup() 25 | cleanup = effect(state) 26 | prevState = state 27 | firstInvocation = false 28 | } 29 | } 30 | 31 | const unsubscribe = observable.subscribe((transaction) => { 32 | if (transaction) { 33 | if (!transactions.has(transaction)) { 34 | transactions.add(transaction) 35 | transaction.onCommitPhaseTwo(() => { 36 | runEffect() 37 | transactions.delete(transaction) 38 | }) 39 | transaction.onRollback(() => { 40 | transactions.delete(transaction) 41 | }) 42 | } 43 | } else { 44 | runEffect() 45 | } 46 | }) 47 | 48 | runEffect() 49 | 50 | const dispose = () => { 51 | unsubscribe() 52 | runCleanup() 53 | } 54 | 55 | return dispose 56 | } 57 | -------------------------------------------------------------------------------- /example/src/App.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Flex, ChakraProvider } from '@chakra-ui/react' 3 | import { atom } from 'elementos' 4 | import { useConstructor } from './react/useConstructor' 5 | import { useObservable } from './react/useObservable' 6 | import Folders from './Folders' 7 | import Folder from './Folder' 8 | import Note from './Note' 9 | import { theme } from './theme' 10 | 11 | const App = () => { 12 | const { selectedFolder$, selectedNote$ } = useConstructor(() => { 13 | const selectedFolder$ = atom(null) 14 | const selectedNote$ = atom(null) 15 | 16 | return { 17 | selectedFolder$, 18 | selectedNote$ 19 | } 20 | }) 21 | 22 | const selectedFolder = useObservable(selectedFolder$) 23 | const selectedNote = useObservable(selectedNote$) 24 | 25 | return ( 26 | 27 | 28 | 35 | 43 | 44 | 45 | 46 | ) 47 | } 48 | 49 | export default App 50 | -------------------------------------------------------------------------------- /example/src/state/dialog.ts: -------------------------------------------------------------------------------- 1 | import { atom, molecule, batched } from 'elementos' 2 | 3 | const createVisibility$ = (defaultValue: boolean) => { 4 | return atom(defaultValue, { 5 | actions: (set) => ({ 6 | open: () => set(true), 7 | close: () => set(false) 8 | }) 9 | }) 10 | } 11 | 12 | type CreateDialogOptions = { 13 | defaultVisibility?: boolean 14 | defaultContext?: Context | null 15 | } 16 | 17 | export const createDialog$ = ({ 18 | defaultVisibility = false, 19 | defaultContext = null 20 | }: CreateDialogOptions = {}) => { 21 | const visibility$ = createVisibility$(defaultVisibility) 22 | const context$ = atom(defaultContext) 23 | 24 | const dialog$ = molecule( 25 | { 26 | visibility: visibility$, 27 | context: context$ 28 | }, 29 | { 30 | actions: ({ visibility, context }) => ({ 31 | open: batched((nextContext: Context) => { 32 | context.actions.set(nextContext) 33 | visibility.actions.open() 34 | }), 35 | close: batched(() => { 36 | context.actions.set(null) 37 | visibility.actions.close() 38 | }) 39 | }), 40 | deriver: ({ visibility, context }) => ({ 41 | isOpen: visibility, 42 | context 43 | }) 44 | } 45 | ) 46 | 47 | return dialog$ 48 | } 49 | 50 | type User = { 51 | firstName: string 52 | lastName: string 53 | email: string 54 | } 55 | 56 | const dialog$ = createDialog$() 57 | 58 | dialog$.actions.open({ 59 | firstName: '1', 60 | lastName: '1', 61 | email: '1' 62 | }) 63 | -------------------------------------------------------------------------------- /example/src/Folder.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react' 2 | import { Stack, StackProps, List } from '@chakra-ui/react' 3 | import { observe } from 'elementos' 4 | import { useConstructor } from './react/useConstructor' 5 | import { createRequest$ } from './state/request' 6 | import { useObservable } from './react/useObservable' 7 | import Loader from './Loader' 8 | import ListItem from './ListItem' 9 | import * as api from './api' 10 | 11 | interface FolderProps extends StackProps { 12 | folder: string | null 13 | selectedNote: number | null 14 | onNoteSelect: (noteId: number) => void 15 | } 16 | 17 | const Folder: FC = ({ 18 | folder, 19 | selectedNote, 20 | onNoteSelect, 21 | ...otherProps 22 | }) => { 23 | const { request$ } = useConstructor( 24 | ({ atoms, beforeUnmount }) => { 25 | const request$ = createRequest$(api.fetchNotes) 26 | beforeUnmount( 27 | observe(atoms.folder, (folder) => { 28 | request$.actions.execute({ folder }) 29 | }) 30 | ) 31 | return { 32 | request$ 33 | } 34 | }, 35 | { 36 | folder 37 | } 38 | ) 39 | 40 | const request = useObservable(request$) 41 | 42 | return ( 43 | 44 | 45 | 46 | {request.data?.map((note) => ( 47 | { 50 | onNoteSelect(note.id) 51 | }} 52 | active={selectedNote === note.id} 53 | title={note.title} 54 | description={note.description} 55 | /> 56 | ))} 57 | 58 | 59 | ) 60 | } 61 | 62 | export default Folder 63 | -------------------------------------------------------------------------------- /example/src/Folders.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react' 2 | import { Stack, StackProps, List } from '@chakra-ui/react' 3 | import { useConstructor } from './react/useConstructor' 4 | import { createRequest$ } from './state/request' 5 | import { useObservable } from './react/useObservable' 6 | import Loader from './Loader' 7 | import ListItem from './ListItem' 8 | import * as api from './api' 9 | 10 | interface FoldersProps extends StackProps { 11 | selectedFolder: string | null 12 | onFolderSelect: (folder: string | null) => void 13 | } 14 | 15 | const Folders: FC = ({ 16 | selectedFolder, 17 | onFolderSelect, 18 | ...otherProps 19 | }) => { 20 | // initializer runs only once on first render 21 | const { request$ } = useConstructor(() => { 22 | const request$ = createRequest$(api.fetchFolders) 23 | request$.actions.execute() 24 | return { 25 | request$ 26 | } 27 | }) 28 | 29 | // request$ observable is translated to react state 30 | const request = useObservable(request$) 31 | 32 | return ( 33 | 34 | 35 | 36 | { 39 | onFolderSelect(null) 40 | }} 41 | active={selectedFolder === null} 42 | title={'All'} 43 | /> 44 | {request.data?.map((folder) => ( 45 | { 48 | onFolderSelect(folder) 49 | }} 50 | active={selectedFolder === folder} 51 | title={folder} 52 | /> 53 | ))} 54 | 55 | 56 | ) 57 | } 58 | 59 | export default Folders 60 | -------------------------------------------------------------------------------- /src/observe.spec.ts: -------------------------------------------------------------------------------- 1 | import { molecule, observe, derived } from './index' 2 | import { createUser$, USER, USER_2, createEffectSpy } from './test-utils' 3 | 4 | describe('observe', () => { 5 | let user$ = createUser$(USER) 6 | 7 | beforeEach(() => { 8 | user$ = createUser$(USER) 9 | }) 10 | 11 | it('should run effect when intialized', () => { 12 | const { effect, spies } = createEffectSpy() 13 | observe(user$, effect) 14 | expect(spies.effect).toHaveBeenCalledTimes(1) 15 | expect(spies.effect).toBeCalledWith(USER) 16 | expect(spies.cleanup).toHaveBeenCalledTimes(0) 17 | }) 18 | 19 | it('should run effect when value changed', () => { 20 | const { effect, spies } = createEffectSpy() 21 | observe(user$, effect) 22 | user$.actions.set(USER_2) 23 | expect(spies.effect).toHaveBeenNthCalledWith(1, USER) 24 | expect(spies.effect).toHaveBeenNthCalledWith(2, USER_2) 25 | expect(spies.cleanup).toHaveBeenCalledTimes(1) 26 | }) 27 | 28 | it('should run effect when value changed', () => { 29 | const fullName$ = derived( 30 | user$, 31 | ({ firstName, lastName }) => firstName + ' ' + lastName 32 | ) 33 | 34 | const { effect, spies } = createEffectSpy() 35 | observe( 36 | molecule({ 37 | fullName: fullName$, 38 | user: user$ 39 | }), 40 | effect 41 | ) 42 | user$.actions.set(USER_2) 43 | expect(spies.effect).toHaveBeenCalledTimes(2) 44 | expect(spies.cleanup).toHaveBeenCalledTimes(1) 45 | }) 46 | 47 | it('should not run after dispose', () => { 48 | const { effect, spies } = createEffectSpy() 49 | const dispose = observe(user$, effect) 50 | dispose() 51 | user$.actions.set(USER_2) 52 | user$.actions.setFirstName('frostin') 53 | expect(spies.effect).toHaveBeenCalledTimes(1) 54 | expect(spies.cleanup).toHaveBeenCalledTimes(1) 55 | }) 56 | }) 57 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "elementos-example", 3 | "homepage": ".", 4 | "version": "0.0.0", 5 | "private": true, 6 | "scripts": { 7 | "start": "node ../node_modules/react-scripts/bin/react-scripts.js start", 8 | "build": "node ../node_modules/react-scripts/bin/react-scripts.js build", 9 | "test": "node ../node_modules/react-scripts/bin/react-scripts.js test", 10 | "eject": "node ../node_modules/react-scripts/bin/react-scripts.js eject" 11 | }, 12 | "dependencies": { 13 | "@chakra-ui/react": "^1.0.0", 14 | "@emotion/core": "^11.0.0", 15 | "@emotion/react": "^11.0.0", 16 | "@emotion/styled": "^11.0.0", 17 | "@testing-library/jest-dom": "file:../node_modules/@testing-library/jest-dom", 18 | "@testing-library/react": "file:../node_modules/@testing-library/react", 19 | "@testing-library/user-event": "file:../node_modules/@testing-library/user-event", 20 | "@types/jest": "file:../node_modules/@types/jest", 21 | "@types/node": "file:../node_modules/@types/node", 22 | "@types/react": "file:../node_modules/@types/react", 23 | "@types/react-dom": "file:../node_modules/@types/react-dom", 24 | "elementos": "file:..", 25 | "framer-motion": "^2.9.4", 26 | "lodash": "^4.17.20", 27 | "react": "file:../node_modules/react", 28 | "react-dom": "file:../node_modules/react-dom", 29 | "react-scripts": "file:../node_modules/react-scripts", 30 | "typescript": "file:../node_modules/typescript" 31 | }, 32 | "devDependencies": { 33 | "@babel/plugin-syntax-object-rest-spread": "^7.8.3", 34 | "@types/lodash": "^4.14.165" 35 | }, 36 | "eslintConfig": { 37 | "extends": "react-app" 38 | }, 39 | "browserslist": { 40 | "production": [ 41 | ">0.2%", 42 | "not dead", 43 | "not op_mini all" 44 | ], 45 | "development": [ 46 | "last 1 chrome version", 47 | "last 1 firefox version", 48 | "last 1 safari version" 49 | ] 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /example/src/react/useConstructor.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react' 2 | import { Atom, atom, batched } from 'elementos' 3 | import usePrevious from './usePrevious' 4 | import useConstant from './useConstant' 5 | 6 | export type UnmountSubscriber = () => void 7 | export type Constructor = (params: { 8 | beforeUnmount: (subscriber: UnmountSubscriber) => void 9 | atoms: Atoms 10 | }) => T 11 | 12 | const mapValues = (obj: Obj, mapper: (val: any) => any) => { 13 | var k, result, v 14 | result = {} 15 | for (k in obj) { 16 | v = obj[k] 17 | result[k] = mapper(v) 18 | } 19 | return result as { [K in keyof Obj]: any } 20 | } 21 | 22 | type Atoms = { [K in keyof Observed]: Atom } 23 | 24 | export const useConstructor = ( 25 | constructor: Constructor>, 26 | observed: Observed = {} as Observed 27 | ): T => { 28 | const unmountSubscribersRef = useRef([]) 29 | const atoms = useConstant>(() => { 30 | return mapValues(observed, (val) => atom(val)) 31 | }) 32 | 33 | const state = useConstant(() => { 34 | const beforeUnmount = (subscriber: UnmountSubscriber) => { 35 | unmountSubscribersRef.current.push(subscriber) 36 | } 37 | return constructor({ beforeUnmount, atoms }) 38 | }) 39 | 40 | const prevObserved = usePrevious(observed) 41 | 42 | useEffect(() => { 43 | if (!prevObserved) { 44 | return 45 | } 46 | // update atoms if pbserved values have changed 47 | batched(() => { 48 | Object.keys(atoms).forEach((key) => { 49 | if (!Object.is(prevObserved[key], observed[key])) { 50 | atoms[key].actions.set(() => observed[key]) 51 | } 52 | }) 53 | })() 54 | }) 55 | 56 | useEffect(() => { 57 | return () => { 58 | unmountSubscribersRef.current.forEach((subscriber) => { 59 | subscriber() 60 | }) 61 | } 62 | }, []) 63 | 64 | return state 65 | } 66 | -------------------------------------------------------------------------------- /example/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 10 | 11 | 12 | 16 | 17 | 18 | 19 | 20 | 29 | elementos 30 | 31 | 32 | 33 | 36 | 37 |
38 | 39 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "elementos", 3 | "version": "1.0.0", 4 | "description": "Composable reactive state management", 5 | "author": "malerba118", 6 | "license": "MIT", 7 | "repository": "malerba118/elementos", 8 | "main": "dist/index.js", 9 | "module": "dist/index.modern.js", 10 | "source": "src/index.ts", 11 | "engines": { 12 | "node": ">=10" 13 | }, 14 | "scripts": { 15 | "build": "microbundle-crl --no-compress --format modern,cjs", 16 | "start": "microbundle-crl watch --no-compress --format modern,cjs", 17 | "prepare": "run-s build", 18 | "test": "run-s test:unit test:lint test:build", 19 | "test:build": "run-s build", 20 | "test:lint": "eslint src/**/*", 21 | "test:unit": "cross-env CI=1 react-scripts test --env=jsdom", 22 | "test:watch": "react-scripts test --env=jsdom", 23 | "predeploy": "cd example && npm install && npm run build", 24 | "deploy": "gh-pages -d example/build" 25 | }, 26 | "devDependencies": { 27 | "@testing-library/jest-dom": "^4.2.4", 28 | "@testing-library/react": "^9.5.0", 29 | "@testing-library/user-event": "^7.2.1", 30 | "@types/jest": "^25.1.4", 31 | "@types/node": "^12.12.38", 32 | "@types/react": "^16.9.27", 33 | "@types/react-dom": "^16.9.7", 34 | "@typescript-eslint/eslint-plugin": "^2.26.0", 35 | "@typescript-eslint/parser": "^2.26.0", 36 | "microbundle-crl": "^0.13.10", 37 | "babel-eslint": "^10.0.3", 38 | "cross-env": "^7.0.2", 39 | "eslint": "^6.8.0", 40 | "eslint-config-prettier": "^6.7.0", 41 | "eslint-config-standard": "^14.1.0", 42 | "eslint-config-standard-react": "^9.2.0", 43 | "eslint-plugin-import": "^2.18.2", 44 | "eslint-plugin-node": "^11.0.0", 45 | "eslint-plugin-prettier": "^3.1.1", 46 | "eslint-plugin-promise": "^4.2.1", 47 | "eslint-plugin-react": "^7.17.0", 48 | "eslint-plugin-standard": "^4.0.1", 49 | "gh-pages": "^2.2.0", 50 | "npm-run-all": "^4.1.5", 51 | "prettier": "^2.0.4", 52 | "react": "^16.13.1", 53 | "react-dom": "^16.13.1", 54 | "react-scripts": "^3.4.1", 55 | "typescript": "^3.7.5" 56 | }, 57 | "files": [ 58 | "dist" 59 | ] 60 | } 61 | -------------------------------------------------------------------------------- /src/derived.spec.ts: -------------------------------------------------------------------------------- 1 | import { observe, derived } from './index' 2 | import { createUser$, USER } from './test-utils' 3 | 4 | const createFullName$ = () => { 5 | return derived( 6 | createUser$(USER), 7 | (user) => `${user.firstName} ${user.lastName}` 8 | ) 9 | } 10 | 11 | describe('derived', () => { 12 | let fullName$ = createFullName$() 13 | 14 | beforeEach(() => { 15 | fullName$ = createFullName$() 16 | }) 17 | 18 | it('should get correct state when unobserved', () => { 19 | expect(fullName$.get()).toBe('austin malerba') 20 | }) 21 | 22 | it('should update when child updates', () => { 23 | fullName$.child.actions.setFirstName('foo') 24 | expect(fullName$.get()).toBe('foo malerba') 25 | }) 26 | 27 | it('should notify observer when child updated', () => { 28 | const effect = jest.fn() 29 | const dispose = observe(fullName$, effect) 30 | fullName$.child.actions.setFirstName('foo') 31 | fullName$.child.actions.setFirstName('foo') 32 | expect(effect).toBeCalledTimes(2) 33 | expect(effect).toHaveBeenNthCalledWith(1, 'austin malerba') 34 | expect(effect).toHaveBeenNthCalledWith(2, 'foo malerba') 35 | dispose() 36 | }) 37 | 38 | it('should receive updates through onObserverChange', () => { 39 | const listener = jest.fn() 40 | fullName$.onObserverChange(listener) 41 | expect(listener).toHaveBeenCalledTimes(0) 42 | const dispose1 = observe(fullName$, () => {}) 43 | expect(listener).toHaveBeenCalledTimes(1) 44 | expect(listener).toHaveBeenNthCalledWith(1, { count: 1 }) 45 | const dispose2 = observe(fullName$, () => {}) 46 | expect(listener).toHaveBeenCalledTimes(2) 47 | expect(listener).toHaveBeenNthCalledWith(2, { count: 2 }) 48 | dispose1() 49 | expect(listener).toHaveBeenCalledTimes(3) 50 | expect(listener).toHaveBeenNthCalledWith(3, { count: 1 }) 51 | dispose2() 52 | expect(listener).toHaveBeenCalledTimes(4) 53 | expect(listener).toHaveBeenNthCalledWith(4, { count: 0 }) 54 | }) 55 | 56 | it('effect should not run if not changed', () => { 57 | const lastName$ = derived(createUser$(USER), (user) => user.lastName) 58 | const effect = jest.fn() 59 | observe(lastName$, effect) 60 | expect(effect).toHaveBeenCalledTimes(1) 61 | lastName$.child.actions.setFirstName('foo') 62 | expect(effect).toHaveBeenCalledTimes(1) 63 | }) 64 | }) 65 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![foo](https://github.com/malerba118/elementos-docs/blob/main/static/img/logo.svg) 2 | 3 | [![NPM](https://img.shields.io/npm/v/elementos.svg)](https://www.npmjs.com/package/elementos) [![JavaScript Style Guide](https://img.shields.io/badge/code_style-standard-brightgreen.svg)](https://standardjs.com) 4 | 5 | Elementos is a framework-agnostic, reactive state management library with an emphasis on state composition and encapsulation. 6 | 7 | **Please see the [full documentation](https://malerba118.github.io/elementos-docs)!** 8 | 9 | ## Install 10 | 11 | ```bash 12 | npm install --save elementos 13 | ``` 14 | 15 | ## Basic Usage 16 | 17 | [Open in CodeSandbox](https://codesandbox.io/s/elementos-basic-usage-7yng7?file=/src/index.js) 18 | 19 | ```js 20 | import { atom, molecule, observe } from "elementos"; 21 | 22 | document.getElementById("app").innerHTML = ` 23 | 26 | 29 |

30 | Count One: 31 |

32 |

33 | Count Two: 34 |

35 |

36 | Sum: 37 |

38 | `; 39 | 40 | const createCount$ = (defaultVal) => { 41 | return atom(defaultVal, { 42 | actions: (set) => ({ 43 | increment: () => set((prev) => prev + 1) 44 | }) 45 | }); 46 | }; 47 | 48 | const countOne$ = createCount$(0); 49 | const countTwo$ = createCount$(0); 50 | const sum$ = molecule( 51 | { 52 | countOne: countOne$, 53 | countTwo: countTwo$ 54 | }, 55 | { 56 | deriver: ({ countOne, countTwo }) => countOne + countTwo 57 | } 58 | ); 59 | 60 | const elements = { 61 | incCountOne: document.getElementById("inc-count-one"), 62 | incCountTwo: document.getElementById("inc-count-two"), 63 | countOne: document.getElementById("count-one"), 64 | countTwo: document.getElementById("count-two"), 65 | sum: document.getElementById("sum") 66 | }; 67 | 68 | elements.incCountOne.onclick = () => { 69 | countOne$.actions.increment(); 70 | }; 71 | 72 | elements.incCountTwo.onclick = () => { 73 | countTwo$.actions.increment(); 74 | }; 75 | 76 | observe(countOne$, (countOne) => { 77 | elements.countOne.innerHTML = countOne; 78 | }); 79 | 80 | observe(countTwo$, (countTwo) => { 81 | elements.countTwo.innerHTML = countTwo; 82 | }); 83 | 84 | observe(sum$, (sum) => { 85 | elements.sum.innerHTML = sum; 86 | }); 87 | ``` 88 | 89 | ## License 90 | 91 | MIT © [malerba118](https://github.com/malerba118) 92 | -------------------------------------------------------------------------------- /example/src/state/request.ts: -------------------------------------------------------------------------------- 1 | import { atom, molecule, batched } from 'elementos' 2 | 3 | enum Status { 4 | Initial = 'initial', 5 | Pending = 'pending', 6 | Fulfilled = 'fulfilled', 7 | Rejected = 'rejected' 8 | } 9 | 10 | export type CreateRequestOptions = { 11 | defaultData?: T 12 | } 13 | 14 | export const createRequest$ = ( 15 | executor: (...args: ExecutorParams) => Promise, 16 | { defaultData }: CreateRequestOptions = {} 17 | ) => { 18 | return molecule( 19 | { 20 | status: atom(Status.Initial), 21 | data: atom(defaultData), 22 | error: atom(null as Error | null) 23 | }, 24 | { 25 | actions: ({ status, data, error }) => { 26 | const baseActions = { 27 | setPending: batched(() => { 28 | status.actions.set(Status.Pending) 29 | error.actions.set(null) 30 | }), 31 | setFulfilled: batched((result) => { 32 | status.actions.set(Status.Fulfilled) 33 | data.actions.set(result) 34 | error.actions.set(null) 35 | }), 36 | setRejected: batched((err) => { 37 | status.actions.set(Status.Rejected) 38 | error.actions.set(err) 39 | }) 40 | } 41 | let invocationCount = 0 42 | const execute = async ( 43 | ...args: ExecutorParams 44 | ): Promise => { 45 | let invocationNumber = ++invocationCount 46 | baseActions.setPending() 47 | const prom = executor(...args) 48 | prom 49 | .then((data) => { 50 | if (invocationNumber !== invocationCount) { 51 | return 52 | } 53 | baseActions.setFulfilled(data) 54 | }) 55 | .catch((err) => { 56 | if (invocationNumber !== invocationCount) { 57 | return 58 | } 59 | baseActions.setRejected(err) 60 | }) 61 | return prom 62 | } 63 | return { 64 | ...baseActions, 65 | execute 66 | } 67 | }, 68 | deriver: ({ status, data, error }) => { 69 | return { 70 | isInitial: status === Status.Initial, 71 | isPending: status === Status.Pending, 72 | isFulfilled: status === Status.Fulfilled, 73 | isRejected: status === Status.Rejected, 74 | status, 75 | data, 76 | error 77 | } 78 | } 79 | } 80 | ) 81 | } 82 | -------------------------------------------------------------------------------- /src/atom.ts: -------------------------------------------------------------------------------- 1 | import { createSubscriptionManager } from './utils/subscription' 2 | import { Transaction } from './transaction' 3 | import { Observable, ObserverChangeSubscriber } from './observable' 4 | import { batched } from './batched' 5 | import { getCurrentTransaction } from './context' 6 | 7 | export type Setter = (value: State) => State 8 | 9 | export type Set = ( 10 | setter: State | Setter, 11 | transaction?: Transaction 12 | ) => void 13 | 14 | export interface AtomDefaultActions { 15 | set: Set 16 | } 17 | 18 | export interface Atom> 19 | extends Observable { 20 | actions: Actions 21 | } 22 | 23 | export interface AtomOptions< 24 | State, 25 | Actions extends {} = AtomDefaultActions 26 | > { 27 | actions: (set: Set) => Actions 28 | } 29 | 30 | export const atom = >( 31 | defaultValue: State, 32 | options?: AtomOptions 33 | ): Atom => { 34 | let value: State = defaultValue 35 | const transactionValues = new WeakMap() 36 | const observerChangeManager = createSubscriptionManager< 37 | Parameters 38 | >() 39 | const manager = createSubscriptionManager<[Transaction]>({ 40 | onSubscriberChange: ({ count }) => { 41 | observerChangeManager.notifySubscribers({ count }) 42 | } 43 | }) 44 | const set = batched( 45 | ( 46 | setter: Setter | State, 47 | transaction: Transaction = getCurrentTransaction() as Transaction 48 | ) => { 49 | // transaction will always exist because this function is batched 50 | if (!transactionValues.has(transaction)) { 51 | transaction.onCommitPhaseOne(() => { 52 | value = transactionValues.get(transaction) as State 53 | transactionValues.delete(transaction) 54 | }) 55 | transaction.onRollback(() => { 56 | transactionValues.delete(transaction) 57 | }) 58 | transactionValues.set(transaction, value) 59 | } 60 | let nextValue: State 61 | if (typeof setter === 'function') { 62 | nextValue = (setter as Setter)( 63 | transactionValues.get(transaction) as State 64 | ) 65 | } else { 66 | nextValue = setter 67 | } 68 | transactionValues.set(transaction, nextValue) 69 | manager.notifySubscribers(transaction) 70 | } 71 | ) 72 | 73 | return { 74 | get: ( 75 | selector = (x) => x as any, 76 | transaction = getCurrentTransaction() 77 | ) => { 78 | if (transaction && transactionValues.has(transaction)) { 79 | return selector(transactionValues.get(transaction) as State) 80 | } 81 | return selector(value) 82 | }, 83 | subscribe: (subscriber: (transaction: Transaction) => void) => { 84 | return manager.subscribe(subscriber) 85 | }, 86 | onObserverChange: (subscriber) => { 87 | return observerChangeManager.subscribe(subscriber) 88 | }, 89 | actions: options?.actions?.(set) || (({ set } as any) as Actions) 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/molecule.spec.ts: -------------------------------------------------------------------------------- 1 | import { observe, molecule, batched } from './index' 2 | import { createUser$, USER, USER_2 } from './test-utils' 3 | 4 | const createMol$ = () => { 5 | return molecule( 6 | { 7 | user1: createUser$(USER), 8 | user2: createUser$(USER_2) 9 | }, 10 | { 11 | actions: (children) => children 12 | } 13 | ) 14 | } 15 | 16 | describe('molecule', () => { 17 | let mol$ = createMol$() 18 | 19 | beforeEach(() => { 20 | mol$ = createMol$() 21 | }) 22 | 23 | it('should get correct state when unobserved', () => { 24 | const USER_3 = { 25 | firstName: 'foo', 26 | lastName: 'bar' 27 | } 28 | expect(mol$.get()).toBe(mol$.get()) 29 | mol$.actions.user1.actions.set(USER_3) 30 | expect(mol$.get().user1).toBe(USER_3) 31 | }) 32 | 33 | it('should get correct state when observed', () => { 34 | const dispose = observe(mol$, () => {}) 35 | const USER_3 = { 36 | firstName: 'foo', 37 | lastName: 'bar' 38 | } 39 | expect(mol$.get()).toBe(mol$.get()) 40 | mol$.actions.user1.actions.set(USER_3) 41 | expect(mol$.get().user1).toBe(USER_3) 42 | dispose() 43 | }) 44 | 45 | it('should get/set transactional state during batched and rollback', () => { 46 | const effect = jest.fn() 47 | const dispose = observe(mol$, effect) 48 | const USER_3 = { 49 | firstName: 'foo', 50 | lastName: 'bar' 51 | } 52 | const spy1 = jest.fn() 53 | const spy2 = jest.fn() 54 | const spy3 = jest.fn() 55 | const spy4 = jest.fn() 56 | const run = batched(() => { 57 | mol$.actions.user1.actions.set(USER_3) 58 | mol$.actions.user1.actions.set((prev) => { 59 | spy1(prev) 60 | return prev 61 | }) 62 | spy2(mol$.get().user1) 63 | spy3(mol$.actions.user1.get()) 64 | spy4(mol$.actions.user2.get()) 65 | throw new Error('rollback') 66 | }) 67 | 68 | try { 69 | run() 70 | } catch (err) {} 71 | 72 | expect(spy1).toBeCalledWith(USER_3) 73 | expect(spy2).toBeCalledWith(USER_3) 74 | expect(spy3).toBeCalledWith(USER_3) 75 | expect(spy4).toBeCalledWith(USER_2) 76 | expect(mol$.get().user1).toBe(USER) 77 | expect(effect).toBeCalledTimes(1) 78 | dispose() 79 | }) 80 | 81 | it('should get/set transactional state during batched and commit', () => { 82 | const effect = jest.fn() 83 | const dispose = observe(mol$, effect) 84 | const USER_3 = { 85 | firstName: 'foo', 86 | lastName: 'bar' 87 | } 88 | const spy1 = jest.fn() 89 | const spy2 = jest.fn() 90 | const run = batched(() => { 91 | mol$.actions.user1.actions.set(USER_3) 92 | mol$.actions.user1.actions.set((prev) => { 93 | spy1(prev) 94 | return prev 95 | }) 96 | spy2(mol$.get().user1) 97 | }) 98 | 99 | try { 100 | run() 101 | } catch (err) {} 102 | 103 | expect(spy1).toBeCalledWith(USER_3) 104 | expect(spy2).toBeCalledWith(USER_3) 105 | expect(mol$.get().user1).toBe(USER_3) 106 | expect(effect).toBeCalledTimes(2) 107 | dispose() 108 | }) 109 | }) 110 | -------------------------------------------------------------------------------- /src/derived.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Observable, 3 | ExtractObservableType, 4 | ObserverChangeSubscriber 5 | } from './observable' 6 | import { getCurrentTransaction } from './context' 7 | import { Transaction } from './transaction' 8 | import { createSubscriptionManager, Unsubscriber } from './utils/subscription' 9 | import { memoized } from './utils/memoize' 10 | 11 | export type Deriver = ( 12 | state: ExtractObservableType 13 | ) => DerivedState 14 | 15 | export interface Derived< 16 | Child extends Observable, 17 | DerivedState = ExtractObservableType 18 | > extends Observable { 19 | child: Child 20 | } 21 | 22 | export const derived = < 23 | Child extends Observable, 24 | DerivedState = ExtractObservableType 25 | >( 26 | child: Child, 27 | deriver: (state: ExtractObservableType) => DerivedState 28 | ): Derived => { 29 | const getChildValue = (transaction?: Transaction) => { 30 | return child.get((x) => x, transaction) 31 | } 32 | const memoizedDeriver = memoized(deriver) 33 | const observerChangeManager = createSubscriptionManager< 34 | Parameters 35 | >() 36 | let unsubscribeFromChild: Unsubscriber | undefined 37 | const manager = createSubscriptionManager<[Transaction]>({ 38 | onSubscriberChange: ({ count }) => { 39 | observerChangeManager.notifySubscribers({ count }) 40 | if (count > 0 && !unsubscribeFromChild) { 41 | unsubscribeFromChild = subscribeToChild() 42 | } else if (count === 0 && unsubscribeFromChild) { 43 | unsubscribeFromChild() 44 | unsubscribeFromChild = undefined 45 | } 46 | } 47 | }) 48 | const transactionDerivers = new WeakMap< 49 | Transaction, 50 | Deriver 51 | >() 52 | 53 | const subscribeToChild = () => { 54 | const unsubscribe = child.subscribe((transaction: Transaction) => { 55 | if (!transactionDerivers.has(transaction)) { 56 | transaction.onCommitPhaseOne(() => { 57 | transactionDerivers.delete(transaction) 58 | }) 59 | transaction.onRollback(() => { 60 | transactionDerivers.delete(transaction) 61 | }) 62 | transactionDerivers.set(transaction, memoized(deriver)) 63 | } 64 | manager.notifySubscribers(transaction) 65 | }) 66 | 67 | return unsubscribe 68 | } 69 | 70 | let observable: Observable = { 71 | get: ( 72 | selector = (x) => x as any, 73 | transaction = getCurrentTransaction() 74 | ) => { 75 | if (transaction && transactionDerivers.has(transaction)) { 76 | const transactionDeriver = transactionDerivers.get(transaction) 77 | return selector( 78 | transactionDeriver?.(getChildValue(transaction)) as DerivedState 79 | ) 80 | } 81 | return selector(memoizedDeriver(getChildValue())) 82 | }, 83 | subscribe: (subscriber: (transaction: Transaction) => void) => { 84 | return manager.subscribe(subscriber) 85 | }, 86 | onObserverChange: (subscriber) => { 87 | return observerChangeManager.subscribe(subscriber) 88 | } 89 | } 90 | 91 | return { 92 | ...observable, 93 | child 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/atom.spec.ts: -------------------------------------------------------------------------------- 1 | import { atom } from './index' 2 | import { getCurrentTransaction } from './context' 3 | import { createUser$, USER, USER_2 } from './test-utils' 4 | import { derived } from './derived' 5 | import { observe } from './observe' 6 | 7 | describe('atom', () => { 8 | let user$ = createUser$(USER) 9 | 10 | beforeEach(() => { 11 | user$ = createUser$(USER) 12 | }) 13 | 14 | it('should get state with default selector', () => { 15 | expect(user$.get()).toBe(USER) 16 | }) 17 | 18 | it('should get state with passed selector', () => { 19 | expect(user$.get((u) => u.firstName)).toEqual(USER.firstName) 20 | }) 21 | 22 | it('should partially update state', () => { 23 | user$.actions.setFirstName('frostin') 24 | expect(user$.get((u) => u.firstName)).toEqual('frostin') 25 | expect(user$.get((u) => u.lastName)).toEqual('malerba') 26 | expect(user$.get()).not.toEqual(USER) 27 | expect(user$.get()).not.toBe(USER) 28 | }) 29 | 30 | it('should fully update state', () => { 31 | user$.actions.set(USER_2) 32 | expect(user$.get((u) => u.firstName)).toEqual(USER_2.firstName) 33 | expect(user$.get((u) => u.lastName)).toEqual(USER_2.lastName) 34 | expect(user$.get()).not.toEqual(USER) 35 | expect(user$.get()).not.toBe(USER) 36 | }) 37 | 38 | it('should work with no options', () => { 39 | const count$ = atom(10) 40 | expect(count$.get()).toEqual(10) 41 | count$.actions.set(11) 42 | expect(count$.get()).toEqual(11) 43 | count$.actions.set((p) => p + 1) 44 | expect(count$.get()).toEqual(12) 45 | }) 46 | 47 | it('should commit at the end of setting', () => { 48 | const count$ = atom(10) 49 | expect(count$.get()).toEqual(10) 50 | count$.actions.set(() => { 51 | count$.actions.set(11) 52 | const snapshot = count$.get() 53 | count$.actions.set(12) 54 | return snapshot 55 | }) 56 | expect(count$.get()).toEqual(11) 57 | }) 58 | 59 | it('should rollback if error thrown', () => { 60 | const count$ = atom(10) 61 | expect(count$.get()).toEqual(10) 62 | try { 63 | count$.actions.set(() => { 64 | count$.actions.set(11) 65 | throw new Error('foo') 66 | }) 67 | } catch (err) {} 68 | expect(count$.get()).toEqual(10) 69 | }) 70 | 71 | it('should lazy subscribe with calls to onObserverChange', () => { 72 | const count$ = atom(10) 73 | const doubled$ = derived(count$, (count) => count * 2) 74 | const observerChangeListeners = { 75 | count: jest.fn(), 76 | doubled: jest.fn() 77 | } 78 | count$.onObserverChange(observerChangeListeners.count) 79 | doubled$.onObserverChange(observerChangeListeners.doubled) 80 | expect(observerChangeListeners.count).toHaveBeenCalledTimes(0) 81 | expect(observerChangeListeners.doubled).toHaveBeenCalledTimes(0) 82 | const dispose = observe(doubled$, () => {}) 83 | expect(observerChangeListeners.count).toHaveBeenCalledTimes(1) 84 | expect(observerChangeListeners.doubled).toHaveBeenCalledTimes(1) 85 | expect(observerChangeListeners.count).toHaveBeenNthCalledWith(1, { 86 | count: 1 87 | }) 88 | expect(observerChangeListeners.doubled).toHaveBeenNthCalledWith(1, { 89 | count: 1 90 | }) 91 | dispose() 92 | expect(observerChangeListeners.count).toHaveBeenNthCalledWith(2, { 93 | count: 0 94 | }) 95 | expect(observerChangeListeners.doubled).toHaveBeenNthCalledWith(2, { 96 | count: 0 97 | }) 98 | }) 99 | }) 100 | -------------------------------------------------------------------------------- /example/src/Note.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react' 2 | import debounce from 'lodash/debounce' 3 | import { Textarea, Flex, FlexProps, Text } from '@chakra-ui/react' 4 | import { molecule, observe, atom, batched } from 'elementos' 5 | import { useConstructor } from './react/useConstructor' 6 | import { createRequest$ } from './state/request' 7 | import { useObservable } from './react/useObservable' 8 | import Loader from './Loader' 9 | import * as api from './api' 10 | 11 | interface NoteProps extends FlexProps { 12 | noteId: number | null 13 | } 14 | 15 | const Note: FC = ({ noteId, ...otherProps }) => { 16 | const { form$, fetchRequest$ } = useConstructor( 17 | ({ atoms, beforeUnmount }) => { 18 | const form$ = molecule( 19 | { 20 | title: atom(''), 21 | description: atom('') 22 | }, 23 | { 24 | actions: ({ title, description }) => ({ 25 | setData: batched((data: any) => { 26 | title.actions.set(data.title) 27 | description.actions.set(data.description) 28 | }), 29 | title, 30 | description 31 | }) 32 | } 33 | ) 34 | 35 | const debouncedUpdateNote = debounce(api.updateNote, 1000) 36 | 37 | const fetchRequest$ = createRequest$(api.fetchNote) 38 | const updateRequest$ = createRequest$(async (id, payload) => { 39 | debouncedUpdateNote(id, payload) 40 | }) 41 | 42 | beforeUnmount( 43 | observe(atoms.noteId, (id) => { 44 | // whenever noteId changes via props, refetch note 45 | if (id) { 46 | fetchRequest$.actions.execute(id) 47 | } 48 | }) 49 | ) 50 | 51 | beforeUnmount( 52 | observe(fetchRequest$, ({ isFulfilled, data }) => { 53 | // whenever refetch succeeds, update the form data 54 | if (isFulfilled) { 55 | form$.actions.setData(data) 56 | } 57 | }) 58 | ) 59 | 60 | beforeUnmount( 61 | observe(form$, (form) => { 62 | // whenever form data changes, get note id and update note 63 | updateRequest$.actions.execute(atoms.noteId.get(), form) 64 | }) 65 | ) 66 | 67 | return { 68 | form$, 69 | fetchRequest$ 70 | } 71 | }, 72 | { 73 | noteId // track value of noteId over time as an atom 74 | } 75 | ) 76 | 77 | const request = useObservable(fetchRequest$) 78 | const form = useObservable(form$) 79 | 80 | return ( 81 | 82 | {noteId === null && ( 83 | 84 | No note selected 85 | 86 | No note selected 87 | 88 | 89 | )} 90 | {noteId && ( 91 | <> 92 | 93 | {request.isFulfilled && ( 94 | <> 95 |