Search code examples
reactjstypescriptzustand

Uncaught Error: Maximum update depth exceeded while using Zustand in React with Typescript


I was learning Zustand from the following youtube tutorial: https://www.youtube.com/watch?v=m41aGndJNPU. The guy has taught it in Javascript but I was doing it in Typescript by reading the Typescript specific docs for Zustand along with it.

The app is a simple course list, where you can add, remove and toggle the status of courses. In order to display the courses in a list form I grabbed the courses, removeCourse and ToggleCourse actions from the store:

courseStore.ts:

import { create } from 'zustand';

import { devtools, persist } from 'zustand/middleware'

type Course = {
  id: Number, 
  title: String,
  completed: boolean,
}

interface StoreState {
  courses: Course[],
  addCourse: (course: Course) => void, // type of input it takes and type of return
  removeCourse: (courseId: Number) => void,
  toggleCourse: (courseId: Number) => void,
}
const useCourseStore = create<StoreState>()(
  devtools(
    persist(
      (set) => ({
        courses: [], //initial state
        // actions
        addCourse: (course) => {
          set((state) => ({
            courses: [course, ...state.courses] // corrected line
          }))
        },
        removeCourse: (courseId) => {
          set((state) => ({
            courses: state.courses.filter((course) => course.id !== courseId) // corrected line
          }))
        }, 
        toggleCourse: (courseId) => {
          set((state) => ({
            courses: state.courses.map((course) => course.id === courseId
              ? { ...course, completed: !course.completed }
              : course
            )
          }))
        }
      }),
      { name: 'courseStore' },
    ),
  ),
)

export default useCourseStore;

courseList.tsx:

import React from 'react'
import useCourseStore from '../app/courseStore'

const CourseList = () => {
  // const {courses, removeCourse, toggleCourse} = useCourseStore(
  //     (state) => ({
  //         courses: state.courses,
  //         removeCourse: state.removeCourse,
  //         toggleCourse: state.toggleCourse, 
  //     })
  // )
  const courses = useCourseStore(state => state.courses)
  const removeCourse = useCourseStore((state) => state.removeCourse)
  const toggleCourse = useCourseStore((state) => state.toggleCourse)  
    
  return (
    <>
      <ul>
        {courses.map((course, i) => {
          return (
            <React.Fragment key={i}>
              <li
                className={`course-item`}
                style={{
                  backgroundColor: course.completed ? "#00FF0044" : "white"
                }}
              >
                <span className="course-item-col-1">
                  <input 
                    checked={course.completed}
                    type="checkbox"
                    onChange={() => {
                      toggleCourse(course.id)
                    }}
                  />
                </span>
                <span>{course?.title}</span>
                <button 
                  onClick={() => {
                    removeCourse(course.id)
                  }}
                  className="delete-btn"
                >
                  Delete
                </button>
              </li>
            </React.Fragment>
          )
        })}
      </ul>
    </>
  )
}

export default CourseList

In CourseList when I used the commented out code to get the actions from the store, I got this error:

error in console:

But when I use the code below the commented out code, it works just fine.

Can anyone explain why this happens?

I thought the error was due to not using useEffect while getting the actions, but it seems without it, it works using the current implementation.


Solution

  • The root problem is, that you are always returning a new object from your selector ((state) => ({ ... })), which breaks reference equality every time.

    So you have two solutions to fix this, either you split your selectors, as such:

    const courses = useCourseStore((state) => state.courses)
    const removeCourse = useCourseStore((state) => state.removeCourse)
    const toggleCourse = useCourseStore((state) => state.toggleCourse) 
    

    Or alternatively, you use the shallow option so that multiple selected fields in a single object don’t trigger re-renders unless the contents really change.

    E.g. as such:

    import shallow from 'zustand/shallow'
    
    const { courses, removeCourse, toggleCourse } = useCourseStore(
      (state) => ({
        courses: state.courses,
        removeCourse: state.removeCourse,
        toggleCourse: state.toggleCourse,
      }),
      shallow
    )