innrVoice
innrVoice

Reputation: 75

Implementing infinite scroll with React/Redux and react-waypoint issue

Im struggling to achieve infinite scroll with my test React/Redux application.

Here how it works in simple words:

1) On componentDidMount I dispatch an action which sets the Redux state after getting 100 photos from the API. So I got photos array in Redux state.

2) I implemented react-waypoint, so when you scroll to the bottom of those photos it fires a method which dispatches another action that get more photos and "appends" them to the photos array and...

as I understand - the state changed, so redux is firing the setState and the component redraws completely, so I need to start scrolling again but its 200 photos now. When I reach waypoint again everything happens again, component fully rerenders and I need to scroll from top through 300 photos now.

This is not how I wanted it to work of course.

The simple example on react-waypoint without Redux works like this:

1) you fetch first photos and set the components initial state 2) after you scroll to the waypoint it fires a method which makes another request to the api, constructs new photos array(appending newly fetched photos) and (!) call setState with the new photos array.

And it works. No full re-renders of the component. Scroll position stays the same, and the new items appear below waypoint.

So the question is — is the problem I experience the problem with Redux state management or am I implementing my redux reducers/actions not correctly or...???

Why is setting component state in React Waypoint Infinite Scroll example (no Redux) works the way I want (no redrawing the whole component)?

I appreciate any help! Thank you!

The reducers

import { combineReducers } from 'redux';

const data = (state = {}, action) => {
  if (action.type === 'PHOTOS_FETCH_DATA_SUCCESS') {
    const photos = state.photos ?
      [...state.photos, ...action.data.photo] :
      action.data.photo;

    return {
      photos,
      numPages: action.data.pages,
      loadedAt: (new Date()).toISOString(),
    };
  }
  return state;
};

const photosHasErrored = (state = false, action) => {
  switch (action.type) {
    case 'PHOTOS_HAS_ERRORED':
      return action.hasErrored;
    default:
      return state;
  }
};

const photosIsLoading = (state = false, action) => {
  switch (action.type) {
    case 'PHOTOS_IS_LOADING':
      return action.isLoading;
    default:
      return state;
  }
};

const queryOptionsIntitial = {
  taste: 0,
  page: 1,
  sortBy: 'interestingness-asc',
};
const queryOptions = (state = queryOptionsIntitial, action) => {
  switch (action.type) {
    case 'SET_TASTE':
      return Object.assign({}, state, {
        taste: action.taste,
      });
    case 'SET_SORTBY':
      return Object.assign({}, state, {
        sortBy: action.sortBy,
      });
    case 'SET_QUERY_OPTIONS':
      return Object.assign({}, state, {
        taste: action.taste,
        page: action.page,
        sortBy: action.sortBy,
      });
    default:
      return state;
  }
};

const reducers = combineReducers({
  data,
  photosHasErrored,
  photosIsLoading,
  queryOptions,
});

export default reducers;

Action creators

import tastes from '../tastes';

// Action creators
export const photosHasErrored = bool => ({
  type: 'PHOTOS_HAS_ERRORED',
  hasErrored: bool,
});

export const photosIsLoading = bool => ({
  type: 'PHOTOS_IS_LOADING',
  isLoading: bool,
});

export const photosFetchDataSuccess = data => ({
  type: 'PHOTOS_FETCH_DATA_SUCCESS',
  data,
});

export const setQueryOptions = (taste = 0, page, sortBy = 'interestingness-asc') => ({
  type: 'SET_QUERY_OPTIONS',
  taste,
  page,
  sortBy,
});

export const photosFetchData = (taste = 0, page = 1, sort = 'interestingness-asc', num = 500) => (dispatch) => {
  dispatch(photosIsLoading(true));
  dispatch(setQueryOptions(taste, page, sort));
  const apiKey = '091af22a3063bac9bfd2e61147692ecd';
  const url = `https://api.flickr.com/services/rest/?api_key=${apiKey}&method=flickr.photos.search&format=json&nojsoncallback=1&safe_search=1&content_type=1&per_page=${num}&page=${page}&sort=${sort}&text=${tastes[taste].keywords}`;
  // console.log(url);
  fetch(url)
    .then((response) => {
      if (!response.ok) {
        throw Error(response.statusText);
      }
      dispatch(photosIsLoading(false));
      return response;
    })
    .then(response => response.json())
    .then((data) => {
      // console.log('vvvvv', data.photos);
      dispatch(photosFetchDataSuccess(data.photos));
    })
    .catch(() => dispatch(photosHasErrored(true)));
};

I also include my main component that renders the photos because I think maybe it's somehow connected with the fact that i "connect" this component to Redux store...

