Michael McGreal

Michael McGreal

My Favorite Zustand Patterns

Zustand

One of my favorite react state management solutions is Zustand.

React context requires boilerplate, and often causes many unnecessary re-renders.

Zustand allows you to create small state stores, and then access them anywhere in any component via hooks!

There is a fantastic article on Tkdodos blog that introduced me to some interesting redux style patterns, like modeling actions as Events, not Setters.

A flexible modal layer

Before React Portals, modal dialogs could often be obfuscated by parent elements with a strict stacking order, such as 'overflow: hidden'.

Wouldn't it be nice to invoke a modal from anywhere within an application, and have it appear at the top of the DOM? Let's model a simple zustand store.

A novice way to model the store:

1export enum ModalType = {
2 modalA,
3 modalB
4}
5 
6interface ModalStore {
7 modal: ModalType | null // close a modal by setting it to null
8 setModal: () => void
9}
10 
11export const useModalStore = create<ModalStore>((set) => ({
12 modal: ModalType.modalA,
13 setModal: (modal: ModalType | null) =>
14 set({ modal }),
15}))

As you introduce more modals, you simply add another ModalType enum.

However, as feature requirements grow, edge cases inevitably arise.

What if when you open modalB, you need to initialize it with some data that's only relevant to modalB?

Do you add another value to the store? If so, you'll need a setter too:

1interface ModalStore {
2 modal: ModalType | null
3 setModal: () => void
4 modalBData: string
5 setModalBData: () => void
6}
7 
8export const useModalStore = create<ModalStore>((set, get) => ({
9 modal: ModalType.modalA,
10 setModal: (modal: ModalType | null) => set({ modal }),
11 modalBData: 'I am modal B'
12 setModalBData: (modalBData: string) => set({ modalBData })
13}))

This single requirement is already bloating our store.

Another feature requirement appears:

You need a way to track if a modal has been seen, to avoid showing it again in the future.

Sure, we can add more state and setters to our store:

1interface ModalStore {
2 modal: ModalType | null
3 setModal: () => void
4 modalBData: string
5 setModalBData: () => void
6 seenModalA: boolean
7 setSeetModalA: boolean
8}
9 
10export const useModalStore = create<ModalStore>((set) => ({
11 modal: ModalType.modalA,
12 setModal: (modal: ModalType | null) => set({ modal }),
13 modalBData: 'I am modal B'
14 setModalBData: (modalBData: string) => set({ modalBData })
15 seenModalA: false
16 setSeenModalA: (seenModalA: boolean) => set({ seenModalA })
17}))

Then, in your caller component, you can subscribe to state.seenModalA, and use it as a condition in your button event handler:

1const Caller = () => {
2 const seenModalA = useModalStore((state) => state.seenModalA)
3 const { setShowModal } = useModalStore()
4 
5 return (
6 <button
7 onClick={() => {
8 if (!seenModalA) setShowModal(ModalType.modalA)
9 }}>
10 Show modal
11 </button>
12 )
13}

Besides the [state, setState] syntax becoming hideous (it works for local React.useState() but not in a zustand store), a major problem with this solution is that now your Caller component will unnecessarily re-render everytime state.seenModalA changes.

How can you solve this?

One solution is to move this logic out of the Caller component and into the store, because Zustand offers a get() function inside store setters.

1export const useModalStore = create<ModalStore>((set, get) => ({
2 modal: ModalType.modalA,
3 setModal: (modal: ModalType | null) => set({ modal }),
4 modalBData: 'I am modal B'
5 setModalBData: (modalBData: string) => set({ modalBData })
6 seenModalA: false
7 setSeenModalA: (seenModalA: boolean) => set({ seenModalA })
8 setSeenModalA: (seenModalA: boolean) => {
9 if (!get().seenModalA) set({ seenModalA })
10 }
11}))
12const Caller = () => {
13 const seenModalA = useModalStore((state) => state.seenModalA)
14 const { setShowModal } = useModalStore()
15 
16 return (
17 <button
18 onClick={() => {
19 if (!seenModalA) setShowModal(ModalType.modalA) // remove the condition
20 setShowModal(ModalType.modalA) // call it always, handle condition check in the store
21 }}>
22 Show modal
23 </button>
24 )
25}

