Skip to content
Permalink

Comparing changes

Choose two branches to see what’s changed or to start a new pull request. If you need to, you can also or learn more about diff comparisons.

Open a pull request

Create a new pull request by comparing changes across two branches. If you need to, you can also . Learn more about diff comparisons here.
base repository: pmndrs/zustand
Failed to load repositories. Confirm that selected base ref is valid, then try again.
Loading
base: v4.4.7
Choose a base ref
...
head repository: pmndrs/zustand
Failed to load repositories. Confirm that selected head ref is valid, then try again.
Loading
compare: v4.5.0
Choose a head ref
  • 12 commits
  • 22 files changed
  • 10 contributors

Commits on Dec 9, 2023

  1. 1
    Copy the full SHA
    19960f6 View commit details
  2. 1
    Copy the full SHA
    fa04478 View commit details

Commits on Dec 11, 2023

  1. 1
    Copy the full SHA
    662f38a View commit details

Commits on Dec 12, 2023

  1. chore(test): stop testing deprecated features and test subscribe in v…

    …anilla (#2244)
    
    * chore(test): update test to stop testing deprecated features
    
    * chore(test): add subscribe test for zustand/vanilla
    
    * chore(test): only verify that subscribe exists on `create`
    
    * chore(test): switch type of test
    charkour authored Dec 12, 2023
    1
    Copy the full SHA
    9baf0a5 View commit details

Commits on Dec 13, 2023

  1. chore(docs): rewrite to remove deprecated references in persist docs (#…

    …2248)
    
    * chore(docs): rewrite to remove deprecated references in persist docs
    
    * chore(docs): remove deprecated equalityFn
    charkour authored Dec 13, 2023
    1
    Copy the full SHA
    41838c5 View commit details

Commits on Dec 15, 2023

  1. 1
    Copy the full SHA
    a4578fa View commit details

Commits on Dec 22, 2023

  1. Adding Zusty (zustand dev tool) to the third party libraries (#2267)

    * Update third-party-libraries.md
    
    Adding zustand dev tool made by oslabs to the third party libraries.
    
    * Update third-party-libraries.md
    
    changed to alphabetical order
    
    * Update third-party-libraries.md
    
    formatted with prettier/which got rid of the extra space on line 17
    JaeBrian authored Dec 22, 2023
    1
    Copy the full SHA
    517524d View commit details

Commits on Dec 31, 2023

  1. fix(docs): errors and types fix for URL state example (#2218)

    * Update connect-to-state-with-url-hash.md
    
    Resolved a few errors and type errors in the persist and create state with URL example:
    
    1. createJsonStorage not being called in storageOptions resulting in a type error.
    2. Correct hook not being exported
    3. Moved the creation of initial state inline to get the correct types passed from create/persist.
    4. Used state type as generic for persist.
    
    * yarn prettier run
    
    * Update docs/guides/connect-to-state-with-url-hash.md
    
    Better name for state in setter
    
    Co-authored-by: Danilo Britto <dbritto.dev@gmail.com>
    
    * prettier run
    
    ---------
    
    Co-authored-by: Danilo Britto <dbritto.dev@gmail.com>
    Co-authored-by: Daishi Kato <dai-shi@users.noreply.github.com>
    3 people authored Dec 31, 2023
    1
    Copy the full SHA
    4be1e9e View commit details

Commits on Jan 20, 2024

  1. feat: getInitialState (#2277)

    * 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>
    TkDodo and dai-shi authored Jan 20, 2024
    1
    Copy the full SHA
    740033c View commit details
  2. 1
    Copy the full SHA
    43a2b11 View commit details
  3. chore(deps): update dev dependencies (#2297)

    * chore(deps): update dev dependencies
    
    * fix lock file
    dai-shi authored Jan 20, 2024
    1
    Copy the full SHA
    d12b4fb View commit details
  4. 4.5.0

    dai-shi committed Jan 20, 2024
    1
    Copy the full SHA
    13830c1 View commit details
4 changes: 2 additions & 2 deletions .github/workflows/test-multiple-versions.yml
Original file line number Diff line number Diff line change
@@ -33,8 +33,8 @@ jobs:
- 18.0.0
- 18.1.0
- 18.2.0
- 18.3.0-canary-6c7b41da3-20231123
- 0.0.0-experimental-6c7b41da3-20231123
- 18.3.0-canary-feed8f3f9-20240118
- 0.0.0-experimental-feed8f3f9-20240118
devtools-skip:
- CI-MATRIX-NOSKIP
include:
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -70,7 +70,7 @@ you can use this suggested workflow:
- Create failing tests for your fix or new feature;
- Implement your changes and confirm that all test are passing.
You can run the tests continuously during development
with the `yarn test:dev` command.
with the `yarn test` command.
- If you want to test it in a React project:
- Either use `yarn link`, or
- Use the `yalc` package.
41 changes: 25 additions & 16 deletions docs/guides/connect-to-state-with-url-hash.md
Original file line number Diff line number Diff line change
@@ -71,18 +71,18 @@ const persistentStorage: StateStorage = {
if (getUrlSearch()) {
const searchParams = new URLSearchParams(getUrlSearch())
const storedValue = searchParams.get(key)
return JSON.parse(storedValue)
return JSON.parse(storedValue as string)
} else {
// Otherwise, we should load from localstorage or alternative storage
return JSON.parse(localStorage.getItem(key))
return JSON.parse(localStorage.getItem(key) as string)
}
},
setItem: (key, newValue): void => {
// Check if query params exist at all, can remove check if always want to set URL
if (getUrlSearch()) {
const searchParams = new URLSearchParams(getUrlSearch())
searchParams.set(key, JSON.stringify(newValue))
window.history.replaceState(null, null, `?${searchParams.toString()}`)
window.history.replaceState(null, '', `?${searchParams.toString()}`)
}

localStorage.setItem(key, JSON.stringify(newValue))
@@ -94,24 +94,33 @@ const persistentStorage: StateStorage = {
},
}

let localAndUrlStore = (set) => ({
typesOfFish: [],
addTypeOfFish: (fishType) =>
set((state) => ({ typesOfFish: [...state.typesOfFish, fishType] })),

numberOfBears: 0,
setNumberOfBears: (newNumber) =>
set((state) => ({ numberOfBears: newNumber })),
})
type LocalAndUrlStore = {
typesOfFish: string[]
addTypeOfFish: (fishType: string) => void
numberOfBears: number
setNumberOfBears: (newNumber: number) => void
}

let storageOptions = {
const storageOptions = {
name: 'fishAndBearsStore',
storage: persistentStorage,
storage: createJSONStorage<LocalAndUrlStore>(() => persistentStorage),
}

const useLocalAndUrlStore = create(persist(localAndUrlStore, storageOptions))
const useLocalAndUrlStore = create(
persist<LocalAndUrlStore>(
(set) => ({
typesOfFish: [],
addTypeOfFish: (fishType) =>
set((state) => ({ typesOfFish: [...state.typesOfFish, fishType] })),

numberOfBears: 0,
setNumberOfBears: (numberOfBears) => set(() => ({ numberOfBears })),
}),
storageOptions,
),
)

export default localAndUrlStore
export default useLocalAndUrlStore
```

When generating the URL from a component, you can call buildShareableUrl:
2 changes: 1 addition & 1 deletion docs/guides/flux-inspired-practice.md
Original file line number Diff line number Diff line change
@@ -74,4 +74,4 @@ import { redux } from 'zustand/middleware'
const useReduxStore = create(redux(reducer, initialState))
```

Another way to update the store could be through functions wrapping the state functions. These could also handle side-effects of actions. For example, with HTTP-calls. To use Zustand in a none-reactive way, see [the readme](https://github.com/pmndrs/zustand#readingwriting-state-and-reacting-to-changes-outside-of-components).
Another way to update the store could be through functions wrapping the state functions. These could also handle side-effects of actions. For example, with HTTP-calls. To use Zustand in a non-reactive way, see [the readme](https://github.com/pmndrs/zustand#readingwriting-state-and-reacting-to-changes-outside-of-components).
22 changes: 7 additions & 15 deletions docs/guides/typescript.md
Original file line number Diff line number Diff line change
@@ -443,15 +443,9 @@ const bearStore = createStore<BearState>()((set) => ({
}))

function useBearStore(): BearState
function useBearStore<T>(
selector: (state: BearState) => T,
equals?: (a: T, b: T) => boolean,
): T
function useBearStore<T>(
selector?: (state: BearState) => T,
equals?: (a: T, b: T) => boolean,
) {
return useStore(bearStore, selector!, equals)
function useBearStore<T>(selector: (state: BearState) => T): T
function useBearStore<T>(selector?: (state: BearState) => T) {
return useStore(bearStore, selector!)
}
```
@@ -471,15 +465,13 @@ const bearStore = createStore<BearState>()((set) => ({
increase: (by) => set((state) => ({ bears: state.bears + by })),
}))

const createBoundedUseStore = ((store) => (selector, equals) =>
useStore(store, selector as never, equals)) as <S extends StoreApi<unknown>>(
const createBoundedUseStore = ((store) => (selector) => useStore(store)) as <
S extends StoreApi<unknown>,
>(
store: S,
) => {
(): ExtractState<S>
<T>(
selector: (state: ExtractState<S>) => T,
equals?: (a: T, b: T) => boolean,
): T
<T>(selector: (state: ExtractState<S>) => T): T
}

type ExtractState<S> = S extends { getState: () => infer X } ? X : never
4 changes: 2 additions & 2 deletions docs/integrations/immer-middleware.md
Original file line number Diff line number Diff line change
@@ -125,5 +125,5 @@ Zustand will skip calling the subscriptions.

## CodeSandbox Demo

- [Basic](https://codesandbox.io/s/zustand-updating-draft-states-basic-demo-zkp22g),
- [Advanced](https://codesandbox.io/s/zustand-updating-draft-states-advanced-demo-3znqzk).
- [Basic](https://codesandbox.io/p/sandbox/zustand-updating-draft-states-basic-demo-forked-96mkdw),
- [Advanced](https://codesandbox.io/p/sandbox/zustand-updating-draft-states-advanced-demo-forked-phkzzg).
11 changes: 3 additions & 8 deletions docs/integrations/persisting-store-data.md
Original file line number Diff line number Diff line change
@@ -630,7 +630,7 @@ export const useBoundStore = create(
)
```

If you're using a type that JSON.stringify() doesn't support, you'll need to write your own serialization/deserialization code. However, if this is tedious, you can use third-party libraries to serialize and deserialize different types of data.
If you're using a type that `JSON.stringify()` doesn't support, you'll need to write your own serialization/deserialization code. However, if this is tedious, you can use third-party libraries to serialize and deserialize different types of data.

For example, [Superjson](https://github.com/blitz-js/superjson) can serialize data along with its type, allowing the data to be parsed back to its original type upon deserialization

@@ -735,15 +735,10 @@ export const useBearStore = create<MyState>()(

### How do I use it with Map and Set

With the previous persist API, you would use `serialize`/`deserialize`
to deal with `Map` and `Set` and convert them into
an Array so they could be parsed into proper JSON.
In order to persist object types such as `Map` and `Set`, they will need to be converted to JSON-serializable types such as an `Array` which can be done by defining a custom `storage` engine.

The new persist API has deprecated `serialize`/`deserialize`.

Now, you will need to use the `storage` prop.
Let's say your state uses `Map` to handle a list of `transactions`,
then you can convert the Map into an Array in the storage prop:
then you can convert the `Map` into an `Array` in the `storage` prop which is shown below:

```ts

1 change: 1 addition & 0 deletions docs/integrations/third-party-libraries.md
Original file line number Diff line number Diff line change
@@ -52,3 +52,4 @@ This can be done using third-party libraries created by the community.
- [zustand-yjs](https://github.com/tandem-pt/zustand-yjs) — Zustand stores for Yjs structures.
- [zusteller](https://github.com/timkindberg/zusteller) — Your global state savior. "Just hooks" + Zustand.
- [zustood](https://github.com/udecode/zustood) — 🐻‍❄️ A modular store factory using Zustand.
- [zusty](https://github.com/oslabs-beta/Zusty) - Zustand tool to assist debugging with time travel, action logs, state snapshots, store view, render time metrics and state component tree.
60 changes: 29 additions & 31 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "zustand",
"private": true,
"version": "4.4.7",
"version": "4.5.0",
"description": "🐻 Bear necessities for state management in React",
"main": "./index.js",
"types": "./index.d.ts",
@@ -205,68 +205,66 @@
"use-sync-external-store": "1.2.0"
},
"devDependencies": {
"@babel/core": "^7.23.3",
"@babel/core": "^7.23.7",
"@babel/plugin-external-helpers": "^7.23.3",
"@babel/plugin-transform-react-jsx": "^7.23.4",
"@babel/plugin-transform-runtime": "^7.23.4",
"@babel/plugin-transform-typescript": "^7.23.4",
"@babel/preset-env": "^7.23.3",
"@redux-devtools/extension": "^3.2.6",
"@babel/plugin-transform-runtime": "^7.23.7",
"@babel/plugin-transform-typescript": "^7.23.6",
"@babel/preset-env": "^7.23.8",
"@redux-devtools/extension": "^3.3.0",
"@rollup/plugin-alias": "^5.1.0",
"@rollup/plugin-babel": "^6.0.4",
"@rollup/plugin-node-resolve": "^15.2.3",
"@rollup/plugin-replace": "^5.0.5",
"@rollup/plugin-terser": "^0.4.4",
"@rollup/plugin-typescript": "^11.1.5",
"@rollup/plugin-typescript": "^11.1.6",
"@testing-library/react": "^14.1.2",
"@types/react": "^18.2.39",
"@types/react-dom": "^18.2.17",
"@types/node": "^20.11.5",
"@types/react": "^18.2.48",
"@types/react-dom": "^18.2.18",
"@types/use-sync-external-store": "^0.0.6",
"@typescript-eslint/eslint-plugin": "^6.13.0",
"@typescript-eslint/parser": "^6.13.0",
"@vitest/coverage-v8": "^0.34.6",
"@vitest/ui": "^0.34.6",
"@typescript-eslint/eslint-plugin": "^6.19.0",
"@typescript-eslint/parser": "^6.19.0",
"@vitest/coverage-v8": "0.33.0",
"@vitest/ui": "0.33.0",
"concurrently": "^8.2.2",
"downlevel-dts": "^0.11.0",
"esbuild": "^0.19.8",
"eslint": "^8.54.0",
"eslint-config-prettier": "^9.0.0",
"esbuild": "^0.19.11",
"eslint": "^8.56.0",
"eslint-config-prettier": "^9.1.0",
"eslint-import-resolver-alias": "^1.1.2",
"eslint-plugin-import": "^2.29.0",
"eslint-plugin-prettier": "^5.0.1",
"eslint-plugin-import": "^2.29.1",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-react": "^7.33.2",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-vitest": "^0.3.10",
"eslint-plugin-vitest": "^0.3.20",
"immer": "^10.0.3",
"jsdom": "^23.0.0",
"jsdom": "^23.2.0",
"json": "^11.0.0",
"prettier": "^3.1.0",
"prettier": "^3.2.4",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"redux": "5.0.0-alpha.1",
"rollup": "^4.6.0",
"redux": "^5.0.1",
"rollup": "^4.9.5",
"rollup-plugin-esbuild": "^6.1.0",
"shx": "^0.3.4",
"typescript": "^5.3.2",
"vitest": "^0.34.6"
"typescript": "^5.3.3",
"vitest": "0.33.0"
},
"peerDependencies": {
"immer": ">=9.0",
"@types/react": ">=16.8",
"immer": ">=9.0.6",
"react": ">=16.8"
},
"peerDependenciesMeta": {
"immer": {
"@types/react": {
"optional": true
},
"@types/react": {
"immer": {
"optional": true
},
"react": {
"optional": true
}
},
"resolutions": {
"vite": "4.5.0"
}
}
2 changes: 1 addition & 1 deletion readme.md
Original file line number Diff line number Diff line change
@@ -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
```
2 changes: 2 additions & 0 deletions src/middleware/persist.ts
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 5 additions & 2 deletions src/react.ts
Original file line number Diff line number Diff line change
@@ -23,11 +23,14 @@ type ExtractState<S> = S extends { getState: () => infer T } ? T : never
type ReadonlyStoreApi<T> = Pick<StoreApi<T>, 'getState' | 'subscribe'>

type WithReact<S extends ReadonlyStoreApi<unknown>> = S & {
/** @deprecated please use api.getState() */
getServerState?: () => ExtractState<S>
}

let didWarnAboutEqualityFn = false

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

export function useStore<S extends WithReact<StoreApi<unknown>>>(
api: S,
): ExtractState<S>
@@ -49,7 +52,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 (
@@ -65,7 +68,7 @@ export function useStore<TState, StateSlice>(
const slice = useSyncExternalStoreWithSelector(
api.subscribe,
api.getState,
api.getServerState || api.getState,
api.getServerState || api.getInitialState,
selector,
equalityFn,
)
7 changes: 5 additions & 2 deletions src/traditional.ts
Original file line number Diff line number Diff line change
@@ -23,9 +23,12 @@ type ExtractState<S> = S extends { getState: () => infer T } ? T : never
type ReadonlyStoreApi<T> = Pick<StoreApi<T>, 'getState' | 'subscribe'>

type WithReact<S extends ReadonlyStoreApi<unknown>> = S & {
/** @deprecated please use api.getState() */
getServerState?: () => ExtractState<S>
}

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

export function useStoreWithEqualityFn<S extends WithReact<StoreApi<unknown>>>(
api: S,
): ExtractState<S>
@@ -41,13 +44,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,
)
8 changes: 6 additions & 2 deletions src/vanilla.ts
Original file line number Diff line number Diff line change
@@ -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`
@@ -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
@@ -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
}

1 change: 1 addition & 0 deletions tests/basic.test.tsx
Original file line number Diff line number Diff line change
@@ -31,6 +31,7 @@ it('creates a store hook and api object', () => {
[Function],
{
"destroy": [Function],
"getInitialState": [Function],
"getState": [Function],
"setState": [Function],
"subscribe": [Function],
5 changes: 3 additions & 2 deletions tests/shallow.test.tsx
Original file line number Diff line number Diff line change
@@ -3,11 +3,12 @@ import { act, fireEvent, render } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { create } from 'zustand'
import { useShallow } from 'zustand/react/shallow'
import { createWithEqualityFn } from 'zustand/traditional'
import { shallow } from 'zustand/vanilla/shallow'

describe('types', () => {
it('works with useBoundStore and array selector (#1107)', () => {
const useBoundStore = create(() => ({
const useBoundStore = createWithEqualityFn(() => ({
villages: [] as { name: string }[],
}))
const Component = () => {
@@ -18,7 +19,7 @@ describe('types', () => {
})

it('works with useBoundStore and string selector (#1107)', () => {
const useBoundStore = create(() => ({
const useBoundStore = createWithEqualityFn(() => ({
refetchTimestamp: '',
}))
const Component = () => {
49 changes: 49 additions & 0 deletions tests/ssr.test.tsx
Original file line number Diff line number Diff line change
@@ -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)
122 changes: 4 additions & 118 deletions tests/subscribe.test.tsx
Original file line number Diff line number Diff line change
@@ -1,123 +1,9 @@
import { describe, expect, it, vi } from 'vitest'
import { describe, expect, it } from 'vitest'
import { create } from 'zustand'
import { subscribeWithSelector } from 'zustand/middleware'

describe('subscribe()', () => {
it('should not be called if new state identity is the same', () => {
const spy = vi.fn()
const initialState = { value: 1, other: 'a' }
const { setState, subscribe } = create(() => initialState)

subscribe(spy)
setState(initialState)
expect(spy).not.toHaveBeenCalled()
})

it('should be called if new state identity is different', () => {
const spy = vi.fn()
const initialState = { value: 1, other: 'a' }
const { setState, getState, subscribe } = create(() => initialState)

subscribe(spy)
setState({ ...getState() })
expect(spy).toHaveBeenCalledWith(initialState, initialState)
})

it('should not be called when state slice is the same', () => {
const spy = vi.fn()
const initialState = { value: 1, other: 'a' }
const { setState, subscribe } = create(
subscribeWithSelector(() => initialState),
)

subscribe((s) => s.value, spy)
setState({ other: 'b' })
expect(spy).not.toHaveBeenCalled()
})

it('should be called when state slice changes', () => {
const spy = vi.fn()
const initialState = { value: 1, other: 'a' }
const { setState, subscribe } = create(
subscribeWithSelector(() => initialState),
)

subscribe((s) => s.value, spy)
setState({ value: initialState.value + 1 })
expect(spy).toHaveBeenCalledTimes(1)
expect(spy).toHaveBeenCalledWith(initialState.value + 1, initialState.value)
})

it('should not be called when equality checker returns true', () => {
const spy = vi.fn()
const initialState = { value: 1, other: 'a' }
const { setState, subscribe } = create(
subscribeWithSelector(() => initialState),
)

subscribe((s) => s, spy, { equalityFn: () => true })
setState({ value: initialState.value + 2 })
expect(spy).not.toHaveBeenCalled()
})

it('should be called when equality checker returns false', () => {
const spy = vi.fn()
const initialState = { value: 1, other: 'a' }
const { setState, subscribe } = create(
subscribeWithSelector(() => initialState),
)

subscribe((s) => s.value, spy, { equalityFn: () => false })
setState({ value: initialState.value + 2 })
expect(spy).toHaveBeenCalledTimes(1)
expect(spy).toHaveBeenCalledWith(initialState.value + 2, initialState.value)
})

it('should unsubscribe correctly', () => {
const spy = vi.fn()
const initialState = { value: 1, other: 'a' }
const { setState, subscribe } = create(
subscribeWithSelector(() => initialState),
)

const unsub = subscribe((s) => s.value, spy)

setState({ value: initialState.value + 1 })
unsub()
setState({ value: initialState.value + 2 })

expect(spy).toHaveBeenCalledTimes(1)
expect(spy).toHaveBeenCalledWith(initialState.value + 1, initialState.value)
})

it('should keep consistent behavior with equality check', () => {
const spy = vi.fn()
const initialState = { value: 1, other: 'a' }
const { getState, setState, subscribe } = create(
subscribeWithSelector(() => initialState),
)

const isRoughEqual = (x: number, y: number) => Math.abs(x - y) < 1
setState({ value: 0 })
spy.mockReset()
const spy2 = vi.fn()
let prevValue = getState().value
const unsub = subscribe((s) => {
if (isRoughEqual(prevValue, s.value)) {
// skip assuming values are equal
return
}
spy(s.value, prevValue)
prevValue = s.value
})
const unsub2 = subscribe((s) => s.value, spy2, { equalityFn: isRoughEqual })
setState({ value: 0.5 })
setState({ value: 1 })
unsub()
unsub2()
expect(spy).toHaveBeenCalledTimes(1)
expect(spy).toHaveBeenCalledWith(1, 0)
expect(spy2).toHaveBeenCalledTimes(1)
expect(spy2).toHaveBeenCalledWith(1, 0)
it('should correctly have access to subscribe', () => {
const { subscribe } = create(() => ({ value: 1 }))
expect(typeof subscribe).toBe('function')
})
})
4 changes: 3 additions & 1 deletion tests/vanilla/basic.test.ts
Original file line number Diff line number Diff line change
@@ -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],
123 changes: 123 additions & 0 deletions tests/vanilla/subscribe.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { describe, expect, it, vi } from 'vitest'
import { subscribeWithSelector } from 'zustand/middleware'
import { createStore } from 'zustand/vanilla'

describe('subscribe()', () => {
it('should not be called if new state identity is the same', () => {
const spy = vi.fn()
const initialState = { value: 1, other: 'a' }
const { setState, subscribe } = createStore(() => initialState)

subscribe(spy)
setState(initialState)
expect(spy).not.toHaveBeenCalled()
})

it('should be called if new state identity is different', () => {
const spy = vi.fn()
const initialState = { value: 1, other: 'a' }
const { setState, getState, subscribe } = createStore(() => initialState)

subscribe(spy)
setState({ ...getState() })
expect(spy).toHaveBeenCalledWith(initialState, initialState)
})

it('should not be called when state slice is the same', () => {
const spy = vi.fn()
const initialState = { value: 1, other: 'a' }
const { setState, subscribe } = createStore(
subscribeWithSelector(() => initialState),
)

subscribe((s) => s.value, spy)
setState({ other: 'b' })
expect(spy).not.toHaveBeenCalled()
})

it('should be called when state slice changes', () => {
const spy = vi.fn()
const initialState = { value: 1, other: 'a' }
const { setState, subscribe } = createStore(
subscribeWithSelector(() => initialState),
)

subscribe((s) => s.value, spy)
setState({ value: initialState.value + 1 })
expect(spy).toHaveBeenCalledTimes(1)
expect(spy).toHaveBeenCalledWith(initialState.value + 1, initialState.value)
})

it('should not be called when equality checker returns true', () => {
const spy = vi.fn()
const initialState = { value: 1, other: 'a' }
const { setState, subscribe } = createStore(
subscribeWithSelector(() => initialState),
)

subscribe((s) => s, spy, { equalityFn: () => true })
setState({ value: initialState.value + 2 })
expect(spy).not.toHaveBeenCalled()
})

it('should be called when equality checker returns false', () => {
const spy = vi.fn()
const initialState = { value: 1, other: 'a' }
const { setState, subscribe } = createStore(
subscribeWithSelector(() => initialState),
)

subscribe((s) => s.value, spy, { equalityFn: () => false })
setState({ value: initialState.value + 2 })
expect(spy).toHaveBeenCalledTimes(1)
expect(spy).toHaveBeenCalledWith(initialState.value + 2, initialState.value)
})

it('should unsubscribe correctly', () => {
const spy = vi.fn()
const initialState = { value: 1, other: 'a' }
const { setState, subscribe } = createStore(
subscribeWithSelector(() => initialState),
)

const unsub = subscribe((s) => s.value, spy)

setState({ value: initialState.value + 1 })
unsub()
setState({ value: initialState.value + 2 })

expect(spy).toHaveBeenCalledTimes(1)
expect(spy).toHaveBeenCalledWith(initialState.value + 1, initialState.value)
})

it('should keep consistent behavior with equality check', () => {
const spy = vi.fn()
const initialState = { value: 1, other: 'a' }
const { getState, setState, subscribe } = createStore(
subscribeWithSelector(() => initialState),
)

const isRoughEqual = (x: number, y: number) => Math.abs(x - y) < 1
setState({ value: 0 })
spy.mockReset()
const spy2 = vi.fn()
let prevValue = getState().value
const unsub = subscribe((s) => {
if (isRoughEqual(prevValue, s.value)) {
// skip assuming values are equal
return
}
spy(s.value, prevValue)
prevValue = s.value
})
const unsub2 = subscribe((s) => s.value, spy2, { equalityFn: isRoughEqual })
setState({ value: 0.5 })
setState({ value: 1 })
unsub()
unsub2()
expect(spy).toHaveBeenCalledTimes(1)
expect(spy).toHaveBeenCalledWith(1, 0)
expect(spy2).toHaveBeenCalledTimes(1)
expect(spy2).toHaveBeenCalledWith(1, 0)
})
})
7 changes: 4 additions & 3 deletions tsconfig.json
Original file line number Diff line number Diff line change
@@ -6,16 +6,17 @@
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"skipLibCheck": true /* FIXME remove this once vite fixes it */,
"allowImportingTsExtensions": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noEmit": true,
"baseUrl": ".",
"paths": {
"zustand": ["./src/index.ts"],
"zustand/*": ["./src/*.ts"]
}
"zustand/*": ["./src/*.ts"],
},
},
"include": ["src/**/*", "tests/**/*"],
"exclude": ["node_modules", "dist"]
"exclude": ["node_modules", "dist"],
}
1,672 changes: 762 additions & 910 deletions yarn.lock

Large diffs are not rendered by default.