Skip to content

Commit

Permalink
feat: getInitialState (#2277)
Browse files Browse the repository at this point in the history
* feat(react): implement getServerState by closing over the defaultState

serverState will be used by react on the first client render; this should avoid hydration mismatches when combined with the persist middleware, which can change the state between the SSR and the first CSR

* define getServerState in vanilla.ts

* feat: implement getServerResult in persist middleware

this avoids hydration errors when state is restored from e.g. localstorage synchronously

* feat: capture initialState for getServerState in react

this avoids hydration mismatches when updates happen to the store state between ssr and csr

* refactor: revert changes to oldImpl

* fix: make selector default to identity function

if we default to `api.getState`, we will always read the client snapshot if there is no selector passed. An identity function returns its argument, which is either the snapshot (api.getState) or the server snapshot (api.getServerState)

* define getInitialState in vanilla

* revert WithReact

* fix them

* fix test

* oops, fix another test too

* forgot to use identity

* test: add a test for hydration errors

* fix(readme): imply getInitialState is a public api

---------

Co-authored-by: daishi <daishi@axlight.com>
  • Loading branch information
TkDodo and dai-shi authored Jan 20, 2024
1 parent 4be1e9e commit 740033c
Show file tree
Hide file tree
Showing 8 changed files with 70 additions and 8 deletions.
2 changes: 1 addition & 1 deletion readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,7 @@ Zustand core can be imported and used without the React dependency. The only dif
import { createStore } from 'zustand/vanilla'

const store = createStore((set) => ...)
const { getState, setState, subscribe } = store
const { getState, setState, subscribe, getInitialState } = store

export default store
```
Expand Down
2 changes: 2 additions & 0 deletions src/middleware/persist.ts
Original file line number Diff line number Diff line change
Expand Up @@ -425,6 +425,8 @@ const newImpl: PersistImpl = (config, baseOptions) => (set, get, api) => {
api,
)

api.getInitialState = () => configResult

// a workaround to solve the issue of not storing rehydrated state in sync storage
// the set(state) value would be later overridden with initial state by create()
// to avoid this, we merge the state from localStorage into the initial state.
Expand Down
6 changes: 4 additions & 2 deletions src/react.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ type WithReact<S extends ReadonlyStoreApi<unknown>> = S & {

let didWarnAboutEqualityFn = false

const identity = <T>(arg: T): T => arg

export function useStore<S extends WithReact<StoreApi<unknown>>>(
api: S,
): ExtractState<S>
Expand All @@ -49,7 +51,7 @@ export function useStore<S extends WithReact<StoreApi<unknown>>, U>(

export function useStore<TState, StateSlice>(
api: WithReact<StoreApi<TState>>,
selector: (state: TState) => StateSlice = api.getState as any,
selector: (state: TState) => StateSlice = identity as any,
equalityFn?: (a: StateSlice, b: StateSlice) => boolean,
) {
if (
Expand All @@ -65,7 +67,7 @@ export function useStore<TState, StateSlice>(
const slice = useSyncExternalStoreWithSelector(
api.subscribe,
api.getState,
api.getServerState || api.getState,
api.getServerState || api.getInitialState,
selector,
equalityFn,
)
Expand Down
6 changes: 4 additions & 2 deletions src/traditional.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ type WithReact<S extends ReadonlyStoreApi<unknown>> = S & {
getServerState?: () => ExtractState<S>
}

const identity = <T>(arg: T): T => arg

export function useStoreWithEqualityFn<S extends WithReact<StoreApi<unknown>>>(
api: S,
): ExtractState<S>
Expand All @@ -41,13 +43,13 @@ export function useStoreWithEqualityFn<

export function useStoreWithEqualityFn<TState, StateSlice>(
api: WithReact<StoreApi<TState>>,
selector: (state: TState) => StateSlice = api.getState as any,
selector: (state: TState) => StateSlice = identity as any,
equalityFn?: (a: StateSlice, b: StateSlice) => boolean,
) {
const slice = useSyncExternalStoreWithSelector(
api.subscribe,
api.getState,
api.getServerState || api.getState,
api.getServerState || api.getInitialState,
selector,
equalityFn,
)
Expand Down
8 changes: 6 additions & 2 deletions src/vanilla.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ type SetStateInternal<T> = {
export interface StoreApi<T> {
setState: SetStateInternal<T>
getState: () => T
getInitialState: () => T
subscribe: (listener: (state: T, prevState: T) => void) => () => void
/**
* @deprecated Use `unsubscribe` returned by `subscribe`
Expand Down Expand Up @@ -82,6 +83,9 @@ const createStoreImpl: CreateStoreImpl = (createState) => {

const getState: StoreApi<TState>['getState'] = () => state

const getInitialState: StoreApi<TState>['getInitialState'] = () =>
initialState

const subscribe: StoreApi<TState>['subscribe'] = (listener) => {
listeners.add(listener)
// Unsubscribe
Expand All @@ -97,8 +101,8 @@ const createStoreImpl: CreateStoreImpl = (createState) => {
listeners.clear()
}

const api = { setState, getState, subscribe, destroy }
state = createState(setState, getState, api)
const api = { setState, getState, getInitialState, subscribe, destroy }
const initialState = (state = createState(setState, getState, api))
return api as any
}

Expand Down
1 change: 1 addition & 0 deletions tests/basic.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ it('creates a store hook and api object', () => {
[Function],
{
"destroy": [Function],
"getInitialState": [Function],
"getState": [Function],
"setState": [Function],
"subscribe": [Function],
Expand Down
49 changes: 49 additions & 0 deletions tests/ssr.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,55 @@ describe.skipIf(!React.version.startsWith('18'))(
)
})

const bearCountText = await screen.findByText('bears: 1')
expect(bearCountText).not.toBeNull()
document.body.removeChild(container)
})
it('should not have hydration errors', async () => {
const useStore = create(() => ({
bears: 0,
}))

const { hydrateRoot } =
await vi.importActual<typeof import('react-dom/client')>(
'react-dom/client',
)

const Component = () => {
const bears = useStore((state) => state.bears)
return <div>bears: {bears}</div>
}

const markup = renderToString(
<React.Suspense fallback={<div>Loading...</div>}>
<Component />
</React.Suspense>,
)

const container = document.createElement('div')
document.body.appendChild(container)
container.innerHTML = markup

expect(container.textContent).toContain('bears: 0')

const consoleMock = vi.spyOn(console, 'error')

const hydratePromise = act(async () => {
hydrateRoot(
container,
<React.Suspense fallback={<div>Loading...</div>}>
<Component />
</React.Suspense>,
)
})

// set state during hydration
useStore.setState({ bears: 1 })

await hydratePromise

expect(consoleMock).toHaveBeenCalledTimes(0)

const bearCountText = await screen.findByText('bears: 1')
expect(bearCountText).not.toBeNull()
document.body.removeChild(container)
Expand Down
4 changes: 3 additions & 1 deletion tests/vanilla/basic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,19 +17,21 @@ it('create a store', () => {
return { value: null }
})
expect({ params, result }).toMatchInlineSnapshot(`
{
{
"params": [
[Function],
[Function],
{
"destroy": [Function],
"getInitialState": [Function],
"getState": [Function],
"setState": [Function],
"subscribe": [Function],
},
],
"result": {
"destroy": [Function],
"getInitialState": [Function],
"getState": [Function],
"setState": [Function],
"subscribe": [Function],
Expand Down

1 comment on commit 740033c

@vercel
Copy link

@vercel vercel bot commented on 740033c Jan 20, 2024

Choose a reason for hiding this comment

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

Please sign in to comment.