Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(types)!: require complete state if setState's replace flag is set #2580

Merged
merged 10 commits into from
Jun 29, 2024
Merged
39 changes: 39 additions & 0 deletions docs/guides/migrating-to-v5.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ We highly recommend to update to the latest version of v4, before migrating to v
- Drop UMD/SystemJS support
- Organize entry points in the package.json
- Drop ES5 support
- Stricter types when setState's replace flag is set
- Other small improvements (technically breaking changes)

## Migration Guide
Expand Down Expand Up @@ -127,6 +128,44 @@ Alternatively, if you need v4 behavior, `createWithEqualityFn` will do.
import { createWithEqualityFn as create } from 'zustand/traditional'
```

### Stricter types when setState's replace flag is set (Typescript only)

```diff
- setState:
- (partial: T | Partial<T> | ((state: T) => T | Partial<T>), replace?: boolean | undefined) => void;
+ setState:
+ (partial: T | Partial<T> | ((state: T) => T | Partial<T>), replace?: false) => void;
+ (state: T | ((state: T) => T), replace: true) => void;
```

If you are not using the `replace` flag, no migration is required.

If you are using the `replace` flag and it's set to `true`, you must provide a complete state object.
This change ensures that `store.setState({}, true)` (which results in an invalid state) is no longer considered valid.

**Examples:**

```ts
// Partial state update (valid)
store.setState({ key: 'value' })

// Complete state replacement (valid)
store.setState({ key: 'value' }, true)

// Incomplete state replacement (invalid)
store.setState({}, true) // Error
```

#### Handling Dynamic `replace` Flag

If the value of the `replace` flag is dynamic and determined at runtime, you might face issues. To handle this, you can use a workaround by annotating the `replace` parameter with `as any`:

```ts
const replaceFlag = Math.random() > 0.5
store.setState(partialOrFull, replaceFlag as any)
```

## Links

- https://github.com/pmndrs/zustand/pull/2138
- https://github.com/pmndrs/zustand/pull/2580
30 changes: 30 additions & 0 deletions docs/guides/typescript.md
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,36 @@ For a usual statically typed language, this is impossible. But thanks to TypeScr

If you are eager to know what the answer is to this particular problem then you can [see it here](#middleware-that-changes-the-store-type).

### Handling Dynamic `replace` Flag

If the value of the `replace` flag is not known at compile time and is determined dynamically, you might face issues. To handle this, you can use a workaround by annotating the `replace` parameter with `as any`:

```ts
const replaceFlag = Math.random() > 0.5
store.setState(partialOrFull, replaceFlag as any)
```

#### Example with `as any` Workaround

```ts
import { create } from 'zustand'

interface BearState {
bears: number
increase: (by: number) => void
}

const useBearStore = create<BearState>()((set) => ({
bears: 0,
increase: (by) => set((state) => ({ bears: state.bears + by })),
}))

const replaceFlag = Math.random() > 0.5
useBearStore.setState({ bears: 5 }, replaceFlag as any) // Using the workaround
```

By following this approach, you can ensure that your code handles dynamic `replace` flags without encountering type issues.

## Common recipes

### Middleware that doesn't change the store type
Expand Down
25 changes: 17 additions & 8 deletions src/middleware/devtools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,13 +49,22 @@ type TakeTwo<T> = T extends { length: 0 }

type WithDevtools<S> = Write<S, StoreDevtools<S>>

type Action =
| string
| {
type: string
[x: string | number | symbol]: unknown
}
type StoreDevtools<S> = S extends {
setState: (...a: infer Sa) => infer Sr
setState: {
// capture both overloads of setState
(...a: infer Sa1): infer Sr1
(...a: infer Sa2): infer Sr2
}
}
? {
setState<A extends string | { type: string }>(
...a: [...a: TakeTwo<Sa>, action?: A]
): Sr
setState(...a: [...a: TakeTwo<Sa1>, action?: Action]): Sr1
setState(...a: [...a: TakeTwo<Sa2>, action?: Action]): Sr2
}
: never

Expand Down Expand Up @@ -165,8 +174,8 @@ const devtoolsImpl: DevtoolsImpl =
extractConnectionInformation(store, extensionConnector, options)

let isRecording = true
;(api.setState as NamedSet<S>) = (state, replace, nameOrAction) => {
const r = set(state, replace)
;(api.setState as any) = ((state, replace, nameOrAction: Action) => {
const r = set(state, replace as any)
if (!isRecording) return r
const action: { type: string } =
nameOrAction === undefined
Expand All @@ -189,12 +198,12 @@ const devtoolsImpl: DevtoolsImpl =
},
)
return r
}
}) as NamedSet<S>

const setStateFromDevtools: StoreApi<S>['setState'] = (...a) => {
const originalIsRecording = isRecording
isRecording = false
set(...a)
set(...(a as Parameters<typeof set>))
isRecording = originalIsRecording
}

Expand Down
18 changes: 13 additions & 5 deletions src/middleware/immer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,21 @@ type StoreImmer<S> = S extends {
getState: () => infer T
setState: infer SetState
}
? SetState extends (...a: infer A) => infer Sr
? SetState extends {
(...a: infer A1): infer Sr1
(...a: infer A2): infer Sr2
}
? {
setState(
nextStateOrUpdater: T | Partial<T> | ((state: Draft<T>) => void),
shouldReplace?: boolean | undefined,
...a: SkipTwo<A>
): Sr
shouldReplace?: false,
...a: SkipTwo<A1>
): Sr1
setState(
nextStateOrUpdater: T | ((state: Draft<T>) => void),
shouldReplace: true,
...a: SkipTwo<A2>
): Sr2
}
: never
: never
Expand All @@ -61,7 +69,7 @@ const immerImpl: ImmerImpl = (initializer) => (set, get, store) => {
typeof updater === 'function' ? produce(updater as any) : updater
) as ((s: T) => T) | T | Partial<T>

return set(nextState as any, replace, ...a)
return set(nextState, replace as any, ...a)
}

return initializer(store.setState, get, store)
Expand Down
6 changes: 3 additions & 3 deletions src/middleware/persist.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,7 @@ const persistImpl: PersistImpl = (config, baseOptions) => (set, get, api) => {
console.warn(
`[zustand persist middleware] Unable to update item '${options.name}', the given storage is currently unavailable.`,
)
set(...args)
set(...(args as Parameters<typeof set>))
},
get,
api,
Expand All @@ -214,13 +214,13 @@ const persistImpl: PersistImpl = (config, baseOptions) => (set, get, api) => {
const savedSetState = api.setState

api.setState = (state, replace) => {
savedSetState(state, replace)
savedSetState(state, replace as any)
void setItem()
}

const configResult = config(
(...args) => {
set(...args)
set(...(args as Parameters<typeof set>))
void setItem()
},
get,
Expand Down
3 changes: 2 additions & 1 deletion src/vanilla.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
type SetStateInternal<T> = {
_(
partial: T | Partial<T> | { _(state: T): T | Partial<T> }['_'],
replace?: boolean | undefined,
replace?: false,
Copy link
Member

Choose a reason for hiding this comment

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

I see. I too thought | undefined is required somehow, but this is functions argument, not object property.

): void
_(state: T | { _(state: T): T }['_'], replace: true): void
}['_']

export interface StoreApi<T> {
Expand Down
Loading