├── .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 | 
2 |
3 | [](https://www.npmjs.com/package/elementos) [](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 |
--------------------------------------------------------------------------------
/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 |
85 |
86 | No note selected
87 |
88 |
89 | )}
90 | {noteId && (
91 | <>
92 |
93 | {request.isFulfilled && (
94 | <>
95 |
132 | )
133 | }
134 |
135 | export default Note
136 |
--------------------------------------------------------------------------------
/example/src/api.ts:
--------------------------------------------------------------------------------
1 | export type FetchNotesOptions = {
2 | page?: number
3 | perPage?: number
4 | folder?: string | null
5 | }
6 |
7 | export type Note = {
8 | id: number
9 | folder: string | null
10 | title: string
11 | description: string
12 | }
13 |
14 | let notes: Note[] = [
15 | {
16 | id: 1,
17 | folder: 'recipes',
18 | title:
19 | 'sunt aut facere repellat provident occaecati excepturi optio reprehenderit',
20 | description:
21 | 'quia et suscipit\nsuscipit recusandae consequuntur expedita et cum\nreprehenderit molestiae ut ut quas totam\nnostrum rerum est autem sunt rem eveniet architecto'
22 | },
23 | {
24 | id: 2,
25 | folder: 'project ideas',
26 | title: 'qui est esse',
27 | description:
28 | 'est rerum tempore vitae\nsequi sint nihil reprehenderit dolor beatae ea dolores neque\nfugiat blanditiis voluptate porro vel nihil molestiae ut reiciendis\nqui aperiam non debitis possimus qui neque nisi nulla'
29 | },
30 | {
31 | id: 3,
32 | folder: 'gift ideas',
33 | title: 'ea molestias quasi exercitationem repellat qui ipsa sit aut',
34 | description:
35 | 'et iusto sed quo iure\nvoluptatem occaecati omnis eligendi aut ad\nvoluptatem doloribus vel accusantium quis pariatur\nmolestiae porro eius odio et labore et velit aut'
36 | },
37 | {
38 | id: 4,
39 | folder: 'recipes',
40 | title: 'eum et est occaecati',
41 | description:
42 | 'ullam et saepe reiciendis voluptatem adipisci\nsit amet autem assumenda provident rerum culpa\nquis hic commodi nesciunt rem tenetur doloremque ipsam iure\nquis sunt voluptatem rerum illo velit'
43 | },
44 | {
45 | id: 5,
46 | folder: 'recipes',
47 | title: 'nesciunt quas odio',
48 | description:
49 | 'repudiandae veniam quaerat sunt sed\nalias aut fugiat sit autem sed est\nvoluptatem omnis possimus esse voluptatibus quis\nest aut tenetur dolor neque'
50 | },
51 | {
52 | id: 6,
53 | folder: 'gift ideas',
54 | title: 'dolorem eum magni eos aperiam quia',
55 | description:
56 | 'ut aspernatur corporis harum nihil quis provident sequi\nmollitia nobis aliquid molestiae\nperspiciatis et ea nemo ab reprehenderit accusantium quas\nvoluptate dolores velit et doloremque molestiae'
57 | },
58 | {
59 | id: 7,
60 | folder: 'gift ideas',
61 | title: 'magnam facilis autem',
62 | description:
63 | 'dolore placeat quibusdam ea quo vitae\nmagni quis enim qui quis quo nemo aut saepe\nquidem repellat excepturi ut quia\nsunt ut sequi eos ea sed quas'
64 | },
65 | {
66 | id: 8,
67 | folder: null,
68 | title: 'dolorem dolore est ipsam',
69 | description:
70 | 'dignissimos aperiam dolorem qui eum\nfacilis quibusdam animi sint suscipit qui sint possimus cum\nquaerat magni maiores excepturi\nipsam ut commodi dolor voluptatum modi aut vitae'
71 | },
72 | {
73 | id: 9,
74 | folder: 'project ideas',
75 | title: 'nesciunt iure omnis dolorem tempora et accusantium',
76 | description:
77 | 'consectetur animi nesciunt iure dolore\nenim quia ad\nveniam autem ut quam aut nobis\net est aut quod aut provident voluptas autem voluptas'
78 | },
79 | {
80 | id: 10,
81 | folder: null,
82 | title: 'optio molestias id quia eum',
83 | description:
84 | 'quo et expedita modi cum officia vel magni\ndoloribus qui repudiandae\nvero nisi sit\nquos veniam quod sed accusamus veritatis error'
85 | },
86 | {
87 | id: 11,
88 | folder: 'project ideas',
89 | title: 'et ea vero quia laudantium autem',
90 | description:
91 | 'delectus reiciendis molestiae occaecati non minima eveniet qui voluptatibus\naccusamus in eum beatae sit\nvel qui neque voluptates ut commodi qui incidunt\nut animi commodi'
92 | },
93 | {
94 | id: 12,
95 | folder: 'project ideas',
96 | title: 'in quibusdam tempore odit est dolorem',
97 | description:
98 | 'itaque id aut magnam\npraesentium quia et ea odit et ea voluptas et\nsapiente quia nihil amet occaecati quia id voluptatem\nincidunt ea est distinctio odio'
99 | },
100 | {
101 | id: 13,
102 | folder: 'gift ideas',
103 | title: 'dolorum ut in voluptas mollitia et saepe quo animi',
104 | description:
105 | 'aut dicta possimus sint mollitia voluptas commodi quo doloremque\niste corrupti reiciendis voluptatem eius rerum\nsit cumque quod eligendi laborum minima\nperferendis recusandae assumenda consectetur porro architecto ipsum ipsam'
106 | },
107 | {
108 | id: 14,
109 | folder: 'project ideas',
110 | title: 'voluptatem eligendi optio',
111 | description:
112 | 'fuga et accusamus dolorum perferendis illo voluptas\nnon doloremque neque facere\nad qui dolorum molestiae beatae\nsed aut voluptas totam sit illum'
113 | },
114 | {
115 | id: 15,
116 | folder: null,
117 | title: 'eveniet quod temporibus',
118 | description:
119 | 'reprehenderit quos placeat\nvelit minima officia dolores impedit repudiandae molestiae nam\nvoluptas recusandae quis delectus\nofficiis harum fugiat vitae'
120 | }
121 | ]
122 |
123 | const timeout = (ms: number) =>
124 | new Promise((resolve) => setTimeout(resolve, ms))
125 |
126 | export const fetchFolders = async () => {
127 | await timeout(750)
128 | const folders = notes.reduce((set, note) => {
129 | if (note.folder) {
130 | set.add(note.folder)
131 | }
132 | return set
133 | }, new Set())
134 | return Array.from(folders)
135 | }
136 |
137 | export const fetchNotes = async ({
138 | page = 1,
139 | perPage = 10,
140 | folder
141 | }: FetchNotesOptions = {}) => {
142 | console.log('FETCHING NOTES')
143 | await timeout(750)
144 | let filteredNotes = notes.filter((note) => {
145 | return folder == null ? true : note.folder === folder
146 | })
147 | return filteredNotes.slice((page - 1) * perPage, page * perPage)
148 | }
149 |
150 | export const fetchNote = async (id: number) => {
151 | console.log('FETCHING NOTE: ' + id)
152 | await timeout(750)
153 | const note = notes.find((note) => note.id === id)
154 | return note != null
155 | ? {
156 | ...note
157 | }
158 | : null
159 | }
160 |
161 | type UpdateNotePayload = {
162 | title?: string
163 | description?: string
164 | }
165 |
166 | export const updateNote = async (id: number, payload: UpdateNotePayload) => {
167 | console.log('UPDATING NOTE: ' + id)
168 | await timeout(750)
169 | notes = notes.map((note) => {
170 | if (note.id === id) {
171 | return {
172 | ...note,
173 | ...payload
174 | }
175 | }
176 | return note
177 | })
178 | }
179 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/example/src/react-app-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/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/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/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/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/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/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "env": {
3 | "jest": true
4 | }
5 | }
6 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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/molecule.ts:
--------------------------------------------------------------------------------
1 | import {
2 | Observable,
3 | ObservableMap,
4 | ExtractObservableTypes,
5 | ExtractObservableType,
6 | ObserverChangeSubscriber,
7 | ObservableUnsubscriber
8 | } from './observable'
9 | import { Transaction } from './transaction'
10 | import { getCurrentTransaction } from './context'
11 | import { createSubscriptionManager, Unsubscriber } from './utils/subscription'
12 | import { memoized, defaultParamsEqual } from './utils/memoize'
13 |
14 | const paramsEqual = (params1: any[] | undefined, params2: any[]) => {
15 | return defaultParamsEqual(
16 | params1 && Object.values(params1[0]),
17 | Object.values(params2[0])
18 | )
19 | }
20 |
21 | export type MoleculeDeriver = (
22 | args: {
23 | [Index in keyof Deps]: ExtractObservableType
24 | }
25 | ) => DerivedState
26 |
27 | export interface Molecule<
28 | Children extends ObservableMap,
29 | Actions extends {} = Children,
30 | DerivedState = ExtractObservableTypes
31 | > extends Observable {
32 | children: Children
33 | actions: Actions
34 | }
35 |
36 | export interface MoleculeOptions<
37 | Children extends ObservableMap,
38 | Actions extends {} = Children,
39 | DerivedState = ExtractObservableTypes
40 | > {
41 | actions?: (children: Children) => Actions
42 | deriver?: MoleculeDeriver
43 | }
44 |
45 | export const molecule = <
46 | Children extends ObservableMap,
47 | Actions extends {} = Children,
48 | DerivedState = ExtractObservableTypes
49 | >(
50 | children: Children,
51 | {
52 | actions,
53 | deriver = (x) => x as DerivedState
54 | }: MoleculeOptions = {}
55 | ): Molecule => {
56 | const getChildrenValues = (transaction?: Transaction): any => {
57 | let args: any = {}
58 | Object.keys(children).forEach((key) => {
59 | const observable: any = children[key]
60 | args[key] = observable.get((x: any) => x, transaction)
61 | })
62 | return args
63 | }
64 | const memoizedDeriver = memoized(deriver, { paramsEqual })
65 | const observerChangeManager = createSubscriptionManager<
66 | Parameters
67 | >()
68 | let unsubscribeFromChildren: Unsubscriber | undefined
69 | const manager = createSubscriptionManager<[Transaction]>({
70 | onSubscriberChange: ({ count }) => {
71 | observerChangeManager.notifySubscribers({ count })
72 | if (count > 0 && !unsubscribeFromChildren) {
73 | unsubscribeFromChildren = subscribeToChildren()
74 | } else if (count === 0 && unsubscribeFromChildren) {
75 | unsubscribeFromChildren()
76 | unsubscribeFromChildren = undefined
77 | }
78 | }
79 | })
80 | const transactionDerivers = new WeakMap<
81 | Transaction,
82 | MoleculeDeriver
83 | >()
84 |
85 | const subscribeToChildren = () => {
86 | const unsubscribers: ObservableUnsubscriber[] = []
87 | Object.values(children).forEach((observable: any) => {
88 | const unsubscribe = observable.subscribe((transaction: Transaction) => {
89 | if (!transactionDerivers.has(transaction)) {
90 | transaction.onCommitPhaseOne(() => {
91 | transactionDerivers.delete(transaction)
92 | })
93 | transaction.onRollback(() => {
94 | transactionDerivers.delete(transaction)
95 | })
96 | transactionDerivers.set(
97 | transaction,
98 | memoized(deriver, { paramsEqual })
99 | )
100 | }
101 | manager.notifySubscribers(transaction)
102 | })
103 | unsubscribers.push(unsubscribe)
104 | })
105 |
106 | const unsubscribeFromChildren = () => {
107 | unsubscribers.forEach((unsubscribe) => unsubscribe())
108 | }
109 | return unsubscribeFromChildren
110 | }
111 |
112 | let observable: Observable = {
113 | get: (
114 | selector = (x) => x as any,
115 | transaction = getCurrentTransaction()
116 | ) => {
117 | if (transaction && transactionDerivers.has(transaction)) {
118 | const transactionDeriver = transactionDerivers.get(transaction)
119 | return selector(
120 | transactionDeriver?.(getChildrenValues(transaction)) as DerivedState
121 | )
122 | }
123 | return selector(memoizedDeriver(getChildrenValues()))
124 | },
125 | subscribe: (subscriber: (transaction: Transaction) => void) => {
126 | return manager.subscribe(subscriber)
127 | },
128 | onObserverChange: (subscriber) => {
129 | return observerChangeManager.subscribe(subscriber)
130 | }
131 | }
132 |
133 | return {
134 | ...observable,
135 | children,
136 | actions: actions?.(children) || ((children as any) as Actions)
137 | }
138 | }
139 |
--------------------------------------------------------------------------------
/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