React useEffect and React-Query useQuery issue?

Viewed 2384

I'm still new to React so forgive me if this is a silly approach to this problem.

My goal: Global error handling using a context provider and a custom hook.

The Problem: I can't remove errors without them immediately being re-added.

I display my errors via this component in the shell...

import React, { useState, useEffect } from 'react'
import Alert from '@mui/material/Alert'
import Collapse from '@mui/material/Collapse'
import { useAlertContext } from '@/context/alert-context/alert-context'

export default function AppAlert () {
  const [show, setShow] = useState(false)
  const alertContext = useAlertContext()

  const handleClose = () => {
    alertContext.remove()
    setShow(false)
  }

  useEffect(() => {
    if (alertContext.alert) {
      setShow(true)
    }
  }, [alertContext.alert])

  return (
    <Collapse in={show}>
      <Alert severity='error' onClose={handleClose}>
        {alertContext.alert}
      </Alert>
    </Collapse>
  )
}

I have a provider setup that also exposes a custom hook...

import React, { useState, createContext, useContext } from 'react'

const AlertContext = createContext()

const AlertProvider = ({ children }) => {
  const [alert, setAlert] = useState(null)

  const removeAlert = () => setAlert(null)

  const addAlert = (message) => setAlert(message)

  return (
    <AlertContext.Provider value={{
      alert,
      add: addAlert,
      remove: removeAlert
    }}
    >
      {children}
    </AlertContext.Provider>
  )
}

const useAlertContext = () => {
  return useContext(AlertContext)
}

export {
  AlertProvider as default,
  useAlertContext
}

And finally I have a hook setup to hit an API and call throw errors if it any occur while fetching the data. I'm purposely triggering a 404 by passing a bad API path.

import { useEffect } from 'react'
import { useQuery } from 'react-query'
import ApiV4 from '@/services/api/v4/base'
import { useAlertContext } from '@/context/alert-context/alert-context'

export const useAccess = () => {
  const alertContext = useAlertContext()
  const route = '/accessx'
  const query = useQuery(route, async () => await ApiV4.get(route), {
    retry: 0
  })

  useEffect(() => {
    if (query.isError) {
      alertContext.add(query.error.toString())
    }
  }, [alertContext, query.isError, query.error])

  return query
}

This code seems to be the issue. Because alertContext.remove() triggers useEffect here and query.error still exists, it immediately re-adds the error to the page on remove. Removing alertContext from the array works, but it is not a real fix and linter yells.

  useEffect(() => {
    if (query.isError) {
      alertContext.add(query.error.toString())
    }
  }, [alertContext, query.isError, query.error])
3 Answers

This is a perfectly fine approach to the problem. You've also accurately identified the problem. The solution is to create a second hook with access to the methods that will modify the context. AppAlert needs access to the data in the context, and needs to update when AlertContext.alert changes. UseAccess only needs to be able to call AlertContext.add, and that method wont change and trigger a re-render. This can be done with a second Context. You can just expose one Provider and bake the actions provider into the outer context provider.

import React, { useState, createContext, useContext } from 'react'

const AlertContext = createContext()
const AlertContextActions = createContext()

const AlertProvider = ({ children }) => {
  const [alert, setAlert] = useState(null)

  const removeAlert = () => setAlert(null)

  const addAlert = (message) => setAlert(message)

  return (
    <AlertContext.Provider value={{ alert }}>
      <AlertContextActions.Provider value={{ addAlert, removeAlert }}>
        {children}
      </AlertContextActions.Provider>
    </AlertContext.Provider>
  )
}

const useAlertContext = () => {
  return useContext(AlertContext)
}

export {
  AlertProvider as default,
  useAlertContext
}

Now, where you need access to the alert you use one hook and where you need access to the actions you use the other.

// in AppAlert

import { useAlertContext, useAlertContextActions } from '@/context/alert-context/alert-context'

...

const { alert } = useAlertContext()
const { removeAlert } = useAlertContextActions()

And finally

// in useAccess
import { useAlertContextActions } from '@/context/alert-context/alert-context'

...

const { addAlert } = useAlertContextActions()

So I found a solution that seems to work for my purposes. I got a hint from this article. https://mortenbarklund.com/blog/react-architecture-provider-pattern/

Note the use of useCallback above. It ensures minimal re-renders of components using this context, as the function is guaranteed to be stable (as its memoized without dependencies).

So with this I tried the following and it solved the problem.

import React, { useState, createContext, useContext, useCallback } from 'react'

const AlertContext = createContext()

const AlertProvider = ({ children }) => {
  const [alert, setAlert] = useState(null)

  const removeAlert = useCallback(() => setAlert(null), [])

  const addAlert = useCallback((message) => setAlert(message), [])

  return (
    <AlertContext.Provider value={{
      alert,
      add: addAlert,
      remove: removeAlert
    }}
    >
      {children}
    </AlertContext.Provider>
  )
}

const useAlertContext = () => {
  return useContext(AlertContext)
}

export {
  AlertProvider as default,
  useAlertContext
}

My goal: Global error handling

One problem with the above useEffect approach is that every invocation of useAccess will run their own effects. So if you have useAccess twice on the page, and it fails, you will get two alerts, so it's not really "global".

I would encourage you to look into the global callbacks on the QueryCache in react-query. They are made for this exact use-case: To globally handle errors. Note that to use context, you would need to create the queryClient inside the Application, and make it "stable" with either useRef or useState:

function App() {
  const alertContext = useAlertContext()
  const [queryClient] = React.useState(() => new QueryClient({
    queryCache: new QueryCache({
      onError: (error) =>
        alertContext.add(error.toString())
    }),
  }))

  return (
     <QueryClientProvider client={queryClient}>
       <RestOfMyApp />
     </QueryClientProvider>
  )
}

I also have some examples in my blog.

Related