Skip to content

Commit 2d0e483

Browse files
committed
^ Subscriptions are now tracked by field
This means subscriptions will not fire if the thing they are tracking hasn't changed. This has also had an affect on when state officially changes which is now only after an emit.
1 parent d1ee9ac commit 2d0e483

File tree

5 files changed

+131
-68
lines changed

5 files changed

+131
-68
lines changed

README.md

Lines changed: 10 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,10 @@ class Store extends TwoAndEight {
358358
export const useStore = createReactStore(new Store())
359359
```
360360

361+
> [!NOTE]
362+
> `$` prefixed actions are not available to select from `useStore`, this is
363+
> because these should be called from other public actions.
364+
361365
## Comparison
362366

363367
2n8 feels like a blend between two excellent state management libraries:
@@ -690,32 +694,18 @@ available.
690694
#### `useStore.subscribe`
691695

692696
```ts
693-
useStore.subscribe(callback: () => void): () => void
697+
useStore.subscribe(field: Field, callback: () => void): () => void
694698
```
695699

696-
Subscribes to state updates; registers a callback that fires whenever an action
697-
emits. This can be used to trigger events when all or certain state changes.
700+
Subscribes to state updates for a particular field or getter; registers a
701+
callback that fires whenever an action emits that affects the chosen field.
698702

699703
```ts
700-
useStore.subscribe(() => {
704+
useStore.subscribe('counter', () => {
701705
writeCounterToFile(useStore.get('counter'))
702706
})
703707
```
704708

705-
Note that this will be called on every emitted state from the store. If you'd
706-
like to optimise, it is advisable to use `if` statements and an external cache:
707-
708-
```ts
709-
let counterCache = useStore.get('counter')
710-
711-
useStore.subscribe(() => {
712-
if (useStore.get('counter') !== counterCache) {
713-
writeCounterToFile(useStore.get('counter'))
714-
counterCache = useStore.get('counter')
715-
}
716-
})
717-
```
718-
719709
### `createStore`
720710

721711
```ts
@@ -737,16 +727,8 @@ available.
737727
#### `store.subscribe`
738728

739729
```ts
740-
store.subscribe(callback: () => void): () => void
730+
store.subscribe(field: Field, callback: () => void): () => void
741731
```
742732

743733
Subscribes to state updates; registers a callback that fires whenever an action
744-
emits. This can be used to trigger events when all or certain state changes.
745-
746-
#### `store.getInitialState`
747-
748-
```ts
749-
store.getInitialState(): Store
750-
```
751-
752-
Returns the initial state snapshot, before any mutations have occurred.
734+
emits and the selected field is changed.

src/2n8.bench.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ describe('simple count', () => {
2323

2424
bench('2n8', async () => {
2525
const subscriptionComplete = new Promise<void>((resolve) => {
26-
twoAndEightSubscribe(() => {
26+
twoAndEightSubscribe('count', () => {
2727
if (get('count') === 1) {
2828
resolve()
2929
}

src/2n8.test.tsx

Lines changed: 46 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ test('should update when using async actions', async () => {
5252
expect(get('count')).toBe(7)
5353
})
5454

55-
test('should always return master record', async () => {
55+
test('should only return updated state after emit', async () => {
5656
class Store extends TwoAndEight {
5757
count = 0
5858

@@ -67,6 +67,15 @@ test('should always return master record', async () => {
6767
})
6868
this.count = this.count + 5
6969
}
70+
71+
asyncWithEarlyEmitButtonClicked = async () => {
72+
this.count++
73+
this.$emit()
74+
await new Promise((res) => {
75+
setTimeout(res, 1000)
76+
})
77+
this.count = this.count + 2
78+
}
7079
}
7180

7281
const { get } = createStore(new Store())
@@ -75,7 +84,7 @@ test('should always return master record', async () => {
7584

7685
get('asyncButtonClicked')()
7786

78-
expect(get('count')).toBe(1)
87+
expect(get('count')).toBe(0)
7988

8089
get('buttonClicked')()
8190

@@ -84,6 +93,14 @@ test('should always return master record', async () => {
8493
await vi.advanceTimersByTimeAsync(3000)
8594

8695
expect(get('count')).toBe(7)
96+
97+
get('asyncWithEarlyEmitButtonClicked')()
98+
99+
expect(get('count')).toBe(8)
100+
101+
await vi.advanceTimersByTimeAsync(1000)
102+
103+
expect(get('count')).toBe(10)
87104
})
88105

89106
test('should reset all state', () => {
@@ -467,8 +484,6 @@ test('should return current state', () => {
467484
const { get, subscribe } = createStore(new Store())
468485
get('increaseCount')()
469486

470-
expect(get('$emit')).toStrictEqual(expect.any(Function))
471-
expect(get('$reset')).toStrictEqual(expect.any(Function))
472487
expect(get('increaseCount')).toStrictEqual(expect.any(Function))
473488
expect(get('count')).toBe(1)
474489
expect(get('untouched')).toBe('foo')
@@ -496,14 +511,18 @@ test('should not call subscriber when state has not changed', () => {
496511

497512
const { get, subscribe } = createStore(new Store())
498513
const subscribeSpy = vi.fn<() => void>()
499-
subscribe(subscribeSpy)
514+
const subscribe2Spy = vi.fn<() => void>()
515+
subscribe('count', subscribeSpy)
516+
subscribe('obj', subscribe2Spy)
500517
get('noop')()
501518

502-
expect(subscribeSpy).toHaveBeenCalledOnce()
519+
expect(subscribeSpy).not.toHaveBeenCalled()
520+
expect(subscribe2Spy).not.toHaveBeenCalled()
503521

504522
get('noop2')()
505523

506-
expect(subscribeSpy).toHaveBeenCalledTimes(2)
524+
expect(subscribeSpy).not.toHaveBeenCalled()
525+
expect(subscribe2Spy).not.toHaveBeenCalled()
507526
})
508527

509528
test('should unsubscribe', () => {
@@ -517,7 +536,7 @@ test('should unsubscribe', () => {
517536

518537
const { get, subscribe } = createStore(new Store())
519538
const spy = vi.fn<() => void>()
520-
const unsubscribe = subscribe(spy)
539+
const unsubscribe = subscribe('count', spy)
521540
get('increaseCount')()
522541

523542
expect(spy).toHaveBeenCalledOnce()
@@ -576,8 +595,9 @@ test('should update deep state', () => {
576595
}
577596

578597
const { get, subscribe } = createStore(new Store())
579-
const subscribeSpy = vi.fn<() => void>()
580-
subscribe(subscribeSpy)
598+
// TODO test other fields
599+
const objSubscribeSpy = vi.fn<() => void>()
600+
subscribe('obj', objSubscribeSpy)
581601

582602
expect(get('obj')).toStrictEqual({ foo: { bar: 'baz' } })
583603
expect(get('arr')).toStrictEqual(['hello'])
@@ -587,13 +607,13 @@ test('should update deep state', () => {
587607
get('other')()
588608
get('push')()
589609

590-
expect(subscribeSpy).toHaveBeenCalledTimes(4)
610+
expect(objSubscribeSpy).toHaveBeenCalledOnce()
591611
expect(get('obj')).toStrictEqual({ foo: { bar: 'moo' } })
592612
expect(get('arr')).toStrictEqual(['hello', 'bye'])
593613

594614
get('delete')()
595615

596-
expect(subscribeSpy).toHaveBeenCalledTimes(5)
616+
expect(objSubscribeSpy).toHaveBeenCalledTimes(2)
597617
expect(get('obj')).toStrictEqual({ foo: {} })
598618
expect(get('arr')).toStrictEqual(['hello', 'bye'])
599619
})
@@ -616,7 +636,7 @@ test('should not fire subscription until end of action', () => {
616636

617637
const { get, subscribe } = createStore(new Store())
618638
const subscribeSpy = vi.fn<() => void>()
619-
subscribe(subscribeSpy)
639+
subscribe('count', subscribeSpy)
620640
get('increment')()
621641

622642
expect(subscribeSpy).toHaveBeenCalledOnce()
@@ -627,22 +647,32 @@ test('should not fire subscription until end of action', () => {
627647
expect(subscribeSpy).toHaveBeenCalledTimes(2)
628648
})
629649

630-
test('should not fire subscription if action begins with $ but state still updates', () => {
650+
test('should not fire subscription if action begins with $ and not update state until emit-able action called', () => {
631651
class Store extends TwoAndEight {
632652
count = 999
633653

634654
$increment = () => {
635655
this.count++
636656
}
657+
658+
increment = () => {
659+
this.count++
660+
}
637661
}
638662

639663
const { get, subscribe } = createStore(new Store())
640664
const subscribeSpy = vi.fn<() => void>()
641-
subscribe(subscribeSpy)
665+
subscribe('count', subscribeSpy)
666+
// @ts-expect-error -- normally you shouldn't call $ actions directly.
642667
get('$increment')()
643668

644-
expect(get('count')).toBe(1000)
669+
expect(get('count')).toBe(999)
645670
expect(subscribeSpy).not.toHaveBeenCalled()
671+
672+
get('increment')()
673+
674+
expect(get('count')).toBe(1001)
675+
expect(subscribeSpy).toHaveBeenCalledOnce()
646676
})
647677

648678
test("should fail types when store hasn't extended super class", () => {

src/2n8.ts

Lines changed: 52 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,15 @@ function infuseWithCallbackAfterRun(
1616
}
1717
}
1818

19-
export type State<Store> = {
19+
export type StateFields<Store> = {
2020
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
2121
[K in keyof Store as Store[K] extends Function ? never : K]: Store[K]
2222
}
2323

24+
export type StateFieldsAndPublicActions<Store> = {
25+
[K in keyof Store]: K extends `$${string}` ? never : K
26+
}[keyof Store]
27+
2428
export abstract class TwoAndEight {
2529
$emit(): void {
2630
// no-op, for now, it's enhanced in createStore
@@ -40,14 +44,47 @@ export abstract class TwoAndEight {
4044
export function createStore<Store extends TwoAndEight>(
4145
store: Store,
4246
): {
43-
get: <Field extends keyof Store>(field: Field) => Store[Field]
44-
subscribe: (subscriber: () => void) => () => void
47+
get: <Field extends StateFieldsAndPublicActions<Store>>(
48+
field: Field,
49+
) => Store[Field]
50+
subscribe: <Field extends keyof StateFields<Store>>(
51+
field: Field,
52+
subscriber: () => void,
53+
) => () => void
4554
} {
46-
const subscribers = new Set<() => void>()
55+
const storeCache = {} as StateFields<Store>
56+
57+
const proto = Object.getPrototypeOf(store)
58+
59+
const getterNames = Object.getOwnPropertyNames(proto).filter((name) => {
60+
const descriptor = Object.getOwnPropertyDescriptor(proto, name)
61+
return descriptor && typeof descriptor.get === 'function'
62+
})
63+
64+
const stateNames = (
65+
Object.keys(store) as (keyof StateFields<Store>)[]
66+
).filter((key) => typeof store[key] !== 'function')
67+
68+
const fields = [...getterNames, ...stateNames] as (keyof StateFields<Store>)[]
69+
70+
for (const field of fields) {
71+
storeCache[field] = structuredClone(store[field])
72+
}
73+
74+
const subscribers = {} as Record<keyof StateFields<Store>, Set<() => void>>
75+
76+
for (const field of fields) {
77+
subscribers[field] = new Set<() => void>()
78+
}
4779

4880
store.$emit = () => {
49-
for (const subscriber of subscribers) {
50-
subscriber()
81+
for (const field of fields) {
82+
if (!isEqual(store[field], storeCache[field])) {
83+
storeCache[field] = structuredClone(store[field])
84+
for (const subscriber of subscribers[field]) {
85+
subscriber()
86+
}
87+
}
5188
}
5289
}
5390

@@ -70,7 +107,7 @@ export function createStore<Store extends TwoAndEight>(
70107
}
71108
}
72109

73-
store.$reset = (field?: keyof State<Store>): void => {
110+
store.$reset = (field?: keyof StateFields<Store>): void => {
74111
if (field) {
75112
const value = Reflect.get(store, field)
76113
if (typeof value === 'function') {
@@ -90,23 +127,21 @@ export function createStore<Store extends TwoAndEight>(
90127
}
91128
}
92129

93-
const subscribe = (subscriber: () => void): (() => void) => {
94-
subscribers.add(subscriber)
95-
return () => subscribers.delete(subscriber)
130+
const subscribe = <Field extends keyof StateFields<Store>>(
131+
field: Field,
132+
subscriber: () => void,
133+
): (() => void) => {
134+
subscribers[field].add(subscriber)
135+
return () => subscribers[field].delete(subscriber)
96136
}
97137

98-
const cache = {} as Store
99-
100138
const get = <Field extends keyof Store>(field: Field) => {
101139
if (typeof store[field] === 'function') {
102140
return store[field]
103141
}
104142

105-
if (!isEqual(store[field], cache[field])) {
106-
cache[field] = structuredClone(store[field])
107-
}
108-
109-
return cache[field]
143+
// Pretending to be store because we've already removed actions above.
144+
return (storeCache as Store)[field]
110145
}
111146

112147
return {

src/react.tsx

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,39 @@
11
import { useSyncExternalStore } from 'react'
22

3-
import type { TwoAndEight } from './2n8.js'
3+
import type {
4+
StateFieldsAndPublicActions,
5+
StateFields,
6+
TwoAndEight,
7+
} from './2n8.js'
48
import { createStore } from './2n8.js'
59

610
export function createReactStore<Store extends TwoAndEight>(
711
rawStore: Store,
8-
): (<Field extends keyof Omit<Store, '$emit' | '$reset'>>(
12+
): (<Field extends StateFieldsAndPublicActions<Store>>(
913
field: Field,
1014
) => Store[Field]) & {
11-
get: <Field extends keyof Omit<Store, '$emit' | '$reset'>>(
15+
get: <Field extends StateFieldsAndPublicActions<Store>>(
1216
field: Field,
1317
) => Store[Field]
14-
subscribe: (subscriber: () => void) => () => void
18+
subscribe: <Field extends keyof StateFields<Store>>(
19+
field: Field,
20+
subscriber: () => void,
21+
) => () => void
1522
} {
1623
const { get, subscribe } = createStore(rawStore)
1724

18-
function useStore<Field extends keyof Store>(field: Field): Store[Field] {
25+
function useStore<Field extends StateFieldsAndPublicActions<Store>>(
26+
field: Field,
27+
): Store[Field] {
1928
return useSyncExternalStore(
20-
subscribe,
29+
(cb) =>
30+
typeof rawStore[field] === 'function'
31+
? () => null
32+
: subscribe(
33+
// @ts-expect-error -- cannot subscribe to actions.
34+
field,
35+
cb,
36+
),
2137
() => get(field),
2238
() => get(field),
2339
)

0 commit comments

Comments
 (0)