Skip to content

Commit

Permalink
feat: use-sync-external-store (#550)
Browse files Browse the repository at this point in the history
* imaginary code that uses uSES

* revert backward compatibility code as this is not going to be v4

* use use-sync-external-store

* revert to react 17

* handle error by our own

* v4.0.0-alpha.2

* fix&refactor a bit

* update uSES experimental package

* remove error propagation hack

* update size snapshot

* update uSES and add dts

* split react.ts and no export wild

* split useStore impl

* context to follow the new api, export wild again

* v4.0.0-alpha.3

* add missing await

* update uSES

* update uSES

* uses uSES extra!

* v4.0.0-alpha.3

* update uSES

* fix update uSES

* v4.0.0-alpha.5

* add useDebugValue

* update uSES

* update uSES types

* update uSES

* v4.0.0-alpha.6

* fix(readme): remove memoization section which is no longer valid with uSES

* feat(readme): add new createStore/useStore usage

* update useSES

* update uSES and deps

* v4.0.0-alpha.7

* update uSES

* update uSES

* shave bytes

* update uSES

* fix yarn lock

* temporary fix #829

* uSES rc.1

* getServerState for #886, no types yet

* uSES v1
  • Loading branch information
dai-shi authored Apr 7, 2022
1 parent 34bf82b commit a34649d
Show file tree
Hide file tree
Showing 7 changed files with 109 additions and 159 deletions.
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,9 @@
"tests/**/*.{js,ts,tsx}"
]
},
"dependencies": {
"use-sync-external-store": "1.0.0"
},
"devDependencies": {
"@babel/core": "^7.17.9",
"@babel/plugin-external-helpers": "^7.16.7",
Expand All @@ -147,6 +150,7 @@
"@types/jest": "^27.4.1",
"@types/react": "^17.0.43",
"@types/react-dom": "^17.0.14",
"@types/use-sync-external-store": "^0.0.3",
"@typescript-eslint/eslint-plugin": "^5.18.0",
"@typescript-eslint/parser": "^5.18.0",
"concurrently": "^7.1.0",
Expand Down
46 changes: 28 additions & 18 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,23 +106,6 @@ const treats = useStore(
)
```

## Memoizing selectors

It is generally recommended to memoize selectors with useCallback. This will prevent unnecessary computations each render. It also allows React to optimize performance in concurrent mode.

```jsx
const fruit = useStore(useCallback(state => state.fruits[id], [id]))
```

If a selector doesn't depend on scope, you can define it outside the render function to obtain a fixed reference without useCallback.

```jsx
const selector = state => state.berries

function Component() {
const berries = useStore(selector)
```
## Overwriting state

The `set` function has a second argument, `false` by default. Instead of merging, it will replace the state model. Be careful not to wipe out parts you rely on, like actions.
Expand Down Expand Up @@ -468,7 +451,33 @@ devtools(..., { anonymousActionType: 'unknown', ... })
## React context
The store created with `create` doesn't require context providers. In some cases, you may want to use contexts for dependency injection or if you want to initialize your store with props from a component. Because the store is a hook, passing it as a normal context value may violate rules of hooks. To avoid misusage, a special `createContext` is provided.
The store created with `create` doesn't require context providers. In some cases, you may want to use contexts for dependency injection or if you want to initialize your store with props from a component. Because the normal store is a hook, passing it as a normal context value may violate rules of hooks.
The flexible method available since v4 is to use vanilla store.
```jsx
import { createContext, useContext } from 'react'
import { createStore, useStore } from 'zustand'

const store = createStore(...) // vanilla store without hooks

const StoreContext = createContext()

const App = () => (
<StoreContext.Provider value={store}>
...
</StoreContext.Provider>
)

const Component = () => {
const store = useContext(StoreContext)
const slice = useStore(store, selector)
...
}
```
Alternatively, a special `createContext` is provided since v3.5,
which avoid misusing the store hook.
```jsx
import create from 'zustand'
Expand All @@ -490,6 +499,7 @@ const Component = () => {
...
}
```
<details>
<summary>createContext usage in real components</summary>
Expand Down
3 changes: 3 additions & 0 deletions rollup.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ function createESMConfig(input, output) {
resolve({ extensions }),
replace({
__DEV__: '(import.meta.env&&import.meta.env.MODE)!=="production"',
// a workround for #829
'use-sync-external-store/shim/with-selector':
'use-sync-external-store/shim/with-selector.js',
preventAssignment: true,
}),
getEsbuild('node12'),
Expand Down
66 changes: 29 additions & 37 deletions src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,13 @@ import {
useMemo,
useRef,
} from 'react'
import { EqualityChecker, State, StateSelector, UseBoundStore } from 'zustand'
import {
EqualityChecker,
State,
StateSelector,
StoreApi,
useStore,
} from 'zustand'

/**
* @deprecated Use `typeof MyContext.useStore` instead.
Expand All @@ -18,35 +24,22 @@ export type UseContextStore<T extends State> = {

function createContext<
TState extends State,
TUseBoundStore extends UseBoundStore<TState> = UseBoundStore<TState>
CustomStoreApi extends StoreApi<TState> = StoreApi<TState>
>() {
const ZustandContext = reactCreateContext<TUseBoundStore | undefined>(
const ZustandContext = reactCreateContext<CustomStoreApi | undefined>(
undefined
)

const Provider = ({
initialStore,
createStore,
children,
}: {
/**
* @deprecated
*/
initialStore?: TUseBoundStore
createStore: () => TUseBoundStore
createStore: () => CustomStoreApi
children: ReactNode
}) => {
const storeRef = useRef<TUseBoundStore>()
const storeRef = useRef<CustomStoreApi>()

if (!storeRef.current) {
if (initialStore) {
console.warn(
'Provider initialStore is deprecated and will be removed in the next version.'
)
if (!createStore) {
createStore = () => initialStore
}
}
storeRef.current = createStore()
}

Expand All @@ -57,50 +50,49 @@ function createContext<
)
}

const useStore: UseContextStore<TState> = <StateSlice>(
const useBoundStore: UseContextStore<TState> = <StateSlice>(
selector?: StateSelector<TState, StateSlice>,
equalityFn = Object.is
equalityFn?: EqualityChecker<StateSlice>
) => {
// ZustandContext value is guaranteed to be stable.
const useProviderStore = useContext(ZustandContext)
if (!useProviderStore) {
const store = useContext(ZustandContext)
if (!store) {
throw new Error(
'Seems like you have not used zustand provider as an ancestor.'
)
}
return useProviderStore(
return useStore(
store,
selector as StateSelector<TState, StateSlice>,
equalityFn
)
}

const useStoreApi = (): {
getState: TUseBoundStore['getState']
setState: TUseBoundStore['setState']
subscribe: TUseBoundStore['subscribe']
destroy: TUseBoundStore['destroy']
getState: CustomStoreApi['getState']
setState: CustomStoreApi['setState']
subscribe: CustomStoreApi['subscribe']
destroy: CustomStoreApi['destroy']
} => {
// ZustandContext value is guaranteed to be stable.
const useProviderStore = useContext(ZustandContext)
if (!useProviderStore) {
const store = useContext(ZustandContext)
if (!store) {
throw new Error(
'Seems like you have not used zustand provider as an ancestor.'
)
}
return useMemo(
() => ({
getState: useProviderStore.getState,
setState: useProviderStore.setState,
subscribe: useProviderStore.subscribe,
destroy: useProviderStore.destroy,
getState: store.getState,
setState: store.setState,
subscribe: store.subscribe,
destroy: store.destroy,
}),
[useProviderStore]
[store]
)
}

return {
Provider,
useStore,
useStore: useBoundStore,
useStoreApi,
}
}
Expand Down
127 changes: 28 additions & 99 deletions src/react.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,5 @@
import {
useDebugValue,
useEffect,
useLayoutEffect,
useReducer,
useRef,
} from 'react'
import { useDebugValue } from 'react'
import { useSyncExternalStoreWithSelector } from 'use-sync-external-store/shim/with-selector'
import createStore, {
EqualityChecker,
GetState,
Expand All @@ -15,14 +10,28 @@ import createStore, {
StoreApi,
} from './vanilla'

// For server-side rendering: https://github.com/pmndrs/zustand/pull/34
// Deno support: https://github.com/pmndrs/zustand/issues/347
const isSSR =
typeof window === 'undefined' ||
!window.navigator ||
/ServerSideRendering|^Deno\//.test(window.navigator.userAgent)

const useIsomorphicLayoutEffect = isSSR ? useEffect : useLayoutEffect
export function useStore<T extends State>(api: StoreApi<T>): T
export function useStore<T extends State, U>(
api: StoreApi<T>,
selector: StateSelector<T, U>,
equalityFn?: EqualityChecker<U>
): U
export function useStore<TState extends State, StateSlice>(
api: StoreApi<TState>,
selector: StateSelector<TState, StateSlice> = api.getState as any,
equalityFn?: EqualityChecker<StateSlice>
) {
const slice = useSyncExternalStoreWithSelector(
api.subscribe,
api.getState,
// TODO avoid `any` and add type only in react.ts
(api as any).getServerState || api.getState,
selector,
equalityFn
)
useDebugValue(slice)
return slice
}

export type UseBoundStore<
T extends State,
Expand Down Expand Up @@ -62,92 +71,12 @@ function create<
const api: CustomStoreApi =
typeof createState === 'function' ? createStore(createState) : createState

const useStore: any = <StateSlice>(
selector: StateSelector<TState, StateSlice> = api.getState as any,
equalityFn: EqualityChecker<StateSlice> = Object.is
) => {
const [, forceUpdate] = useReducer((c) => c + 1, 0) as [never, () => void]

const state = api.getState()
const stateRef = useRef(state)
const selectorRef = useRef(selector)
const equalityFnRef = useRef(equalityFn)
const erroredRef = useRef(false)

const currentSliceRef = useRef<StateSlice>()
if (currentSliceRef.current === undefined) {
currentSliceRef.current = selector(state)
}

let newStateSlice: StateSlice | undefined
let hasNewStateSlice = false

// The selector or equalityFn need to be called during the render phase if
// they change. We also want legitimate errors to be visible so we re-run
// them if they errored in the subscriber.
if (
stateRef.current !== state ||
selectorRef.current !== selector ||
equalityFnRef.current !== equalityFn ||
erroredRef.current
) {
// Using local variables to avoid mutations in the render phase.
newStateSlice = selector(state)
hasNewStateSlice = !equalityFn(
currentSliceRef.current as StateSlice,
newStateSlice
)
}

// Syncing changes in useEffect.
useIsomorphicLayoutEffect(() => {
if (hasNewStateSlice) {
currentSliceRef.current = newStateSlice as StateSlice
}
stateRef.current = state
selectorRef.current = selector
equalityFnRef.current = equalityFn
erroredRef.current = false
})

const stateBeforeSubscriptionRef = useRef(state)
useIsomorphicLayoutEffect(() => {
const listener = () => {
try {
const nextState = api.getState()
const nextStateSlice = selectorRef.current(nextState)
if (
!equalityFnRef.current(
currentSliceRef.current as StateSlice,
nextStateSlice
)
) {
stateRef.current = nextState
currentSliceRef.current = nextStateSlice
forceUpdate()
}
} catch (error) {
erroredRef.current = true
forceUpdate()
}
}
const unsubscribe = api.subscribe(listener)
if (api.getState() !== stateBeforeSubscriptionRef.current) {
listener() // state has changed before subscription
}
return unsubscribe
}, [])

const sliceToReturn = hasNewStateSlice
? (newStateSlice as StateSlice)
: currentSliceRef.current
useDebugValue(sliceToReturn)
return sliceToReturn
}
const useBoundStore: any = (selector?: any, equalityFn?: any) =>
useStore(api, selector, equalityFn)

Object.assign(useStore, api)
Object.assign(useBoundStore, api)

return useStore
return useBoundStore
}

export default create
Loading

0 comments on commit a34649d

Please sign in to comment.