Skip to content

Commit

Permalink
started staterino 2.0.0
Browse files Browse the repository at this point in the history
- added tests using jest/preact
- added type definitions
- breaking change because of api change to subscript, selectors are now passed before callback
  • Loading branch information
fuzetsu committed Mar 18, 2021
1 parent ed840c5 commit 7e09ef8
Show file tree
Hide file tree
Showing 6 changed files with 3,662 additions and 42 deletions.
26 changes: 20 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@ const App = () => {
return (
<div>
<p>Count is {count}</p>
<button onclick={increment}>+</button>
<button onclick={decrement}>-</button>
<button onClick={increment}>+</button>
<button onClick={decrement}>-</button>
</div>
)
}
Expand Down Expand Up @@ -83,6 +83,12 @@ const [count, age] = useStore(['counter.count', state => state.age])

If you pass an array the hook will return an array as well with the state slices in the correct order.

If no arguments are passed `useStore()` will return the whole state object:

```js
const state = useStore()
```

`useStore` is the hook itself, but it contains 3 essential functions:

```js
Expand All @@ -96,24 +102,32 @@ const {
} = useStore
```

`subscribe` takes two parameters, a callback for when the subscribed portion of state changes, and a selector that specifies which part of state you would like to subscribe to:
`subscribe` takes two parameters, a state selector or array of state selectors, and a callback for when the subscribed portion of state changes:

```js
// the subscribe call returns a function used to unsubscribe
const unSub = subscribe(
// the state selector
['counter.count', state => state.age],
// the callback function that triggers when state changes
(count, age) => {
console.log(count, age)
// optional cleanup function, similar to useEffect
return () => console.log('cleaning up', count, age)
},
// the state selector
['counter.count', state => state.age]
}
)
```

The selector parameter works under the same rules as the one passed to `useStore`, it can be a string a function or an array with a mix of the two.

If you just want to subscribe to any change to the state overall you can just pass a single parameter as a shorthand:

```js
subscribe(state => {
// do something whenever state changes
})
```

## Credits

Inspired by [zustand](https://github.com/react-spring/zustand) ❤️
16 changes: 13 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "staterino",
"version": "1.1.0",
"version": "2.0.0",
"description": "hook based state management",
"source": "dist/staterino.js",
"main": "dist/staterino.min.js",
Expand All @@ -11,11 +11,17 @@
"license": "MIT",
"type": "module",
"scripts": {
"build": "node ./build.js"
"build": "node ./build.js && jest",
"test": "jest --watch"
},
"devDependencies": {
"@testing-library/preact": "^2.0.1",
"@types/jest": "^26.0.20",
"buble": "^0.20.0",
"eslint": "^7.3.0",
"jest": "^26.6.3",
"mergerino": "^0.4.0",
"preact": "^10.5.12",
"terser": "^4.8.0"
},
"keywords": [
Expand All @@ -25,5 +31,9 @@
"hooks",
"merge",
"store"
]
],
"dependencies": {
"@types/mergerino": "^0.4.0"
},
"types": "types/staterino.d.ts"
}
28 changes: 13 additions & 15 deletions staterino.js
Original file line number Diff line number Diff line change
@@ -1,32 +1,31 @@
const arrEqual = (a, b) => a === b || (a.length === b.length && a.every((val, i) => val === b[i]))
const getPath = (obj, path) => path.split('.').reduce((acc, k) => (acc ? acc[k] : null), obj)
const getPath = (obj, path) => path.split('.').reduce((acc, k) => (acc ? acc[k] : undefined), obj)
const runCallback = (callback, slice, isArr) => callback[isArr ? 'apply' : 'call'](null, slice)

const SELF = x => x

