├── .eslintrc.json ├── .gitignore ├── LICENSE ├── README.md ├── package-lock.json ├── package.json ├── src └── index.ts ├── test └── index.ts ├── tsconfig.build.json └── tsconfig.json /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "plugins": ["github"], 4 | "extends": ["plugin:github/recommended", "plugin:github/typescript", "plugin:github/browser"], 5 | "rules": { 6 | "no-invalid-this": "off", 7 | "@typescript-eslint/no-explicit-any": ["off"], 8 | "@typescript-eslint/ban-types": ["off"], 9 | "@typescript-eslint/no-invalid-this": ["error"], 10 | "@typescript-eslint/consistent-type-imports": ["error", {"prefer": "type-imports"}] 11 | }, 12 | "overrides": [ 13 | { 14 | "files": "test/*", 15 | "rules": { 16 | "@typescript-eslint/no-empty-function": "off", 17 | "@typescript-eslint/no-shadow": "off", 18 | "import/no-deprecated": "off" 19 | }, 20 | "globals": { 21 | "chai": false, 22 | "expect": false 23 | }, 24 | "env": { 25 | "mocha": true 26 | } 27 | }, 28 | { 29 | "files": "*.cjs", 30 | "env": { 31 | "node": true 32 | } 33 | } 34 | ] 35 | } 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.tsbuildinfo 3 | *.js 4 | *.d.ts 5 | *.map 6 | lib/ 7 | coverage/ 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Keith Cirkel 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # nibstate 2 | 3 | `nibstate` is an attempt to make the smallest and easiest to learn state management solution. As opposed to learning complex concepts like "Observables" or "Reactive Programming" or "Reducers", nibs provides a single function which boxes up a value and - if the value gets re-assigned - anything listening to it will update. 4 | 5 | ## Installation 6 | 7 | ```shell 8 | $ npm install --save nibstate 9 | ``` 10 | 11 | ## Usage 12 | 13 | The main export is `nib`, which lets you box a value into a "Nib". You can then call `next(fn)`, and `fn` will be called at-most-once: the next time the `nib` changes value: 14 | 15 | ```typescript 16 | import {nib, get, set} from 'nibstate' 17 | 18 | const myState = nib('foo') 19 | get(myState) // 'foo' 20 | myState.value // 'foo' 21 | 22 | myState.next(console.log) 23 | 24 | set(myState, 'bar') // you can also use `myState.value = 'bar'` 25 | // "bar" will be logged to the console 26 | 27 | ``` 28 | 29 | nibs can be derived from other nibs by passing a function, which is given a special version of `get` that allows for automatic updating: 30 | 31 | ```typescript 32 | import {nib, get, set} from 'nibstate' 33 | 34 | const myState = nib('hello') 35 | 36 | const myStateUpper = nib(get => get(myState).toUpperCase()) 37 | const myStateLength = nib(get => get(myState).length) 38 | 39 | get(myState) // 'hello' 40 | get(myStateUpper) // 'HELLO' 41 | get(myStateLength) // 5 42 | 43 | set(myState, 'goodbye') 44 | 45 | get(myState) // 'goodbye' 46 | get(myStateUpper) // 'GOODBYE' 47 | get(myStateLength) // 7 48 | ``` 49 | 50 | nibs can be of any value, and they can be derived from any value, so for example you could have a function which fetches the JSON of a URL of a previous nib, like so: 51 | 52 | ```typescript 53 | import {nib, get, set} from 'nibstate' 54 | import type {Person} from './my-api/types' 55 | 56 | const initials = nib('A') 57 | 58 | const url = nib(get => `https://example.com/api/people.json?initials=${get(initials)}`) 59 | 60 | const people = nib>(async get => { 61 | const res = await fetch(get(url)) 62 | const json = await res.json() 63 | return json.data.people 64 | }) 65 | 66 | const givenNames = nib>(async get => { 67 | const results = await get(people) 68 | return results.map(person => person.givenName) 69 | }) 70 | 71 | const peopleCount = nib>(async get => { 72 | const results = await get(people) 73 | return results.length 74 | }) 75 | 76 | console.log(await get(givenNames)) // ['Aki', 'Alex', 'Ali', 'Azriel'] 77 | console.log(await get(peopleCount)) // 4 78 | 79 | initials.value = 'B' 80 | 81 | console.log(await get(givenNames)) // ['Baily', 'Byrd', 'Bo', 'Brit', 'Bernie'] 82 | console.log(await get(peopleCount)) // 5 83 | ``` 84 | 85 | If you want to subscribe to _all_ changes of a Nib, you can `for await` them: 86 | 87 | ```typescript 88 | import {nib} from 'nibstate' 89 | 90 | const tick = nib(0) 91 | 92 | function incr() { 93 | tick.value += 1 94 | setTimeout(incr, 1000) 95 | } 96 | setTimeout(incr, 1000) 97 | 98 | for await(const t of tick) { 99 | console.log(t) 100 | } 101 | // Console logs: 102 | // 1 103 | // 2 104 | // 3 105 | // ... and so on 106 | ``` 107 | 108 | # Inspiration & Credits 109 | 110 | nibs take some inspiration from "atoms" in libraries like [Recoil](https://recoiljs.org) and [Jotai](https://jotai.org). nibs offer similar API surface, but are modelled more closely from Observables (specifically, [mini-observable](https://github.com/keithamus/mini-observable)) 111 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nibstate", 3 | "version": "1.1.0", 4 | "description": "An attempt to make the smallest and easiest to learn state management solution", 5 | "homepage": "https://keithamus.github.io/nibstate", 6 | "bugs": { 7 | "url": "https://github.com/keithamus/nibstate/issues" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/keithamus/nibstate.git" 12 | }, 13 | "license": "MIT", 14 | "author": "Keith Cirkel (https://keithcirkel.co.uk/)", 15 | "type": "module", 16 | "main": "lib/index.js", 17 | "module": "lib/index.js", 18 | "files": [ 19 | "lib" 20 | ], 21 | "scripts": { 22 | "build": "tsc --build tsconfig.build.json", 23 | "clean": "tsc --build --clean tsconfig.build.json", 24 | "lint": "eslint . --ignore-path .gitignore", 25 | "pretest": "npm run lint && npm run build", 26 | "prepack": "npm run build", 27 | "test": "web-test-runner" 28 | }, 29 | "prettier": "@github/prettier-config", 30 | "devDependencies": { 31 | "@github/prettier-config": "^0.0.4", 32 | "@open-wc/testing": "^3.1.5", 33 | "@typescript-eslint/eslint-plugin": "^5.14.0", 34 | "@typescript-eslint/parser": "^5.14.0", 35 | "@web/dev-server-esbuild": "^0.3.0", 36 | "@web/test-runner": "^0.13.30", 37 | "eslint": "^8.10.0", 38 | "eslint-plugin-github": "^4.1.1", 39 | "mini-observable": "^3.0.0", 40 | "ts-node": "^10.7.0", 41 | "tslib": "^2.1.0", 42 | "typescript": "^4.2.2" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | // The `Get` type is a function that is passed to the Nib initializer 2 | export type Get = Nib> = (s: S) => S extends Nib ? I : never 3 | 4 | interface PrivateState { 5 | v: T 6 | n: Set<(v: T) => void> 7 | } 8 | 9 | const states = new WeakMap, PrivateState>() 10 | export class Nib implements AsyncIterable { 11 | constructor(init: T | ((get: Get) => T)) { 12 | states.set(this, {v: init, n: new Set()}) 13 | if (typeof init === 'function') { 14 | const clears = new Set<() => void>() 15 | const next = () => { 16 | for (const clear of clears) clear() 17 | this.value = (init as (get: Get) => T)(track) 18 | } 19 | const track: Get = (s: Nib): T => { 20 | s.next(next) 21 | return s.value 22 | } 23 | next() 24 | } else { 25 | this.value = init 26 | } 27 | } 28 | 29 | get value(): T { 30 | return (states.get(this) as PrivateState).v 31 | } 32 | 33 | set value(value: T) { 34 | const state = states.get(this) as PrivateState 35 | if (Object.is(state.v, value)) return 36 | state.v = value 37 | const pastNexts = state.n 38 | state.n = new Set() 39 | for (const next of pastNexts) next(value) 40 | } 41 | 42 | next(fn: (v: T) => void): () => void { 43 | const state = states.get(this) as PrivateState 44 | state.n.add(fn) 45 | return () => state.n.delete(fn) 46 | } 47 | 48 | map(fn: (v: T) => B): Nib { 49 | return new Nib((g: Get) => fn(g(this))) 50 | } 51 | 52 | async *[Symbol.asyncIterator](): AsyncIterator { 53 | yield this.value 54 | while (true) { 55 | yield await new Promise(r => this.next(r)) 56 | } 57 | } 58 | 59 | static get(x: Nib): T { 60 | return x.value 61 | } 62 | 63 | static set(x: Nib, value: T): void { 64 | x.value = value 65 | } 66 | } 67 | 68 | export function nib(init: T | ((get: Get) => T)): Nib { 69 | return new Nib(init) 70 | } 71 | export const set = (nib.set = Nib.set) 72 | export const get = (nib.get = Nib.get) 73 | -------------------------------------------------------------------------------- /test/index.ts: -------------------------------------------------------------------------------- 1 | import {expect} from '@open-wc/testing' 2 | import {Nib, nib, get, set} from '../src/index' 3 | import type {Get} from '../src/index' 4 | 5 | describe('nib', () => { 6 | it('is a factory function for Nib', () => { 7 | const s = nib('foo') 8 | expect(s).to.be.instanceOf(Nib) 9 | expect(s).to.eql(new Nib('foo')) 10 | }) 11 | 12 | it('has nib.get/nib.set funtions', () => { 13 | expect(nib.get).to.eql(nib.get) 14 | expect(nib.set).to.eql(nib.set) 15 | }) 16 | 17 | describe('((type checks))', () => { 18 | // @ts-expect-error cast as number but given string 19 | nib('foo') 20 | 21 | const s = nib('foo') 22 | s.value = 'bar' 23 | 24 | // @ts-expect-error conflicting type as s is Nib 25 | s.value = 1 26 | }) 27 | }) 28 | 29 | describe('get/set', () => { 30 | it('gets/sets a nib value', () => { 31 | const s = nib('foo') 32 | expect(nib.get(s)).to.eql('foo') 33 | nib.set(s, 'bar') 34 | expect(nib.get(s)).to.eql('bar') 35 | set(s, 'baz') 36 | expect(get(s)).to.eql('baz') 37 | }) 38 | }) 39 | 40 | describe('Nib', () => { 41 | it('has static get/set members', () => { 42 | expect(Nib.get).to.eql(nib.get) 43 | expect(Nib.set).to.eql(nib.set) 44 | }) 45 | 46 | it('creates a new Nib object with a value getter/setter', () => { 47 | const s = nib('foo') 48 | expect(s.value).to.equal('foo') 49 | s.value = 'bar' 50 | expect(s.value).to.equal('bar') 51 | }) 52 | 53 | it('creates unique values', () => { 54 | const makeS = () => nib('foo') 55 | const xs = [makeS(), makeS(), makeS()] 56 | expect(xs.map(x => x.value)).to.eql(['foo', 'foo', 'foo']) 57 | xs[1].value = 'bar' 58 | expect(xs.map(x => x.value)).to.eql(['foo', 'bar', 'foo']) 59 | }) 60 | 61 | it('exposes an async iterator', async () => { 62 | const s = nib('foo') 63 | for await (const v of s) { 64 | expect(v).to.equal('foo') 65 | break 66 | } 67 | }) 68 | 69 | it('exposes a map method that returns derived values', async () => { 70 | const s = nib('foo') 71 | const l = s.map((x: string) => x.length) 72 | expect(l.value).to.equal(3) 73 | s.value = 'bing' 74 | expect(l.value).to.equal(4) 75 | }) 76 | 77 | it('can be iterated asynchronously', async () => { 78 | const s = nib('foo') 79 | setTimeout(() => (s.value = 'bar'), 10) 80 | setTimeout(() => (s.value = 'baz'), 20) 81 | setTimeout(() => (s.value = 'bing'), 30) 82 | const values = [] 83 | for await (const v of s) { 84 | values.push(v) 85 | if (v === 'bing') break 86 | } 87 | s.value = 'bong' 88 | expect(values).to.eql(['foo', 'bar', 'baz', 'bing']) 89 | }) 90 | 91 | describe('((types))', () => { 92 | // @ts-expect-error cast as number but given string 93 | nib('foo') 94 | }) 95 | 96 | describe('composition', () => { 97 | it('can be used to derive Nib from other fields', () => { 98 | const word = nib('foo') 99 | const len = nib((get: Get) => get(word).length) 100 | const caps = nib((get: Get) => get(word).toUpperCase()) 101 | expect(word.value).to.equal('foo') 102 | expect(len.value).to.equal(3) 103 | expect(caps.value).to.equal('FOO') 104 | word.value = 'bing' 105 | expect(word.value).to.equal('bing') 106 | expect(len.value).to.equal(4) 107 | expect(caps.value).to.equal('BING') 108 | }) 109 | 110 | it('can compose async', async () => { 111 | const word = nib('foo') 112 | const wordLater = nib>(async (get: Get) => get(word)) 113 | const wordEvenLater = nib>(async (get: Get) => get(wordLater)) 114 | expect(word.value).to.equal('foo') 115 | expect(wordLater.value).to.be.instanceOf(Promise) 116 | expect(wordEvenLater.value).to.be.instanceOf(Promise) 117 | expect(await wordLater.value).to.equal('foo') 118 | expect(await wordEvenLater.value).to.equal('foo') 119 | word.value = 'bar' 120 | expect(wordLater.value).to.be.instanceOf(Promise) 121 | expect(await wordLater.value).to.equal('bar') 122 | expect(await wordEvenLater.value).to.equal('bar') 123 | }) 124 | 125 | it('only calls when dependencies update', () => { 126 | let c = 0 127 | const first = nib('foo') 128 | const second = nib('bar') 129 | const both = nib((get: Get) => (c += 1) + get(first) + get(second)) 130 | expect(both.value).to.equal('1foobar') 131 | first.value = 'baz' 132 | expect(both.value).to.equal('2bazbar') 133 | second.value = 'bing' 134 | expect(both.value).to.equal('3bazbing') 135 | first.value = 'foo' 136 | second.value = 'bar' 137 | expect(both.value).to.equal('5foobar') 138 | }) 139 | 140 | describe('((types))', () => { 141 | const string = nib('foo') 142 | const number = nib(1) 143 | nib((get: Get) => { 144 | return get(string) + get(number) 145 | }) 146 | }) 147 | }) 148 | 149 | describe('README example', () => { 150 | type Person = {givenName: string} 151 | const db: Person[] = [ 152 | {givenName: 'Aki'}, 153 | {givenName: 'Alex'}, 154 | {givenName: 'Ali'}, 155 | {givenName: 'Azriel'}, 156 | {givenName: 'Baily'}, 157 | {givenName: 'Byrd'}, 158 | {givenName: 'Bo'}, 159 | {givenName: 'Brit'}, 160 | {givenName: 'Bernie'} 161 | ] 162 | 163 | async function contrivedFetch(url: string) { 164 | const initial = new URL(url, 'https://example.com').searchParams.get('initials') 165 | return { 166 | async json() { 167 | return {data: {people: db.filter(p => p.givenName[0] === initial)}} 168 | } 169 | } 170 | } 171 | 172 | it('allows composition of complex asynchronous state', async () => { 173 | const initials = nib('A') 174 | const url = nib(get => `https://example.com/api/people.json?initials=${get(initials)}`) 175 | const people = nib>(async get => { 176 | const res = await contrivedFetch(get(url)) 177 | const json = await res.json() 178 | return json.data.people 179 | }) 180 | const givenNames = nib>(async get => { 181 | const results = await get(people) 182 | return results.map((person: Person) => person.givenName) 183 | }) 184 | const peopleCount = nib>(async get => { 185 | const results = await get(people) 186 | return results.length 187 | }) 188 | expect(await givenNames.value).to.eql(['Aki', 'Alex', 'Ali', 'Azriel']) 189 | expect(await peopleCount.value).to.eql(4) 190 | initials.value = 'B' 191 | expect(await givenNames.value).to.eql(['Baily', 'Byrd', 'Bo', 'Brit', 'Bernie']) 192 | expect(await peopleCount.value).to.eql(5) 193 | }) 194 | }) 195 | 196 | describe('ToDo Example', () => { 197 | type Todo = Nib<{title: string; done: boolean}> 198 | let filter: Nib<'all' | 'incomplete' | 'complete'> 199 | let todos: Nib 200 | let filtered: Nib 201 | function add(title: string, done = false) { 202 | const todo = nib({title, done}) 203 | todos.value = [...todos.value, todo] 204 | return todo 205 | } 206 | function remove(todo: Todo) { 207 | todos.value = todos.value.filter(t => t !== todo) 208 | } 209 | function toggle(todo: Todo) { 210 | todo.value = {...todo.value, done: !todo.value.done} 211 | } 212 | beforeEach(() => { 213 | filter = nib<'all' | 'incomplete' | 'complete'>('all') 214 | todos = nib([]) 215 | filtered = nib((get: Get) => { 216 | if (get(filter) === 'incomplete') { 217 | return get(todos).filter((todo: Todo) => !get(todo).done) 218 | } else if (get(filter) === 'complete') { 219 | return get(todos).filter((todo: Todo) => get(todo).done) 220 | } 221 | return get(todos) 222 | }) 223 | }) 224 | 225 | it('reacts to new todos being added and removed', () => { 226 | const todo = add('foo') 227 | expect(todos.value.map(s => s.value)).to.eql([{title: 'foo', done: false}]) 228 | remove(todo) 229 | expect(todos.value.map(s => s.value)).to.eql([]) 230 | }) 231 | 232 | it('reacts to changes to filter Nib', () => { 233 | add('foo') 234 | add('bar', true) 235 | add('baz') 236 | add('bing', true) 237 | expect(filtered.value.map(s => s.value.title)).to.eql(['foo', 'bar', 'baz', 'bing']) 238 | filter.value = 'complete' 239 | expect(filtered.value.map(s => s.value.title)).to.eql(['bar', 'bing']) 240 | filter.value = 'incomplete' 241 | expect(filtered.value.map(s => s.value.title)).to.eql(['foo', 'baz']) 242 | }) 243 | 244 | it('reacts to changes to todo Nib', () => { 245 | filter.value = 'complete' 246 | add('foo') 247 | add('bar', true) 248 | const baz = add('baz') 249 | const bing = add('bing', true) 250 | filter.value = 'complete' 251 | expect(filtered.value.map(s => s.value.title)).to.eql(['bar', 'bing']) 252 | toggle(baz) 253 | expect(filtered.value.map(s => s.value.title)).to.eql(['bar', 'baz', 'bing']) 254 | toggle(bing) 255 | expect(filtered.value.map(s => s.value.title)).to.eql(['bar', 'baz']) 256 | }) 257 | }) 258 | }) 259 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "exclude": ["__tests__"], 3 | "extends": "./tsconfig.json", 4 | "compilerOptions": { 5 | "declaration": true, 6 | "declarationMap": true, 7 | "outDir": "./lib", 8 | "noEmit": false 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src", "__tests__"], 3 | "compilerOptions": { 4 | "baseUrl": ".", 5 | "esModuleInterop": true, 6 | "lib": ["es2020", "dom"], 7 | "module": "ESNext", 8 | "moduleResolution": "node", 9 | "noEmit": true, 10 | "sourceMap": true, 11 | "strict": true, 12 | "target": "ES2019" 13 | } 14 | } 15 | --------------------------------------------------------------------------------