Michael McGreal

Michael McGreal

Typescript Partials in Zustand

Storing objects in Zustand state

Often storing the state of an object in zustand is useful. For example, here is a filters object:

1interface FilterObject {
2 price: {
3 min: number
4 max: number
5 }
6 layout: {
7 beds: number
8 baths: number
9 }
10}
11 
12interface FilterStore {
13 filters: FilterObject
14 setFilters: (filter: FilterObject) => void
15}
16export const useFilterStore = create<FilterStore>((set, get) => ({
17 filters: InitialValues,
18 setFilters: (filters: FilterObject) => {
19 set({ filters })
20 },
21}))

This works, but has some ergnomic and performance issues when setting filters.

The problem lies in setFilters requiring an entire FilterObject value.

What if the component is a price filter, and wants to set the price values onlynot layout?

With this structure, the price filter would need to subscribe to the entire filter object value, so it can spread over the layout values when it sets the price values. Like this:

1const PriceFilter = () => {
2 const filters = useFilterStore(state => state.filters)
3 const { setFilters } = useFilterStore()
4 
5 return <button onClick={() =>
6 setFilters({
7 price: {
8 min: 300,
9 max: 600
10 },
11 layout: {
12 beds: filters.beds,
13 baths: filters.baths
14 }
15 }))
16 }>Set price filter</button>
17}

Of course, the ergonomics of this can be improved by spreading over the filters object to set non-price values (i.e. ...filters).

But the major performance problem remains: this PriceFilter component will re-render anytime the filters object state changes.

And for state such as filters, that can be frequently!

How to improve this?

Typescript Partials!

Can we refactor the setFilters interface so that the calling component only has to set the nested value it manages, such as price, and let the store handle the rest?

Without Typescript, this can become a mess of Object.entries() to get keys.

Luckily, Typescript as the perfect solution – Partial<T>

Pair this with Zustand's, get() function, and we have a beautiful pattern.

It looks like this:

1interface FilterStore {
2 filters: FilterObject
3 setFilters: (filter: Partial<FilterObject>) => void
4}
5export const useFilterStore = create<FilterStore>((set, get) => ({
6 filters: InitialValues,
7 setFilters: (filters: Partial<FilterObject>) => {
8 set({ filters: { ...get().filters, ...filters } })
9 },
10}))

This says: setFilters will now accept an argument that contains values for any combination of keys from the FilterObject interface! We could set only price, only layout, both, or any other combination of keys as the FiltersObject grows.

Then, in the Zustand store, we set the filters state to be a new object that is a combination of: 1) the current filters object, which is retrieved via zustand's get().filter, and then ...spread over to easily extract all of its key values pairs (especially useful for large objects with many keys); 2) the new partial filters value passed from the caller!

It makes the interface look slightly more intimidating, but it is now extremely flexible!

Over time, we can easily add new keys to the FilterObject interface without ever updating the FilterStore!

And most importantly, caller components do not need to subscribe to unnecessary re-renders – they simply set the values they manage, and let zustand set the rest!

1const PriceFilter = () => {
2 const filters = useFilterStore(state => state.filters)
3 const { setFilters } = useFilterStore()
4 
5 return <button onClick={() =>
6 setFilters({
7 price: {
8 min: 300,
9 max: 600
10 },
11 layout: {
12 beds: filters.beds,
13 baths: filters.baths
14 }
15 }))
16 }>Set price filter</button>
17}

Thank you Typescript for Partial<T>, Zustand for get(), and ES6 for the the spread operator.