Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,30 @@ export function useStore<
? Pick<StoreValue<SomeStore>, Key>
: StoreValue<SomeStore>

type Listener<SomeStore extends Store> = (
value: StoreValue<SomeStore>,
changed?: SomeStore extends MapStore ? StoreValue<SomeStore> : never
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this parameter only have a value if the store is map store?

And what is its value if the store value is a primitive?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this parameter only have a value if the store is map store?

Yeap

And what is its value if the store value is a primitive?

In this case you do not have store.setKey and we have no easy way to detect changes

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

) => void

export interface UseStoreListenerOptions<
SomeStore extends Store,
Key extends string | number | symbol
> extends UseStoreOptions<SomeStore, Key> {
leading?: boolean
listener: Listener<SomeStore>
}

/**
* Subscribe to store changes to trigger an effect
*
* @param store Store instance.
* @returns null
*/
export function useStoreListener<
SomeStore extends Store,
Key extends keyof StoreValue<Store>
>(store: SomeStore, options: UseStoreListenerOptions<SomeStore, Key>): null

/**
* Batch React updates. It is just wrap for React’s `unstable_batchedUpdates`
* with fix for React Native.
Expand Down
28 changes: 28 additions & 0 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,31 @@ export function useStore(store, opts = {}) {

return store.get()
}

export function useStoreListener(store, opts = {}) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it is worth moving the new hook into a separate file: this way it will not affect those who do not need it.

if (process.env.NODE_ENV !== 'production') {
if (typeof store === 'function') {
throw new Error(
'Use useStore(Template(id)) or useSync() ' +
'from @logux/client/react for templates'
)
}
}

let listenerRef = React.useRef(opts.listener)
listenerRef.current = opts.listener

React.useEffect(() => {
let listener = (value, changed) => listenerRef.current(value, changed)
if (opts.leading) {
listener(store.get())
}
if (opts.keys) {
return listenKeys(store, opts.keys, listener)
} else {
return store.listen(listener)
}
}, [store, '' + opts.keys, opts.leading])

return null
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the return does nothing, it seems it can be removed.

}
127 changes: 122 additions & 5 deletions index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,14 @@ import {
mapTemplate,
onMount,
atom,
map
map,
MapStore
} from 'nanostores'
import React, { FC } from 'react'
import ReactTesting from '@testing-library/react'
import { delay } from 'nanodelay'

import { useStore } from './index.js'
import { useStore, useStoreListener } from './index.js'

let { render, screen, act } = ReactTesting
let { createElement: h, useState } = React
Expand Down Expand Up @@ -177,16 +178,16 @@ it('does not reload store on component changes', async () => {
})

it('handles keys option', async () => {
type MapStore = {
type StoreValue = {
a?: string
b?: string
}
let Wrapper: FC = ({ children }) => h('div', {}, children)
let mapStore = map<MapStore>()
let mapStore = map<StoreValue>()
let renderCount = 0
let MapTest = (): React.ReactElement => {
renderCount++
let [keys, setKeys] = useState<(keyof MapStore)[]>(['a'])
let [keys, setKeys] = useState<(keyof StoreValue)[]>(['a'])
let { a, b } = useStore(mapStore, { keys })
return h(
'div',
Expand Down Expand Up @@ -245,3 +246,119 @@ it('handles keys option', async () => {
expect(screen.getByTestId('map-test')).toHaveTextContent('map:a-b')
expect(renderCount).toBe(4)
})

describe('useStoreListener hook', () => {
it('throws on template instead of store', () => {
let Test = (): void => {}
let [errors, Catcher] = getCatcher(() => {
// @ts-expect-error
useStoreListener(Test, { listener: () => {} })
})
render(h(Catcher))
expect(errors).toEqual([
'Use useStore(Template(id)) or useSync() ' +
'from @logux/client/react for templates'
])
})

function createTest(opts = {}): {
Test: FC
stats: { renders: number; calls: number }
store: MapStore
} {
let store = map({ a: 0 })
let stats = { renders: 0, calls: 0 }
let Test = (): React.ReactElement => {
stats.renders += 1
useStoreListener(store, {
...opts,
listener: () => {
stats.calls += 1
}
})
return h('span')
}
return { Test, stats, store }
}

it('invokes provided callback on store change', async () => {
let { Test, stats, store } = createTest()
render(h(Test))
await act(async () => {
store.set({ a: 1 })
await delay(1)
})
expect(stats.calls).toBe(1)
})

it("doesn't trigger rerenders on store change, but invokes the callback", async () => {
let { Test, stats, store } = createTest()
render(h(Test))
await act(async () => {
store.set({ a: 1 })
await delay(1)
store.set({ a: 2 })
await delay(1)
})
expect(stats.calls).toBe(2)
expect(stats.renders).toBe(1)
})

it('handles `leading` option', () => {
let { Test, stats } = createTest({ leading: true })
render(h(Test))
expect(stats.calls).toBe(1)
expect(stats.renders).toBe(1)
})

it('handles `keys` option', async () => {
let renders = 0
let calls = 0
type StoreValue = { a: number; b: number }
let mapStore = map<StoreValue>({ a: 0, b: 0 })
let MapTest = (): React.ReactElement => {
renders += 1
let [keys, setKeys] = useState<(keyof StoreValue)[]>(['a'])
useStoreListener(mapStore, {
keys,
listener: () => {
calls += 1
}
})
return h(
'div',
{ 'data-testid': 'map-test' },
h('button', {
onClick: () => {
setKeys(['a', 'b'])
}
}),
null
)
}
render(h(MapTest))
await act(async () => {
mapStore.setKey('a', 1)
await delay(1)
})
expect(calls).toBe(1)
expect(renders).toBe(1)

// does not react to 'b' key change
await act(async () => {
mapStore.setKey('b', 1)
await delay(1)
})
expect(calls).toBe(1)
expect(renders).toBe(1)

await act(async () => {
screen.getByRole('button').click() // enable 'b' key
await delay(1)
mapStore.setKey('b', 2)
await delay(1)
})
expect(calls).toBe(2)
expect(renders).toBe(2) // due to `keys` state change inside the component
})
})