In Zustand, what does set() do with the object you pass it, and when would you pass true as its second argument?
answer
- merge, but how deep
- Object.assign onto a copy
- nested objects are not merged
- second argument skips the merge
- same reference means no notify
basics
~20 sset shallow-merges the object into a copy of the current state: omitted top-level keys survive, but nested objects you pass replace the old ones wholesale. Passing true as the second argument replaces the state outright, used for resets or removing keys.
solid answer
~40 sZustand's `set` (and `store.setState`) takes a partial object or an updater `(state) => partial`. It builds the next state with `Object.assign({}, state, partial)`: a **one-level merge**, so untouched top-level keys stay, but a nested object such as `shipping` is replaced as a whole, which is why you spread it yourself: `set((s) => ({ shipping: { ...s.shipping, method } }))`. The second argument, `replace`, skips the merge and installs the value as the entire state; in v5's TypeScript types `replace: true` demands a complete state, so it suits a reset (`set(store.getInitialState(), true)`) or dropping a key. If an updater returns the exact current state object, Zustand sees `Object.is` equality and notifies nobody; any other call notifies every listener synchronously.
code
ts · 18 linesimport { create } from 'zustand'
type CheckoutState = {
coupon: string | null
shipping: { method: 'standard' | 'express'; address: string | null }
setMethod: (m: 'standard' | 'express') => void
reset: () => void
}
export const useCheckoutStore = create<CheckoutState>()((set, _get, store) => ({
coupon: null,
shipping: { method: 'standard', address: null },
// spread the nested level: set merges only the top level
setMethod: (method) =>
set((s) => ({ shipping: { ...s.shipping, method } })),
// replace with the initial state, which includes the actions
reset: () => set(store.getInitialState(), true),
}))go deeper
Remember that set merges only the top level: spread any nested object you change, and reach for the updater form when the new value depends on the old one.
Walk through the implementation: updater call, the Object.is bail-out, Object.assign onto a copy unless replace, then synchronous listener calls with state and previous state.
Point out the traps that reach production: resets that keep stray keys, replace calls that delete actions, and nested writes that silently drop sibling fields.
Decide as a team whether nested state should be flattened, spread by hand, or handled with the immer middleware, and make that a review rule rather than individual taste.
## The signature In a Zustand store without middleware, the function handed to the store creator as `set` is the same function exposed as `store.setState` (middleware such as `persist` or `devtools` wraps them). Its first argument is either: - a **partial state** object, such as `{ coupon: 'SPRING' }`, or - an **updater function** that receives the current state and returns a partial (or complete) state. Its optional second argument is `replace`. In Zustand 5 the TypeScript overloads are stricter than in v4: `replace` may be omitted or `false` with a partial state, and when it is `true` the value must be a complete state object. `store.setState({}, true)`, which would wipe the state, no longer type-checks. ## What happens inside, step by step The vanilla store implementation is short enough to describe exactly: 1. If the argument is a function, call it with the current state to get `nextState`; otherwise `nextState` is the argument itself. 2. If `Object.is(nextState, state)` is true, stop: nothing is written and no listener runs. 3. Otherwise remember the previous state, then build the new one. If `replace` is true — or `replace` is omitted and `nextState` is not an object, or is `null` — the new state is `nextState` as it stands. Otherwise it is `Object.assign({}, state, nextState)`. 4. Call every listener synchronously with `(state, previousState)`. Two consequences follow from step 2. First, the bail-out only fires when an updater returns the **same object** it received, for example `set((s) => (s.coupon === code ? s : { coupon: code }))` when the coupon is already applied. Second, `set({ count: 3 })` when `count` is already 3 still creates a new state object and notifies every listener; the components that do not re-render are spared by their selectors, which return an equal value, not by `set`. ## Why the merge is only one level deep `Object.assign` copies own enumerable properties from the partial onto a new object. It does not look inside nested objects. With a state shaped like this: ```ts type CheckoutState = { items: string[] shipping: { method: 'standard' | 'express'; address: string | null } } ``` the call `set({ shipping: { method: 'express' } })` keeps `items` (a top-level key you omitted) but **drops `shipping.address`**, because the whole `shipping` object is replaced. The fix is to spread the nested level yourself: ```ts set((s) => ({ shipping: { ...s.shipping, method: 'express' } })) ``` For deeply nested state that becomes noisy, which is the case the `immer` middleware from `zustand/middleware/immer` addresses: with it, `set` accepts a draft-mutating recipe such as `set((s) => { s.shipping.method = 'express' })` and produces the immutable update for you. ## When to pass `true` | Situation | Call | Why replace | |---|---|---| | Reset the store to its first state | `set(store.getInitialState(), true)` | guarantees no key added later survives | | Remove a top-level key | `set(({ [id]: _, ...rest }) => rest, true)` | a merge can never delete a key | | State is a union of shapes | `set({ status: 'idle' }, true)` | fields from the previous variant must not linger | | State is not an object | `set(0)` | non-object values already replace; `true` is implicit | A reset written as `set(initialState)` without `true` looks equivalent but is not: any key that was added after creation, for example a cached lookup keyed by id, stays in the merged result. Be careful that `replace: true` also replaces **actions**, because in the usual pattern they live in the same object. Replacing with a data-only object deletes `addItem` and friends. `getInitialState()` includes the actions, which is why the docs reset with it; a hand-written reset object must carry them too. ## Practical rules - Use the **updater form** whenever the new value depends on the old one; `get()` inside an action is fine for reads, but an updater keeps the read and write together. - Treat nested objects as immutable: spread each level you change, or use the `immer` middleware. - Do not use `set` to signal an event with no data change; listeners fire, but selectors return equal values and nothing renders, which is easy to misread as a bug. - Keep `replace: true` for the three cases above and read its type error in v5 as a hint that you forgot a field.
- In Zustand, a component selects s.shipping.method; after set({ coupon: 'X' }), does it re-render?No. `set` produces a new top-level state object, but `shipping` keeps its old reference and `method` its old value, so the selector returns an equal string and the `Object.is` check keeps the component quiet. Only components selecting `coupon`, or the whole state, re-render.
- Why can't a Zustand merge-style set remove a key from the state, and what do you do instead?`Object.assign` only copies keys from the partial onto a copy of the old state; a key missing from the partial is kept, not deleted. Setting it to `undefined` leaves the key present. To remove it, build the complete next state without that key and call `set(next, true)`, remembering that replace also drops actions unless you carry them.
saying these in an interview costs you the question
- Zustand's set deep-merges nested objects, so partial nested updates are safe.
- Calling set with an unchanged value never notifies any listener.
- Passing true as the second argument makes set merge more deeply.
- A reset written as set(initialState) is identical to set(initialState, true).
- Replacing state with a data-only object keeps the actions defined in the creator.