How to use useEffect() to fetch data from an api in a useReducer() and useContext() state management setup?

Viewed 753

reducer.js

export const reducer = (state, action) => {
  switch(action.type) {
    case SET_HEROES:
      return { ...state, heroes: action.heroes }
  }
}

AppContext.js

export const AppContext = React.createContext()

export const AppProvider = (props) => {
  const initialState = { 
    heroes: [] 
  }

  const [appState, dispatch] = useReducer(reducer, initialState)

  const setHeroes = async () => {
    const result = await getHeroes()
    dispatch({ type: SET_HEROES, heroes: result })
  }

  return <AppContext.Provider
    values={{ heroes: appState.heroes, setHeroes }}
  >
    {props.children}
  </AppContext.Provider>
}

HeroesScreen.js

const HeroesScreen = () => {
  const { heroes, setHeroes } = useContext(AppContext)

  useEffect(() => {
    setHeroes()
  }, [])

  return <>
  // iterate hero list
  </>
}

export default HeroesScreen

Above is plain simple setup of a component using reducer + context as state management. Heroes are showing on the screen, everything works fine but I'm having a warning Reach Hook useEffect has a missing dependency: 'setHeroes'. But if I add it in as a dependency, it'll crash my app with Maximum depth update exceeded

Been searching but all I see is putting the function fetch call inside the useEffect(). What I would like is to extract the function and put it in a separate file following the SRP principle

EDITED:

As advised on using useCallback()

AppContext.js

const setHeroes = useCallback(() => {
    getHeroes().then(result => dispatch({ type: SET_HEROES, heroes: result }))
  }, [dispatch, getHeroes])

HeroesScreen.js

useEffect(() => {
  setHeroes()
}, [setHeroes])

Adding the getHeroes as dependency on useCallback, linter shows unnecessary dependency

2 Answers

If you look at the code in AppProvider, you are creating a new setHeroes function every time it renders. So if you add setHeroes as a dependency to useEffect the code executes something like this:

  1. AppProvider renders, setHeroes is created, the state is initial state
  2. Somewhere down the component hierarchy HeroesScreen renders. useEffect is called which in turn calls setHeroes
  3. getHeroes is called and an action is dispatched
  4. reducer changes the state which causes AppProvider to re-render
  5. AppProvider renders, setHeroes is created from scratch
  6. The useEffect executes again since setHeroes changed and the whole loop repeats forever!

To fix the issue you indeed need to add setHeroes as a dependency to useEffect but then wrap it using useCallback:

const setHeroes = useCallback(async () => {
  const result = await getHeroes();
  dispatch({ type: SET_HEROES, heroes: result });
}, [getHeroes, dispatch]);

Good question, I also had this problem, this is my solution. I'm using Typescript but it'll work with JS only as well.

UserProvider.tsx

import * as React from 'react'
import { useReducer, useEffect } from 'react'
import UserContext from './UserContext'
import { getUser } from './actions/profile'
import userReducer, { SET_USER } from './UserReducer'

export default ({ children }: { children?: React.ReactNode }) => {
  const [state, dispatch] = useReducer(userReducer, {})

  const getUserData = async () => {
    const { user } = await getUser()
    dispatch({ type: SET_USER, payload: { ...user } })
  }

  useEffect(() => {
    getUserData()
  }, [])

  return (
    <UserContext.Provider value={{ user: state }}>
      {children}
    </UserContext.Provider>
  )
}

Then wrap your App with the provider

index.tsx

<UserProvider>
  <App />
</UserProvider>

Then to use the Context Consumer I do this

AnyComponent.tsx

<UserConsumer>
   {({ user }) => {
     ...
   }} 
</UserConsumer

or you can also use it like this

const { user } = useContext(UserContext)

Let me know if it works.

Related