├── .editorconfig ├── .eslintignore ├── .eslintrc ├── .gitignore ├── .prettierrc ├── .travis.yml ├── README.md ├── example ├── README.md ├── package-lock.json ├── package.json ├── public │ ├── empty.svg │ ├── favicon.ico │ ├── index.html │ └── manifest.json ├── src │ ├── App.test.tsx │ ├── App.tsx │ ├── Folder.tsx │ ├── Folders.tsx │ ├── ListItem.tsx │ ├── Loader.tsx │ ├── Note.tsx │ ├── api.ts │ ├── index.css │ ├── index.tsx │ ├── react-app-env.d.ts │ ├── react │ │ ├── useConstant.ts │ │ ├── useConstructor.ts │ │ ├── useObservable.ts │ │ └── usePrevious.ts │ ├── setupTests.ts │ ├── state │ │ ├── dialog.ts │ │ ├── interval.ts │ │ ├── pagination.ts │ │ ├── request.ts │ │ └── window-size.ts │ └── theme.ts └── tsconfig.json ├── package-lock.json ├── package.json ├── src ├── .eslintrc ├── atom.spec.ts ├── atom.ts ├── batched.ts ├── context.ts ├── derived.spec.ts ├── derived.ts ├── index.ts ├── molecule.spec.ts ├── molecule.ts ├── observable.ts ├── observe.spec.ts ├── observe.ts ├── react-app-env.d.ts ├── test-utils.ts ├── transaction.ts └── utils │ ├── memoize.ts │ └── subscription.ts ├── tsconfig.json └── tsconfig.test.json /.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 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | build/ 2 | dist/ 3 | node_modules/ 4 | .snapshots/ 5 | *.min.js -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 12 4 | - 10 5 | -------------------------------------------------------------------------------- /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/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/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/public/empty.svg: -------------------------------------------------------------------------------- 1 | empty -------------------------------------------------------------------------------- /example/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/malerba118/elementos/9e2ec3bfefe4ac6ddf4cca33a8a09b46d12a1b98/example/public/favicon.ico -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 |