diff --git a/README.md b/README.md
index d992a83..9f25f8b 100644
--- a/README.md
+++ b/README.md
@@ -25,6 +25,18 @@ export const Header = () => {
}
```
+[Nano Stores]: https://github.com/nanostores/nanostores/
+
+---
+
+
Made at Evil Martians, product consulting for developer tools.
+
+---
+
+## Options
+
+### Keys
+
Use the `keys` option to re-render only on specific key changes:
```tsx
@@ -34,10 +46,47 @@ export const Header = () => {
}
```
-[Nano Stores]: https://github.com/nanostores/nanostores/
+### SSR
----
+SSR could be very complicated in React. To avoid hydration errors you
+need exactly the same stores state in the end of server HTML rendering
+and during the first DOM render on the client.
-
Made at Evil Martians, product consulting for developer tools.
+For simple solution you can disable any store update on the server
+by `ssr: 'initial'`:
----
+```tsx
+export const Header = () => {
+ const profile = useStore($profile, { ssr: 'initial' })
+
+ // Server render and client hydration use store's initial value.
+ // After hydration, client re-renders with the current value.
+ return
+}
+```
+
+For advanced cases where you update store values on the server before SSR, and
+need pages to hydrate with the updated value from the server, set a function
+that returns the server state: `ssr: () => serverState`.
+
+```tsx
+// Value of store on server at time of SSR, passed to client somehow...
+const profileFromServer = { name: 'A User' }
+
+export const Header = () => {
+ const profile = useStore($profile, {
+ // On server, always use up-to-date store value (set `ssr` to `false`).
+ // On client, set server value to avoid error on hydration.
+ ssr: typeof window === 'object' && (() => profileFromServer)
+ })
+
+ // Server render uses store's current value. Client uses value from function
+ // for hydration, then after hydration re-renders with the current value.
+ return
+}
+```
+
+A function set on `ssr` works similarly to the `getServerSnapshot` option of
+React's
+[`useSyncExternalStore`](https://react.dev/reference/react/useSyncExternalStore)
+hook, though it works differently internally for Preact.
diff --git a/index.d.ts b/index.d.ts
index 8416925..3a19810 100644
--- a/index.d.ts
+++ b/index.d.ts
@@ -9,6 +9,12 @@ export interface UseStoreOptions {
* Will re-render components only on specific key changes.
*/
keys?: StoreKeys[]
+ /**
+ * Enable SSR support. Set `initial` when store's initial value is the same
+ * on server and client, or provide a function to return the server store
+ * state for advanced cases.
+ */
+ ssr?: 'initial' | (() => StoreValue) | false
}
/**
diff --git a/index.js b/index.js
index 973c3bf..00158b2 100644
--- a/index.js
+++ b/index.js
@@ -1,13 +1,15 @@
import { listenKeys } from 'nanostores'
import { useEffect, useState } from 'preact/hooks'
-export function useStore(store, opts = {}) {
- let [hydrated, setHydrated] = useState(false)
+export function useStore(store, { keys, ssr } = {}) {
+ let [isHydrated, setIsHydrated] = useState(false)
let [, forceRender] = useState({})
+ let [valueBeforeEffect] = useState(store.get())
- // A re-render is always forced on mount and hydrate
useEffect(() => {
- setHydrated(true)
+ // Skip re-render afer hydration when not needed for SSR support
+ ssr && setIsHydrated(true)
+ valueBeforeEffect !== store.get() && forceRender({})
}, [])
useEffect(() => {
@@ -21,8 +23,8 @@ export function useStore(store, opts = {}) {
})
}
}
- if (opts.keys) {
- unlisten = listenKeys(store, opts.keys, rerender)
+ if (keys) {
+ unlisten = listenKeys(store, keys, rerender)
} else {
unlisten = store.listen(rerender)
}
@@ -30,7 +32,13 @@ export function useStore(store, opts = {}) {
unlisten()
clearTimeout(timer)
}
- }, [store, '' + opts.keys])
+ }, [store, '' + keys])
- return !hydrated ? store.init : store.get()
+ // For SSR return initial value or result of function until hydrated: always
+ // on server, until post-hydration on client
+ if (ssr && !isHydrated) {
+ return ssr === 'initial' ? store.init : ssr()
+ }
+
+ return store.get()
}
diff --git a/package.json b/package.json
index 0a5e6e4..c4b6956 100644
--- a/package.json
+++ b/package.json
@@ -90,7 +90,7 @@
"index.js": "{ useStore }",
"nanostores": "{ map, computed }"
},
- "limit": "931 B"
+ "limit": "965 B"
}
]
}
diff --git a/test/index.test.ts b/test/index.test.ts
index 4bbd50b..b7b3f15 100644
--- a/test/index.test.ts
+++ b/test/index.test.ts
@@ -61,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, 2)
+ equal(renders, 1)
await act(async () => {
letter.set('b')
@@ -71,13 +71,13 @@ test('renders simple store', async () => {
equal(screen.getByTestId('test1').textContent, 'c')
equal(screen.getByTestId('test2').textContent, 'c')
- equal(renders, 3)
+ equal(renders, 2)
act(() => {
screen.getByRole('button').click()
})
equal(screen.queryByTestId('test'), null)
- equal(renders, 3)
+ equal(renders, 2)
await delay(STORE_UNMOUNT_DELAY)
deepStrictEqual(events, ['constructor', 'destroy'])
@@ -179,7 +179,7 @@ test('has keys option', async () => {
render(h(Wrapper, {}, h(MapTest, {})))
equal(screen.getByTestId('map-test').textContent, 'map:undefined-undefined')
- equal(renderCount, 2)
+ equal(renderCount, 1)
// updates on init
await act(async () => {
@@ -188,7 +188,7 @@ test('has keys option', async () => {
})
equal(screen.getByTestId('map-test').textContent, 'map:undefined-undefined')
- equal(renderCount, 3)
+ equal(renderCount, 2)
// updates when has key
await act(async () => {
@@ -197,7 +197,7 @@ test('has keys option', async () => {
})
equal(screen.getByTestId('map-test').textContent, 'map:a-undefined')
- equal(renderCount, 4)
+ equal(renderCount, 3)
// does not update when has no key
await act(async () => {
@@ -206,7 +206,7 @@ test('has keys option', async () => {
})
equal(screen.getByTestId('map-test').textContent, 'map:a-undefined')
- equal(renderCount, 4)
+ equal(renderCount, 3)
// reacts on parameter changes
await act(async () => {
@@ -215,7 +215,7 @@ test('has keys option', async () => {
})
equal(screen.getByTestId('map-test').textContent, 'map:a-b')
- equal(renderCount, 5)
+ equal(renderCount, 4)
})
test('supports atom changes between rendering and useEffect', () => {
@@ -256,7 +256,7 @@ test('supports map changes between rendering and useEffect', () => {
equal(result, 'new')
})
-test('returns initial value until hydrated', () => {
+test('support for SSR does not break server behaviour in non-SSR projects', () => {
type Value = 'new' | 'old'
let atomStore = atom('old')
let mapStore = map<{ value: Value }>({ value: 'old' })
@@ -272,7 +272,11 @@ test('returns initial value until hydrated', () => {
let mapValues: Value[] = [] // Track values used across renders
let MapTest: FC = () => {
- let value = useStore(mapStore).value
+ let value = useStore(
+ mapStore,
+ // Setting `ssr:false` should be equivalent to not setting `ssr` at all
+ { ssr: false }
+ ).value
mapValues.push(value)
return h('div', { 'data-testid': 'map-test' }, value)
}
@@ -286,16 +290,66 @@ test('returns initial value until hydrated', () => {
)
}
+ // Simulate store state change on server side
+ atomStore.set('new')
+ mapStore.set({ value: 'new' })
+
+ // Create a "server" rendered element
+ let ssrElement = document.createElement('div')
+ document.body.appendChild(ssrElement)
+ let html = renderToString(h(Wrapper, null))
+ ssrElement.innerHTML = html
+
+ // Confirm server rendered HTML includes the latest store data
+ equal(screen.getByTestId('atom-test').textContent, 'new')
+ equal(screen.getByTestId('map-test').textContent, 'new')
+})
+
+test('support SSR to fix client hydration errors, use initial data', () => {
+ 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, { ssr: 'initial' })
+ 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, { ssr: 'initial' }).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)
+ )
+ }
+
+ // Simulate store state change on server side
+ atomStore.set('new')
+ mapStore.set({ value: 'new' })
+
// 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
+ // Confirm server renders initial value, not current value
equal(screen.getByTestId('atom-test').textContent, 'old')
equal(screen.getByTestId('map-test').textContent, 'old')
- // Simulate store state change on client-side, after "server" render
+ // Simulate store change on client, now different from value at "server" SSR
atomStore.set('new')
mapStore.set({ value: 'new' })
@@ -304,11 +358,93 @@ test('returns initial value until hydrated', () => {
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
+ // Confirm "server" render (renderToString) 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')
})
+
+test('support SSR to fix client hydration errors, server passes data to client', () => {
+ type Value = 'initial' | 'update on client' | 'update on server'
+ let atomStore = atom('initial')
+ let mapStore = map<{ value: Value }>({ value: 'initial' })
+
+ let ssrDataFnForAtom: typeof atomStore.get | undefined
+ let ssrDataFnForMap: typeof mapStore.get | undefined
+
+ let atomValues: Value[] = [] // Track values used across renders
+
+ let AtomTest: FC = () => {
+ let value = useStore(atomStore, { ssr: ssrDataFnForAtom })
+ 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, { ssr: ssrDataFnForMap }).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)
+ )
+ }
+
+ // Simulate store state change on server side
+ atomStore.set('update on server')
+ mapStore.set({ value: 'update on server' })
+
+ // 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
+
+ // Confirm server render includes latest updates to server store
+ equal(screen.getByTestId('atom-test').textContent, 'update on server')
+ equal(screen.getByTestId('map-test').textContent, 'update on server')
+
+ // Simulate store state change on client-side, after "server" render
+ atomStore.set('update on client')
+ mapStore.set({ value: 'update on client' })
+
+ // Simulate passing of store state data from server to client, provided to
+ // hook via `ssr` option
+ ssrDataFnForAtom = (): Value => 'update on server'
+ let serverDataForMap = { value: 'update on server' as Value }
+ ssrDataFnForMap = (): { value: Value } => serverDataForMap
+
+ // Hydrate into SSR element
+ act(() => {
+ hydrate(h(Wrapper, null), ssrElement)
+ })
+
+ // Confirm "server" render got latest update on server, initial client render
+ // got latest update on server at hydration, then post-hydration render got
+ // latest update on client
+ deepStrictEqual(atomValues, [
+ 'update on server',
+ 'update on server',
+ 'update on client'
+ ])
+ deepStrictEqual(mapValues, [
+ 'update on server',
+ 'update on server',
+ 'update on client'
+ ])
+
+ // Confirm final rendered version has latest updates to client store
+ equal(screen.getByTestId('atom-test').textContent, 'update on client')
+ equal(screen.getByTestId('map-test').textContent, 'update on client')
+})