Abhishek Ghosh
Abhishek Ghosh

Reputation: 2706

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;
  }
}

Upvotes: 6

Views: 7959

Answers (1)

Istvan Szasz
Istvan Szasz

Reputation: 1567

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.

Upvotes: 2

Related Questions