Skip to content

Zustand without the spread

Nested immutable updates quickly become a wall of spread operators:

setCity: (city) =>
set((state) => ({
...state,
user: {
...state.user,
address: {
...state.user.address,
city,
},
},
}))

An optic packages the read and immutable-update path as a reusable value.

import * as Optic from '@stopcock/fp/optic'
const city = Optic.optic<State>()
.prop('user')
.prop('address')
.prop('city')
.value

The Zustand action becomes:

setCity: (value) =>
set((state) => Optic.set(city, value)(state))
import { create } from 'zustand'
import * as Optic from '@stopcock/fp/optic'
const city = Optic.optic<State>().prop('user').prop('address').prop('city').value
const theme = Optic.optic<State>().prop('ui').prop('theme').value
const sidebar = Optic.optic<State>().prop('ui').prop('sidebarOpen').value
const discount = Optic.optic<State>().prop('cart').prop('discount').value
const useStore = create<State & Actions>((set) => ({
user: {
name: 'Alice',
address: {
street: '123 Main St',
city: 'Portland',
postcode: '97201',
},
},
cart: { items: [], discount: null },
ui: { sidebarOpen: false, theme: 'light' },
setCity: (value) => set((state) => Optic.set(city, value)(state)),
setTheme: (value) => set((state) => Optic.set(theme, value)(state)),
applyDiscount: (value) => set((state) => Optic.set(discount, value)(state)),
toggleSidebar: () =>
set((state) => Optic.modify(sidebar, (open) => !open)(state)),
}))

The optic is defined once, inferred from State, and independently testable.

Traversals focus on zero or more values:

const outOfStock = Optic.filtered<CartItem>((item) => item.stock === 0)
const updatedItems = Optic.modify(
outOfStock,
(item) => ({ ...item, disabled: true }),
)(items)

Compose the traversal with a lens to update items directly inside state:

const cartItems = Optic.optic<State>().prop('cart').prop('items').value
const unavailableItems = Optic.compose(cartItems, outOfStock)
const markOutOfStock = () =>
set((state) =>
Optic.modify(
unavailableItems,
(item) => ({ ...item, disabled: true }),
)(state),
)

index and find produce Optional optics. Reads return Option; writes leave the source unchanged when no focus exists.

const selected = Optic.find<CartItem>((item) => item.id === selectedId)
const current = Optic.preview(selected)(items)
const renamed = Optic.modify(selected, (item) => ({
...item,
name: item.name.trim(),
}))(items)

Flat state rarely needs them. Optics pay off when paths are nested, shared by several actions, partial, or multi-focus. The 2.0 module also includes prisms, isomorphisms, getters, folds, setters, keyed At optics, composition, and lens law checks.