diff --git a/README.md b/README.md
index 3eb7421..d992a83 100644
--- a/README.md
+++ b/README.md
@@ -25,6 +25,15 @@ export const Header = () => {
}
```
+Use the `keys` option to re-render only on specific key changes:
+
+```tsx
+export const Header = () => {
+ const profile = useStore($profile, { keys: 'name' })
+ return
+}
+```
+
[Nano Stores]: https://github.com/nanostores/nanostores/
---
diff --git a/index.js b/index.js
index 2756e54..9eef8a3 100644
--- a/index.js
+++ b/index.js
@@ -2,13 +2,14 @@ import { listenKeys } from 'nanostores'
import { useEffect, useState } from 'preact/hooks'
export function useStore(store, opts = {}) {
+ let [hydrated, setHydrated] = useState(false)
let [, forceRender] = useState({})
- let [valueBeforeEffect] = useState(store.get())
+ // A re-render is always forced on mount and hydrate
useEffect(() => {
- valueBeforeEffect !== store.get() && forceRender({})
+ setHydrated(true)
}, [])
-
+
useEffect(() => {
let batching, timer, unlisten
let rerender = () => {
@@ -31,5 +32,6 @@ export function useStore(store, opts = {}) {
}
}, [store, '' + opts.keys])
- return store.get()
+ // `'init' in store` check for compatibility with nanostores <= 1.1.1
+ return !hydrated && 'init' in store ? store.init : store.get()
}
diff --git a/package.json b/package.json
index a9630e2..24a8d2a 100644
--- a/package.json
+++ b/package.json
@@ -56,6 +56,7 @@
"nanodelay": "^2.0.2",
"nanostores": "^1.0.1",
"preact": "^10.26.5",
+ "preact-render-to-string": "^6.6.6",
"size-limit": "^11.2.0",
"tsx": "^4.19.3",
"typescript": "^5.8.3"
@@ -88,7 +89,7 @@
"index.js": "{ useStore }",
"nanostores": "{ map, computed }"
},
- "limit": "922 B"
+ "limit": "943 B"
}
]
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 62f7d7c..dba0751 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -59,6 +59,9 @@ importers:
preact:
specifier: ^10.26.5
version: 10.26.5
+ preact-render-to-string:
+ specifier: ^6.6.6
+ version: 6.6.6(preact@10.26.5)
size-limit:
specifier: ^11.2.0
version: 11.2.0
@@ -1373,6 +1376,11 @@ packages:
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
engines: {node: '>= 0.4'}
+ preact-render-to-string@6.6.6:
+ resolution: {integrity: sha512-EfqZJytnjJldV+YaaqhthU2oXsEf5e+6rDv957p+zxAvNfFLQOPfvBOTncscQ+akzu6Wrl7s3Pa0LjUQmWJsGQ==}
+ peerDependencies:
+ preact: '>=10 || >= 11.0.0-0'
+
preact@10.26.5:
resolution: {integrity: sha512-fmpDkgfGU6JYux9teDWLhj9mKN55tyepwYbxHgQuIxbWQzgFg5vk7Mrrtfx7xRxq798ynkY4DDDxZr235Kk+4w==}
@@ -2977,6 +2985,10 @@ snapshots:
possible-typed-array-names@1.1.0: {}
+ preact-render-to-string@6.6.6(preact@10.26.5):
+ dependencies:
+ preact: 10.26.5
+
preact@10.26.5: {}
prelude-ls@1.2.1: {}
diff --git a/test/index.test.ts b/test/index.test.ts
index c777e1e..4bbd50b 100644
--- a/test/index.test.ts
+++ b/test/index.test.ts
@@ -6,7 +6,8 @@ import { atom, map, onMount, STORE_UNMOUNT_DELAY } from 'nanostores'
import { deepStrictEqual, equal } from 'node:assert'
import { afterEach, test } from 'node:test'
import type { FunctionalComponent as FC } from 'preact'
-import { h } from 'preact'
+import { h, hydrate } from 'preact'
+import { renderToString } from 'preact-render-to-string'
import { useState } from 'preact/hooks'
import { useStore } from '../index.js'
@@ -60,7 +61,7 @@ test('renders simple store', async () => {
deepStrictEqual(events, ['constructor'])
equal(screen.getByTestId('test1').textContent, 'a')
equal(screen.getByTestId('test2').textContent, 'a')
- equal(renders, 1)
+ equal(renders, 2)
await act(async () => {
letter.set('b')
@@ -70,13 +71,13 @@ test('renders simple store', async () => {
equal(screen.getByTestId('test1').textContent, 'c')
equal(screen.getByTestId('test2').textContent, 'c')
- equal(renders, 2)
+ equal(renders, 3)
act(() => {
screen.getByRole('button').click()
})
equal(screen.queryByTestId('test'), null)
- equal(renders, 2)
+ equal(renders, 3)
await delay(STORE_UNMOUNT_DELAY)
deepStrictEqual(events, ['constructor', 'destroy'])
@@ -178,7 +179,7 @@ test('has keys option', async () => {
render(h(Wrapper, {}, h(MapTest, {})))
equal(screen.getByTestId('map-test').textContent, 'map:undefined-undefined')
- equal(renderCount, 1)
+ equal(renderCount, 2)
// updates on init
await act(async () => {
@@ -187,7 +188,7 @@ test('has keys option', async () => {
})
equal(screen.getByTestId('map-test').textContent, 'map:undefined-undefined')
- equal(renderCount, 2)
+ equal(renderCount, 3)
// updates when has key
await act(async () => {
@@ -196,7 +197,7 @@ test('has keys option', async () => {
})
equal(screen.getByTestId('map-test').textContent, 'map:a-undefined')
- equal(renderCount, 3)
+ equal(renderCount, 4)
// does not update when has no key
await act(async () => {
@@ -205,7 +206,7 @@ test('has keys option', async () => {
})
equal(screen.getByTestId('map-test').textContent, 'map:a-undefined')
- equal(renderCount, 3)
+ equal(renderCount, 4)
// reacts on parameter changes
await act(async () => {
@@ -214,7 +215,7 @@ test('has keys option', async () => {
})
equal(screen.getByTestId('map-test').textContent, 'map:a-b')
- equal(renderCount, 4)
+ equal(renderCount, 5)
})
test('supports atom changes between rendering and useEffect', () => {
@@ -254,3 +255,60 @@ test('supports map changes between rendering and useEffect', () => {
let result = screen.getByText('new').textContent
equal(result, 'new')
})
+
+test('returns initial value until hydrated', () => {
+ type Value = 'new' | 'old'
+ let atomStore = atom('old')
+ let mapStore = map<{ value: Value }>({ value: 'old' })
+
+ let atomValues: Value[] = [] // Track values used across renders
+
+ let AtomTest: FC = () => {
+ let value = useStore(atomStore)
+ atomValues.push(value)
+ return h('div', { 'data-testid': 'atom-test' }, value)
+ }
+
+ let mapValues: Value[] = [] // Track values used across renders
+
+ let MapTest: FC = () => {
+ let value = useStore(mapStore).value
+ mapValues.push(value)
+ return h('div', { 'data-testid': 'map-test' }, value)
+ }
+
+ let Wrapper: FC = () => {
+ return h(
+ 'div',
+ { 'data-testid': 'test' },
+ h(AtomTest, null),
+ h(MapTest, null)
+ )
+ }
+
+ // Create a "server" rendered element to re-hydrate
+ let ssrElement = document.createElement('div')
+ document.body.appendChild(ssrElement)
+ let html = renderToString(h(Wrapper, null))
+ ssrElement.innerHTML = html
+
+ equal(screen.getByTestId('atom-test').textContent, 'old')
+ equal(screen.getByTestId('map-test').textContent, 'old')
+
+ // Simulate store state change on client-side, after "server" render
+ atomStore.set('new')
+ mapStore.set({ value: 'new' })
+
+ // Hydrate into SSR element
+ act(() => {
+ hydrate(h(Wrapper, null), ssrElement)
+ })
+
+ // Confirm "server" render got old values, initial client render got old
+ // values at hydration, then post-hydration render got new values
+ deepStrictEqual(atomValues, ['old', 'old', 'new'])
+ deepStrictEqual(mapValues, ['old', 'old', 'new'])
+
+ equal(screen.getByTestId('atom-test').textContent, 'new')
+ equal(screen.getByTestId('map-test').textContent, 'new')
+})