Recoil: Incremental loading for infinite scrolling

Created on 27 Nov 2020  路  8Comments  路  Source: facebookexperimental/Recoil

I am implementing infinite scrolling for a list of tickets. Currently, recoil doesnt seem to offer a way to get all data that a selector has fetched.

Example:

export const issues = selector({
  key: 'issueList',
  get: async ({ get }) => {
    const cursor = get(issuesCursor);
    const client = getApolloClient();
    const issues = await client.query<IssuesQuery, IssuesQueryVariables>({
      query: IssuesDocument,
      variables: {
        cursor,
      },
    });
    return { nextCursor: issues.data.issuesCursor.cursor, items: issues.data.issuesCursor.items };
  },
});
export const issueList = selector({
  key: 'nextIssuesCursor',
  get: ({ get }) => get(issues).items,
});

I would like to receive all issues from the issueList selector.

I can solve this issue with a hook, yet i would strongly prefer keeping all the data fetching in recoiljs.

question

Most helpful comment

I finished implementing the way @drarmstr recommended and it worked like a charm. Thanks a lot.

All 8 comments

There are a couple of strategies that can solve this.
You could store all of the data in a single array kept in an atom, and just append to that array when scrolling.
You could also store them in chunks/pages using an atomFamily with the parameter being the cursor, and then keep an array of all known cursors in an atom, and finally have a selector that returns a concatenated list of items by taking the array of cursors and mapping them to the atomFamily.

@BenjaBobs That would still require me to mutate the atom from outside recoil.

This is how i solved it:

export function useIssues() {
  const [issues, setIssues] = useRecoilState(issuesStore);
  const [, setCursor] = useRecoilState(issuesCursor);
  const currentIssues = useRecoilValue(currentIssueBatch);
  const nextCursor = useRecoilValue(nextIssuesCursor);

  useEffect(() => {
    setIssues((prev) => [...prev, ...currentIssues]);
  }, [currentIssues, setIssues]);

  const fetchMore = () => {
    setCursor(nextCursor ?? null);
  };
  return { issues, fetchMore };
}

which works quite well, but it would still be cool to have a way of accessing selectors previous states or have selectors write the state. I can understand why recoil wont allow writing state in getters, because you would open up a lot of possibilitys for accidental recursion, but it would still be nice to have something to solve in recoil only

You could do something like this if you want to keep it all in Recoil:

async function ApiCall(cursor: Number): Promise<any[]> {
  return [];
}

export const allItems = atom<any[]>({
  key: 'allItems',
  default: []
})

const currentCursorInternal = atom<number>({
  key: 'currentCursorInternal',
  default: 0,
});

export const currentCursor = selector<number>({
  key: 'currentCursor',
  get: ({ get }) => get(currentCursorInternal),
  set: async ({ get, set }) => {
    const current = get(currentCursorInternal);
    const next = current + 1;

    const newData = await ApiCall(next);
    set(currentCursorInternal, next);
    set(allItems, existing => [...existing, ...newData]);
  }
})

That is so cool. Thanks @BenjaBobs !

Theres no async sets in selectors :( https://github.com/facebookexperimental/Recoil/issues/762

Ah, I see, guess my suggestion won't work then. And since atomEffects can set values of other atoms, I don't think this is currently possible unless using hacky ways such as storing a reference to a set function from a useRecoilCallback and then using that to set the value.

@mastorm / @BenjaBobs
Would a solution using useRecoilCallback() like the following work?

export const allItems = atom<Array<Item>>>({
  key: 'allItems',
  default: [],
});

const currentCursorInternal = atom<number>({
  key: 'currentCursorInternal',
  default: 0,
});

export fetchMoreItems = useRecoilCallback(({snapshot, set}) => async () => {
  set(currentCursorInternal, cursor => {
    const next = cursor + 1;
    promiseDone(ApiCall(next), newData => {
      // TODO use some queue mechanism to handle out-of-order responses.
      set(allItems, existing => [...existing, ...newData]);
    });
    return next;
  });
});

But, the most versatile or appropriate for this situation might be using the waitForNone() helper to handle the incremental loading pattern:

const itemQuery = selectorFamily({
  key: 'ItemQuery',
  get: cursor => () => ApiCall(cursor),
});

const currentCursorInternal = atom<number>({
  key: 'currentCursorInternal',
  default: 0,
});

export function useFetchMoreItems() {
  const setCurrentCursor = useSetRecoilState(currentCursorInternal);
  return () => setCurrentCursor(cursor => cursor + 1);
}

export const allItems = selector({
  key: 'AllItems',
  get: ({get}) => {
    const current = get(currentCursorInternal);
    const cursorRange = Array.from(Array(current).keys());
    return get(waitForNone(cursorRange.map(itemQuery)));
  },
});

function ShowItemsComponent() {
  const itemLoadables = useGetRecoilValue(allItems);
  const fetchMoreItems = useFetchMoreItems();
  return (
    <div>
      <button onClick={fetchMoreItems}>Fetch More Items</button>
      {itemLoadables.map(itemLoadable => ({
        hasValue: <Item item={itemLoadable.contents} key={itemLoadable.contents} />,
        hasError: <Error error={itemLoadable.contents} key={itemLoadable.contents} />,
        loading: <CoolShimmer key={itemLoadable.contents} />,
      }[itemLoadable.state])}
    </div>
  );
}

I finished implementing the way @drarmstr recommended and it worked like a charm. Thanks a lot.

Was this page helpful?
0 / 5 - 0 ratings

Related issues

robsoncezario picture robsoncezario  路  3Comments

yuantongkang picture yuantongkang  路  3Comments

jamiebuilds picture jamiebuilds  路  3Comments

pesterhazy picture pesterhazy  路  4Comments

jamiewinder picture jamiewinder  路  3Comments