const staterino = ({ merge, hooks: { useReducer, useLayoutEffect }, state = {} }) => {
// evaluate selectors
const staterino = ({ merge, hooks: { useReducer, useLayoutEffect }, state: initialState = {} }) => {
let state = initialState

const runSelectorPart = part => (typeof part === 'string' ? getPath(state, part) : part(state))
const runSelector = sel =>
Array.isArray(sel) ? [sel.map(runSelectorPart), true] : [runSelectorPart(sel), false]

const checkSub = sub => {
const runSub = sub => {
const [slice, isArr] = runSelector(sub._selector)
if (isArr ? !arrEqual(slice, sub._slice) : slice !== sub._slice) {
if (typeof sub._cleanup === 'function') sub._cleanup()
sub._cleanup = runCallback(sub._callback, (sub._slice = slice), isArr)
}
}

// track subs using set
const subs = new Set()
const subscribe = sub => {
subs.add(sub)
checkSub(sub)
runSub(sub)
return () => subs.delete(sub)
}

// main hook, manages subscription and returns slice
const useStore = (selector = SELF) => {
const [, redraw] = useReducer(c => c + 1, 0)
const [sub] = useReducer(SELF, { _callback: redraw })
Expand All @@ -41,17 +40,16 @@ const staterino = ({ merge, hooks: { useReducer, useLayoutEffect }, state = {} }
return sub._slice
}

// getter / setter for state, setter uses mergerino for immutable merges
useStore.get = () => state
useStore.set = (...patches) => {
state = merge(state, patches)
// when state is patched check if each subs slice of state has changed, and callback if so
subs.forEach(checkSub)
useStore.set = patch => {
state = merge(state, patch)
subs.forEach(runSub)
}

// external subscription, pass empty arr as slice to force callback to be called on first run
useStore.subscribe = (callback, selector = SELF) =>
subscribe({ _callback: callback, _selector: selector, _slice: [] })
useStore.subscribe = (selector, callback) => {
if (!callback) [selector, callback] = [SELF, selector]
return subscribe({ _callback: callback, _selector: selector, _slice: [] })
}

return useStore
}
Expand Down
175 changes: 175 additions & 0 deletions staterino.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
const { useReducer, useLayoutEffect } = require('preact/hooks')
const { render, act } = require('@testing-library/preact')
const staterino = require('./dist/staterino.es5')
const merge = require('mergerino')
const { h } = require('preact')

const mergeMock = jest.fn(merge)

const createHook = state =>
staterino({
state,
merge: mergeMock,
hooks: { useReducer, useLayoutEffect }
})

const baseState = {
one: true,
hello: 5,
deep: { f: 1 }
}

const mount = args => {
const result = { current: undefined }
const redraws = { current: 0 }
const Cmp = () => {
result.current = useStore(args)
redraws.current += 1
return null
}
render(h(Cmp))
return [result, redraws]
}

const mountSub = (...args) => {
const result = { current: undefined }
const calls = { current: 0 }
const callback = args.length > 1 && args.pop()
const unSub = useStore.subscribe(...args, (...slices) => {
result.current = slices.length === 1 ? slices[0] : slices
calls.current += 1
return callback && callback(...slices)
})
return [result, calls, unSub]
}

let useStore
beforeEach(() => {
useStore = createHook(baseState)
mergeMock.mockClear()
})

it('basic usage works', () => {
const [result, redraws] = mount()
expect(result.current).toBe(baseState)

const patch = { two: true, hello: 10, deep: { r: 1 } }
act(() => useStore.set(patch))

expect(result.current).toStrictEqual({ one: true, two: true, hello: 10, deep: { f: 1, r: 1 } })
expect(redraws.current).toBe(2)
})

describe('useStore()', () => {
it('no arguments works', () => {
const [result] = mount()
expect(result.current).toBe(baseState)
})
it('single function selector works', () => {
const [result] = mount(s => s.one)
expect(result.current).toBe(true)
})
it('single string selector works', () => {
const [result] = mount('one')
expect(result.current).toBe(true)
})
it('array of function/string selectors', () => {
const [result, redraws] = mount([
s => s.one,
s => s.hello,
s => s.blah,
'deep.f',
'deep.fake.deeper'
])
expect(result.current).toStrictEqual([true, 5, undefined, 1, undefined])
expect(redraws.current).toBe(1)

// only redraws once when multiple selectors change
act(() => useStore.set({ blah: 400, hello: 10 }))
expect(redraws.current).toBe(2)
expect(result.current).toStrictEqual([true, 10, 400, 1, undefined])
})
it('only redraws once when multiple selectors change', () => {
const [, redraws] = mount(['one', 'two', 'three'])
expect(redraws.current).toBe(1)
act(() => useStore.set({ one: 55, two: 55, three: 55 }))
expect(redraws.current).toBe(2)
})
it('does not redraw for unrelated state changes', () => {
const [, redraws] = mount(['one', s => s.deep.f])
expect(redraws.current).toBe(1)
act(() => useStore.set({ other: true, deep: { other: true } }))
expect(redraws.current).toBe(1)
})
})

describe('useStore.set()', () => {
it('uses mergerino under the hood', () => {
useStore.set({ test: true })
expect(mergeMock).toHaveBeenCalledTimes(1)
expect(mergeMock).toHaveReturnedWith(useStore.get())
expect(mergeMock).toHaveBeenCalledWith(baseState, { test: true })
})
})

describe('useStore.subscribe()', () => {
it('no selector redraws on every state change', () => {
const [result, calls] = mountSub()

// calls on initial sub
expect(calls.current).toBe(1)
expect(result.current).toStrictEqual(baseState)

useStore.set({})

// redraws and new state is passed
expect(calls.current).toBe(2)
expect(result.current).toStrictEqual(baseState)
expect(result.current).not.toBe(baseState)
})

it('unSub works', () => {
const [result, calls, unSub] = mountSub()
expect(calls.current).toBe(1)
// unSub works
unSub()
useStore.set({ blah: true })
useStore.set({ one: false })
expect(calls.current).toBe(1)
expect(result.current).toStrictEqual(baseState)
})

it('one selector works', () => {
const callback = jest.fn()
const [result, calls] = mountSub(s => s.one, callback)
expect(result.current).toBe(true)

// only calls sub when state actually changes
useStore.set({ two: 5 })
expect(calls.current).toBe(1)
useStore.set({ one: true })
expect(calls.current).toBe(1)
useStore.set({ one: false })
expect(calls.current).toBe(2)

// slice reflects accurate value
expect(result.current).toBe(false)
})

it('array of selectors work', () => {
const [result, calls] = mountSub([s => s.one, 'hello', 'deep'])
expect(result.current).toStrictEqual([true, 5, { f: 1 }])

// only calls sub when one of the slices changes
useStore.set({ two: 5 })
expect(calls.current).toBe(1)
useStore.set({ one: false })
expect(calls.current).toBe(2)
expect(result.current).toStrictEqual([false, 5, { f: 1 }])

// only calls once when multiple slices change at once
useStore.set({ hello: 10, deep: { b: 2 } })
expect(result.current).toStrictEqual([false, 10, { f: 1, b: 2 }])
expect(calls.current).toBe(3)
})
})
43 changes: 43 additions & 0 deletions types/staterino.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
declare module 'staterino' {
type FunctionSelector<S, T = any> = (state: S) => T
type Selector<S, T = any> = FunctionSelector<S, T> | string

type SelectorArrayToTuple<S, T extends Selector<S>[] | []> = {
[K in keyof T]: T[K] extends FunctionSelector<S> ? ReturnType<T[K]> : any
}

type Unsubscribe = () => void
type CleanupCallback = () => void

interface Subscribe<S> {
(callback: (value: S) => void | CleanupCallback): Unsubscribe
<T>(selector: Selector<S, T>, callback: (value: T) => void | CleanupCallback): Unsubscribe
<T extends Selector<S>[] | []>(
selectors: T,
callback: (
...values: T extends any[] ? SelectorArrayToTuple<S, T> : never
) => void | CleanupCallback
): Unsubscribe
}

interface StoreHook<S> {
(): S
<T>(selector: Selector<S, T>): T
<T extends Selector<S>[] | []>(selectors: T): SelectorArrayToTuple<S, T>
set: (patch: import('mergerino').MultipleTopLevelPatch<S>) => void
get: () => S
subscribe: Subscribe<S>
}

interface CreateHook {
<S>(conf: {
state: S
hooks: { useReducer: any; useLayoutEffect: any }
merge: import('mergerino').Merge<S>
}): StoreHook<S>
}

const staterino: CreateHook

export default staterino
}
Loading

0 comments on commit 7e09ef8

Please sign in to comment.