Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Using reducer state inside useEffect

Hello All 👋🏻 I have a question about our favorite Hooks API!

What am I trying to do?

I am trying to fetch photos from some remote system. I store the blob urls for these photos in my reducer state keyed by an id.

I have a helper function wrapped in the memoized version returned by the useCallback hook. This function is called in the useEffect I have defined.

The Problem ⚠️

My callback a.k.a the helper function depends on part of the reducer state. Which is updated every time a photo is fetched. This causes the component to run the effect in useEffect again and thus causing an infinite loop.

component renders --> useEffect runs ---> `fetchPhotos` runs --> after 1st photo, reducer state is updated --> component updates because `useSelector`'s value changes ---> runs `fetchPhotos` again ---> infinite
const FormViewerContainer = (props) => {
  const { completedForm, classes } = props;

  const [error, setError] = useState(null);

  const dispatch = useDispatch();
  const photosState = useSelector(state => state.root.photos);

  // helper function which fetches photos and updates the reducer state by dispatching actions
  const fetchFormPhotos = React.useCallback(async () => {
    try {
      if (!completedForm) return;
      const { photos: reducerPhotos, loadingPhotoIds } = photosState;
      const { photos: completedFormPhotos } = completedForm;
      const photoIds = Object.keys(completedFormPhotos || {});
      
      // only fetch photos which aren't in reducer state yet
      const photoIdsToFetch = photoIds.filter((pId) => {
        const photo = reducerPhotos[pId] || {};
        return !loadingPhotoIds.includes(pId) && !photo.blobUrl;
      });

      dispatch({
        type: SET_LOADING_PHOTO_IDS,
        payload: { photoIds: photoIdsToFetch } });

      if (photoIdsToFetch.length <= 0) {
        return;
      }

      photoIdsToFetch.forEach(async (photoId) => {
        if (loadingPhotoIds.includes(photoIds)) return;

        dispatch(fetchCompletedFormPhoto({ photoId }));
        const thumbnailSize = {
          width: 300,
          height: 300,
        };

        const response = await fetchCompletedFormImages(
          cformid,
          fileId,
          thumbnailSize,
        )

        if (response.status !== 200) {
          dispatch(fetchCompletedFormPhotoRollback({ photoId }));
          return;
        }
    
        const blob = await response.blob();
        const blobUrl = URL.createObjectURL(blob);

        dispatch(fetchCompletedFormPhotoSuccess({
          photoId,
          blobUrl,
        }));
      });
    } catch (err) {
      setError('Error fetching photos. Please try again.');
    }
  }, [completedForm, dispatch, photosState]);

  // call the fetch form photos function
  useEffect(() => {
    fetchFormPhotos();
  }, [fetchFormPhotos]);

  ...
  ...
}

What have I tried?

I found an alternative way to fetch photos a.k.a by dispatching an action and using a worker saga to do all the fetching. This removes all the need for the helper in the component and thus no useCallback and thus no re-renders. The useEffect then only depends on the dispatch which is fine.

Question ?

I am struggling with the mental modal of using the hooks API. I see the obvious problem, but I am not sure how could this be done without using redux middlewares like thunks and sagas.

Edit:

reducer function:

export const initialState = {
  photos: {},
  loadingPhotoIds: [],
};

export default function photosReducer(state = initialState, action) {
  const { type, payload } = action;
  switch (type) {
    case FETCH_COMPLETED_FORM_PHOTO: {
      return {
        ...state,
        photos: {
          ...state.photos,
          [payload.photoId]: {
            blobUrl: null,
            error: false,
          },
        },
      };
    }
    case FETCH_COMPLETED_FORM_PHOTO_SUCCESS: {
      return {
        ...state,
        photos: {
          ...state.photos,
          [payload.photoId]: {
            blobUrl: payload.blobUrl,
            error: false,
          },
        },
        loadingPhotoIds: state.loadingPhotoIds.filter(
          photoId => photoId !== payload.photoId,
        ),
      };
    }
    case FETCH_COMPLETED_FORM_PHOTO_ROLLBACK: {
      return {
        ...state,
        photos: {
          ...state.photos,
          [payload.photoId]: {
            blobUrl: null,
            error: true,
          },
        },
        loadingPhotoIds: state.loadingPhotoIds.filter(
          photoId => photoId !== payload.photoId,
        ),
      };
    }
    case SET_LOADING_PHOTO_IDS: {
      return {
        ...state,
        loadingPhotoIds: payload.photoIds || [],
      };
    }
    default:
      return state;
  }
}
like image 515
Abhishek Ghosh Avatar asked Oct 08 '19 22:10

Abhishek Ghosh


People also ask

Can I use state inside useEffect?

It's ok to use setState in useEffect you just need to have attention as described already to not create a loop. The reason why this happen in this example it's because both useEffects run in the same react cycle when you change both prop.

Can we use useEffect in reducer?

The useEffectReducer hook takes the same first 2 arguments as the built-in useReducer hook, and returns the current state returned from the effect reducer, as well as a dispatch function for sending events to the reducer.

Can useEffect change state?

Run useEffect on State Change By default, useEffect runs after every render, but it's also perfect for running some code in response to a state change. You can limit when the effect runs by passing the second argument to useEffect.

Can we use useRef in useEffect?

In the useEffect , we are updating the useRef current value each time the inputValue is updated by entering text into the input field.


1 Answers

You could include the photoIdsToFetch calculation logic into your selector function, to reduce the number of renders caused by state change.

const photoIdsToFetch = useSelector(state => {
    const { photos: reducerPhotos, loadingPhotoIds } = state.root.photos;
    const { photos: completedFormPhotos } = completedForm;
    const photoIds = Object.keys(completedFormPhotos || {});
    const photoIdsToFetch = photoIds.filter(pId => {
      const photo = reducerPhotos[pId] || {};
      return !loadingPhotoIds.includes(pId) && !photo.blobUrl;
    });
    return photoIdsToFetch;
  },
  equals
);

However the selector function isn't memoized, it returns a new array object every time, thus object equality will not work here. You will need to provide an isEqual method as a second parameter (that will compare two arrays for value equality) so that the selector will return the same object when the ids are the same. You could write your own or deep-equals library for example:

import equal from 'deep-equal';

fetchFormPhotos will depend only on [photoIdsToFetch, dispatch] this way.

I'm not sure about how your reducer functions mutate the state, so this may require some fine tuning. The idea is: select only the state from store that you depend on, that way other parts of the store will not cause re-renders.

like image 132
Istvan Szasz Avatar answered Nov 12 '22 04:11

Istvan Szasz