However, while this reduced re-renders in your Caller component, your store is becoming bloated again! And too many conditions in a setter will lead to hard-to-track bugs.

Features requests continue:.

The modal should not close until after the modal exit animation completes.

An unexpected consequence of setting the modal to null is that it skips the modal's exit animation entirely.

How do you solve it? No one wants setTimeouts everywhere!

My recommended solution to all of the above

First split open and close setters, and move them into an actions namespace

1interface ModalStore {
2 modal: ModalType | null
3 actions: {
4 showModal: (modal: ModalType) => void // null type is not needed when showing a modal anymore
5 closeModal: () => void
6 }
7}
8 
9export const useModalStore = create<ModalStore>((set) => ({
10 modal: ModalType.modalA,
11 actions: {
12 showModal: (modal: ModalType) => set({ modal }),
13 closeModal: () => set({ modal: null }),
14 },
15}))

This has two benefits:

  1. Your caller components only need to subscribe to a single { actions } namespace, rather than destructuring multiple setters (particularly useful as your store grows). Setters do not cause re-renders, so it's okay to subscribe to the whole actions namespace
  2. By separating the open and close actions, instead of the typical React.useState() syntax of [state, setState] – you can now pass options specific to each setters unique needs!

Pass optional options parameter to each action

I originally fell in love with this pattern from React-query's useQuery interface, but now I see it everywhere.

1interface ModalStore {
2 modal: { type: ModalType | null; data: string }
3 actions: {
4 showModal: (
5 type: ModalType, // first parameter is type
6 options?: {
7 // second parameter is optional (and each option is optional too)
8 data?: string | undefined // edge cases solved!
9 }
10 ) => void
11 closeModal: (options?: { withDelay?: number }) => void // closeModal only needs options, since it always sets modal to null
12 }
13}
14 
15export const useModalStore = create<ModalStore>((set) => ({
16 modal: ModalType.modalA,
17 actions: {
18 showModal: (type: ModalType, options?: { data?: string | undefined }) =>
19 set({
20 modal: {
21 type: type,
22 data: options?.data ?? undefined,
23 },
24 }),
25 closeModal: (options?: { withDelay?: number }) => {
26 setTimeout(() => {
27 set({ modal: { type: null } })
28 }, options?.withDelay ?? 0) // always wrap in setTimeout, even if 0.
29 },
30 },
31}))

To make this pattern even more beautiful, you can offer an option of type () => void.

This pairs beautifully with the withDelay parameter of closeModal() to execute some code after the delay. i.e. a delayedCallback!

For example, to set the seen state of a modal:

1interface ModalStore {
2 modal: { type: ModalType | null; data: string }
3 actions: {
4 showModal: (
5 type: ModalType,
6 options?: {
7 data?: string | undefined
8 }
9 ) => void
10 closeModal: (options?: {
11 withDelay?: number
12 delayedCallback: () => void
13 }) => void
14 }
15}
16 
17export const useModalStore = create<ModalStore>((set) => ({
18 modal: ModalType.modalA,
19 actions: {
20 showModal: (type: ModalType, options?: { data?: string | undefined }) =>
21 set({
22 modal: {
23 type: type,
24 data: options?.data ?? undefined,
25 },
26 }),
27 closeModal: (options?: {
28 withDelay?: number
29 delayedCallback: () => void
30 }) => {
31 setTimeout(() => {
32 set({ modal: { type: null } })
33 options?.delayedCallback && options?.delayedCallback()
34 }, options?.withDelay ?? 0)
35 },
36 },
37}))
38 
39export const Caller = () => {
40 const { actions } = useModalStore()
41 
42 return (
43 <button
44 onClick={() => {
45 actions.closeModal({
46 withDelay: 500,
47 delayedCallback: () => actions.setSeen(ModalType.A), // calling an action inside another action 🤯 with a single actions import
48 })
49 }}>
50 Close modal and set seen
51 </button>
52 )
53}

Wow. Functions as first class citizens of javascript really is a remarkable feature.

Theses patterns of modeleing actions as events, not setters and a flexible options interface are delightful to use, and have solved nearly every edge case I've encountered.

With this pattern, you can implement advanced feature requests with ease.

Enjoy.