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 { pipe } from '@stopcock/fp'
import * as Optic from '@stopcock/fp/optic'
const city = Optic.optic<State>().prop('user').prop('address').prop('city').value

The Zustand action can use the optic directly with data first:

setCity: (value) => set((state) => Optic.set(city, state, value))

Or use the same operation data-last in pipe:

setCity: (value) => set((state) => pipe(state, Optic.set(city, value)))
import { pipe } from '@stopcock/fp'
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) => pipe(state, Optic.set(city, value))),
setTheme: (value) => set((state) => pipe(state, Optic.set(theme, value))),
applyDiscount: (value) => set((state) => pipe(state, Optic.set(discount, value))),
toggleSidebar: () =>
set((state) =>
pipe(
state,
Optic.modify(sidebar, (open) => !open),
),
),
}))

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 = pipe(
items,
Optic.modify(outOfStock, (item) => ({ ...item, disabled: true })),
)

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) =>
pipe(
state,
Optic.modify(unavailableItems, (item) => ({ ...item, disabled: true })),
),
)

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 = pipe(items, Optic.preview(selected))
const renamed = pipe(
items,
Optic.modify(selected, (item) => ({
...item,
name: item.name.trim(),
})),
)

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.