import React from 'react';
import injectSheet from 'react-jss';
import { connect } from 'react-redux';
import Waypoint from 'react-waypoint';

import Photo from '../Photo';
import { photosFetchData } from '../../actions';
import styles from './styles';

class Page extends React.Component {

  loadMore = () => {
    const { options, fetchData } = this.props;
    fetchData(options.taste, options.page + 1, options.sortBy);
  }

  render() {
    const { classes, isLoading, isErrored, data } = this.props;

    const taste = 0;

    const uniqueUsers = [];
    const photos = [];
    if (data.photos && data.photos.length > 0) {
      data.photos.forEach((photo) => {
        if (uniqueUsers.indexOf(photo.owner) === -1) {
          uniqueUsers.push(photo.owner);
          photos.push(photo);
        }
      });
    }

    return (
      <div className={classes.wrap}>
        <main className={classes.page}>

          {!isLoading && !isErrored && photos.length > 0 &&
            photos.map(photo =>
              (<Photo
                key={photo.id}
                taste={taste}
                id={photo.id}
                farm={photo.farm}
                secret={photo.secret}
                server={photo.server}
                owner={photo.owner}
              />))
          }
        </main>
        {!isLoading && !isErrored && photos.length > 0 && <div className={classes.wp}><Waypoint onEnter={() => this.loadMore()} /></div>}
        {!isLoading && !isErrored && photos.length > 0 && <div className={classes.wp}>Loading...</div>}
      </div>
    );
  }
}

const mapStateToProps = state => ({
  data: state.data,
  options: state.queryOptions,
  hasErrored: state.photosHasErrored,
  isLoading: state.photosIsLoading,
});

const mapDispatchToProps = dispatch => ({
  fetchData: (taste, page, sort) => dispatch(photosFetchData(taste, page, sort)),
});

const withStore = connect(mapStateToProps, mapDispatchToProps)(Page);

export default injectSheet(styles)(withStore);

Answer to Eric Na

state.photos is an object and I just check if its present in the state. sorry, in my example I just tried to simplify things.

action.data.photo is an array for sure. Api names it so and I didn't think about renaming it.

I supplied some pics from react dev tools.

  1. Here is my initial state after getting photos
  2. Here is the changed state after getting new portion of photos
  3. There were 496 photos in the initial state, and 996 after getting additional photos for the first time after reaching waypoint
  4. here is action.data

So all I want to say that the photos are fetched and appended but it triggers whole re-render of the component still...

Upvotes: 3

Views: 4614

Answers (2)

Matan Bobi
Matan Bobi

Reputation: 2813

I think I see the problem.

In your component you check for

{!isLoading && !isErrored && photos.length > 0 &&
            photos.map(photo =>
              (<Photo
                key={photo.id}
                taste={taste}
                id={photo.id}
                farm={photo.farm}
                secret={photo.secret}
                server={photo.server}
                owner={photo.owner}
              />))
          }

once you make another api request, in your action creator you set isLoading to true. this tells react to remove the whole photos component and then once it's set to false again react will show the new photos.

you need to add a loader at the bottom and not to remove the whole photos component once fetching and then render it again.

Upvotes: 4

Eric
Eric

Reputation: 2705

EDIT2

Try commenting out the whole uniqueUsers part (let's worry about the uniqueness of the users later)

const photos = [];
if (data.photos && data.photos.length > 0) {
  data.photos.forEach((photo) => {
    if (uniqueUsers.indexOf(photo.owner) === -1) {
      uniqueUsers.push(photo.owner);
      photos.push(photo);
    }
  });
}

and instead of

photos.map(photo =>
  (<Photo ..

try directly mapping data.photos?

data.photos.map(photo =>
  (<Photo ..

EDIT

...action.data.photo] :
     action.data.photo;

can you make sure it's action.data.photo, not action.data.photos, or even just action.data? Can you try logging the data to the console?

Also,

state.photos ? .. : ..

Here, state.photos will always evaluate to true-y value, even if it's an empty array. You can change it to

state.photos.length ? .. : ..

It's hard to tell without actually seeing how you update photos in reducers and actions, but I doubt that it's the problem with how Redux manages state.

When you get new photos from ajax request, the new photos coming in should be appended to the end of the photos array in the store.

For example, if currently photos: [<Photo Z>, <Photo F>, ...] in Redux store, and the new photos in action is photos: [<Photo D>, <Photo Q>, ...], the photos in store should be updated like this:

export default function myReducer(state = initialState, action) {
  switch (action.type) {
    case types.RECEIVE_PHOTOS:
      return {
        ...state,
        photos: [
          ...state.photos,
          ...action.photos,
        ],
      };
...

Upvotes: 1

Related Questions