Snackbar and RTK Query update cause 'Warning: Cannot update during an existing state transition'

46 Views Asked by At

I have a simple ToDos app that uses RTK Query to query a NodeJS backend, and update and cache state. It's working fine, interacting with the backend and updating state as expected. I decided to add snackbar notifications to display messages coming from the backend during queries and mutations, but I'm getting a warning in the console when the snackbar renders

todosDelete.tsx:14 Warning: Cannot update during an existing state transition (such as within `render`). Render methods should be a pure function of props and state.

I found that this is because the snackbar is updating state and rendering at the same time as RTK Query is updating the cache and rerendering my ToDos page -- in the case of deleting a Todo for example.

I'm using the code below (todosDelete.tsx) to render a link, handle the delete action and render a snackbar.

import {
    isErrorWithMessage,
    isFetchBaseQueryError
} from '../../helpers/errorTypeHelper'
import { ToDo, useDeleteTodoMutation } from './todoApiSlice'
import { useSnackbar } from 'notistack'

const TodosDelete = (item: ToDo): JSX.Element => {
    const [deleteTodo, { isSuccess, isError, error, data}] =
        useDeleteTodoMutation()
        const { enqueueSnackbar } = useSnackbar();
    // handle success and error messages
    if (isSuccess) {
        enqueueSnackbar(data.message, { variant: 'info' })
    }
    if (isError) {
        if (isFetchBaseQueryError(error)) {
            // you can access all properties of `FetchBaseQueryError` here
            const errMsg =
                'error' in error ? error.error : JSON.stringify(error.data)
            enqueueSnackbar(errMsg, { variant: 'error' })
        } else if (isErrorWithMessage(error)) {
            // you can access a string 'message' property here
            enqueueSnackbar(error.message, { variant: 'error' })
        }
    }
    return <div onClick={() => deleteTodo(item)}>Delete</div>
}

export default TodosDelete

Here's my slice for my ToDos:

import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
import { Dayjs } from 'dayjs'

export type ToDo = {
    _id?: string | undefined // making id optional because it isn't passed in the form, but added in the action handler
    title: string
    body: string
    email: string
    date: Dayjs | string
}

interface ToDoState {
    list: ToDo[]
}

const initialState: ToDoState = {
    list: []
}

const todoApiSlice = createApi({
    reducerPath: 'todoApi',
    baseQuery: fetchBaseQuery({
        // baseUrl: '/backend'
        baseUrl: 'http://localhost:3001' // for running outside of docker
        // prepareHeaders (headers) {
        //   // headers.set('x-api-key', apiKey) // need to send the auth token here
        //   return headers
        // }
    }),
    tagTypes: ['Todos'], // set the tags for the cache
    endpoints(builder) {
        return {
            fetchTodos: builder.query({
                query: () => '/todos',
                providesTags: ['Todos'] // provide tags and revalidate the cache
            }),
            fetchSingleTodo: builder.query({
                query: (id: string) => ({
                    url: `/todos/${id}`,
                    method: 'GET'
                })
            }),
            createTodo: builder.mutation({
                query: (todo: ToDo) => ({
                    url: '/todos/',
                    method: 'POST',
                    body: todo
                }),
                invalidatesTags: ['Todos'] // invalidate the cache - reload new data after creating
            }),
            updateTodo: builder.mutation({
                query: (todo: ToDo) => ({
                    url: '/todos/',
                    method: 'PUT',
                    body: todo
                }),
                invalidatesTags: ['Todos'] // invalidate the cache - reload new data after updating
            }),
            deleteTodo: builder.mutation({
                query: (todo: ToDo) => ({
                    url: '/todos/',
                    method: 'DELETE',
                    body: todo
                }),
                invalidatesTags: ['Todos'] // invalidate the cache - reload new data after deleting
            })
        }
    }
})

export default todoApiSlice
export const {
    useFetchTodosQuery,
    useFetchSingleTodoQuery,
    useCreateTodoMutation,
    useUpdateTodoMutation,
    useDeleteTodoMutation
} = todoApiSlice

The providesTags parameter on the fetchTodos builder query, in conjunction with the invalidatesTags parameters on the mutations, updates the cache and forces a rerender of the ToDos page. The warning occurs when the snackbar is updating state and rendering at the same moment, causing the warning above. If I comment out the providesTags/invalidatesTags parameters, the ToDos page doesn't update, as I would expect, and the snackbar renders without the warning. My question is how to go about dealing with these parallel state changes to avoid the warning.

1

There are 1 best solutions below

1
Drew Reese On

You are enqueueing updates as unintentional side-effects during a component render. Keep in mind that the entire function body of a React Function component is the "render method".

Move these into a useEffect hook so they are issued as intentional side-effects.

Example:

const TodosDelete = (item: ToDo): JSX.Element => {
  const [
    deleteTodo,
    { isSuccess, isError, error, data }
  ] = useDeleteTodoMutation()
  const { enqueueSnackbar } = useSnackbar();

  // handle success and error messages
  React.useEffect(() => {
    if (isSuccess) {
      enqueueSnackbar(data.message, { variant: 'info' })
    }

    if (isError) {
      if (isFetchBaseQueryError(error)) {
        // you can access all properties of `FetchBaseQueryError` here
        const errMsg =
          'error' in error ? error.error : JSON.stringify(error.data)
        enqueueSnackbar(errMsg, { variant: 'error' })
      } else if (isErrorWithMessage(error)) {
        // you can access a string 'message' property here
        enqueueSnackbar(error.message, { variant: 'error' })
      }
    }
  }, [data, error, isError, isSuccess]);

  return <div onClick={() => deleteTodo(item)}>Delete</div>
};