RTK Query - Infinite Scrolling, retaining existing data - react-redux

I am attempting to implement infinite scrolling functionality in our current application;
We first fetch the first 5 'Posts' for a page. Upon scrolling to the bottom of the page, we then fetch the next 5 Posts.
This works nicely, however using the same query means that the existing data (the first 5 posts) has been replaced by the new data.
Is it possible to merge the existing data with the new data?
I could merge them in place, for example with something like; const posts = [newPosts, oldPosts] but then we lose the data invalidation provided by RTK Query if the existing data is modified.
What is the recommended approach for this case?

In RTK 1.9 it is now possible to use the merge option to merge newly fetched data with the data that currently lives inside the cache. Make sure you use the option together with serializeQueryArgs or forceRefetch to keep a cache entry for the data.
createApi({
baseQuery: fetchBaseQuery({ baseUrl: '/' }),
endpoints: (build) => ({
listItems: build.query<string[], number>({
query: (pageNumber) => `/listItems?page=${pageNumber}`,
// Only have one cache entry because the arg always maps to one string
serializeQueryArgs: ({ endpointName }) => {
return endpointName
},
// Always merge incoming data to the cache entry
merge: (currentCache, newItems) => {
currentCache.push(...newItems)
},
// Refetch when the page arg changes
forceRefetch({ currentArg, previousArg }) {
return currentArg !== previousArg
},
}),
}),
})
Source: RTK Documenation on the merge option
Using this you can easily implement infinite scroll. Changing the pageNumber parameter of your query, will automatically fetch new data and concat it with the data that was already in the cache.
To illustrate this, I've created a working example on CodeSandbox.

Here is a workaround for having infinite loading with caching benefits of rtk-query
in order to make it work properly when invalidating tag I had to fetch first page with hook and handle the rest in useEffect.
import { useCallback, useEffect, useState } from 'react';
import { useDispatch } from 'react-redux';
import * as R from 'ramda';
import { ApiEndpointQuery } from '#reduxjs/toolkit/dist/query/core/module';
import { QueryHooks } from '#reduxjs/toolkit/dist/query/react/buildHooks';
interface UseLazeyInfiniteDataProps<T, N> {
api: T;
/** any rtk-query api: passing the whole enpoint so we have access to api utils to invalidate provided tags */
apiEndpointName: N;
/** apiEndpoint name to retrieve correct apiEndpoint query which will have 'initiate' and 'useQuery' */
apiArgs: { [key: string]: any; params: object };
/** apiArgs are the query arguments it should have a params objec */
limit?: number;
/** limit or page-size per request (defaults 20) */
invalidatesTags?: any[];
}
/**
* This hook is for having infinite loading experience with caching posibility of rtk-query
* it's storing the data comming from rtk-q to local useState throgh a useEffect hook
* in orther to make it work when invalidating tags it makes the first page request through rtk-query hook
* and whenever it changes it will refetch the rest data
*/
const useLazyInfiniteData = <
T extends { endpoints: any; util: any },
N extends keyof T['endpoints'],
>({
api,
apiEndpointName,
apiArgs,
limit = 20,
invalidatesTags,
}: UseLazeyInfiniteDataProps<T, N>) => {
const dispatch = useDispatch<any>();
const [pageNumber, setPageNumber] = useState(1); // first load only page 1
const [maxPage, setMaxPage] = useState(0); // we don't know how many pages could exists yet
const [accData, setAccData] = useState<any[]>([]);
const [isFetchingMore, setIsFetchingMore] = useState(false);
const apiEndpoint: ApiEndpointQuery<any, any> & QueryHooks<any> =
api.endpoints[apiEndpointName];
// we need this extra hook to automate refetching when invalidating tag
// this will make the useEffect rerender if the first page data changes
const {
currentData: firstPageData,
isLoading,
isFetching,
refetch: refetch_,
} = apiEndpoint.useQuery({
...apiArgs,
params: R.mergeRight(apiArgs.params, { offset: 0, limit }),
});
const refetch = useCallback(() => {
if (invalidatesTags) {
dispatch(api.util.invalidateTags());
}
refetch_();
}, [api.util, dispatch, invalidatesTags, refetch_]);
/** when params change like changing filters in the params then we reset the loading pages to 1 */
useEffect(
function resetPageLoadDataForSinglePage() {
setPageNumber(1);
},
[apiArgs.params],
);
useEffect(
function loadMoreDataOnPageNumberIncrease() {
if (firstPageData)
setMaxPage(Math.ceil((firstPageData as any).count / limit));
if (pageNumber === 1) {
setAccData((firstPageData as any)?.items ?? []);
}
if (pageNumber > 1) {
setIsFetchingMore(true);
const promises = R.range(1, pageNumber).map((page) =>
dispatch(
apiEndpoint.initiate({
...apiArgs,
params: R.mergeRight(apiArgs.params, {
offset: page * limit,
limit,
}),
}),
).unwrap(),
);
Promise.all(promises)
.then((data: any[]) => {
const items = R.chain(R.propOr([], 'items'), [
firstPageData,
...data,
]);
setAccData(items);
})
.catch(console.error)
.finally(() => {
setIsFetchingMore(false);
});
}
},
[apiEndpoint, apiArgs, dispatch, firstPageData, limit, pageNumber],
);
/** increasing pageNumber will make the useEffect run */
const loadMore = useCallback(() => {
setPageNumber(R.inc);
}, []);
return {
data: accData,
loadMore,
hasMore: pageNumber < maxPage,
isLoading,
isFetching,
isFetchingMore,
refetch,
};
};
export default useLazyInfiniteData;

Related

Query on page not refetching once navigating back to page

In my nextjs page I have the following hook (generated by using graphql-codegen) that fetches a graphql query.
const { data, error, loading, fetchMore, refetch, variables } = useGetShortlistQuery({
notifyOnNetworkStatusChange: true, // updates loading value
defaultOptions: {
variables: {
offset: undefined,
filterBy: undefined,
sortBy: SortBy.RecentlyAdded,
sortDirection: SortDirection.Desc,
},
},
});
This is the useGetShortlistQuery hook that is generated by graphql-codegen
export function useGetShortlistQuery(
baseOptions?: Apollo.QueryHookOptions<GetShortlistQuery, GetShortlistQueryVariables>,
) {
const options = { ...defaultOptions, ...baseOptions };
return Apollo.useQuery<GetShortlistQuery, GetShortlistQueryVariables>(GetShortlistDocument, options);
}
my component is wrapped in a HOC to enable Apollo Client
export default withApollo({ ssr: true })(Index);
The withApollo HOC uses #apollo/client and the cache property of the apollo client is as follows.
cache: new InMemoryCache({
typePolicies: {
Query: {
fields: {
getShortlist: {
keyArgs: [],
merge(existing: PaginatedProperties | undefined, incoming: PaginatedProperties): PaginatedProperties {
return {
...incoming,
properties: [...(existing?.properties || []), ...(incoming?.properties || [])],
};
},
},
},
},
},
}),
The problem I am having is that on this page I update the variables on the useGetShortlistQuery using refetch which, in turn, updates the data.
However, if I navigate to another page, then come back to this page using this component. It doesn't seem to retrigger the graphql query so returns the previous data.
If you are using getStaticProps (or getServerSideProps) with pre rendered pages, it is a known behavior. It is due to optimisation by Next.js not re-rendering components between page navigations, with pages like [id].js.
The trick is to have a key on components that you want to see refreshing. You have multiple ways to do so. Having a different key on components tells React that it should be re-rendering the components, and thus will trigger again the hooks.
Practical example:
export const getStaticProps: GetStaticProps = async ({ params }) => {
const data = getData() //something that fetches your data here
return {
props: {
// some other data you want to return...
// some unique value that will be on each page
key: data.key
},
}
}
const MyPage: NextPage<InferGetStaticPropsType<typeof getStaticProps>> = (props) => {
<div key={props.key} />
}

Strapi V4 how to modify data response for a component (dynamic zone)

Imagine a case where an editor adds a “Latest Products” component to a page using dynamic zone: they add a title, a summary, and then the latest products will automatically be fetched to be available in the response. How can I add this data to the response of the component?
I know we can override the response for content types using a custom controller, but I can't find anything for how to modify the response for a component.
Maybe there's an alternative approach I haven't thought of, but coming from a Drupal preprocess-everything background this is all I can think of.
Any help appreciated!
I'm sure this isn't the best way, but I created a service for components that can be used in the content type controller to modify the response. Any improvements appreciated!
/api/custom-page/controllers/custom-page.js
'use strict';
/**
* custom-page controller
*/
const { createCoreController } = require('#strapi/strapi').factories;
module.exports = createCoreController('api::custom-page.custom-page', ({ strapi }) => ({
async find(ctx) {
const componentService = strapi.service('api::components.components');
let { data, meta } = await super.find(ctx);
data = await Promise.all(data.map(async (entry, index) => {
if(entry.attributes.sections){
await Promise.all(entry.attributes.sections.map(async (section, index) => {
const component = await componentService.getComponent(section);
entry.attributes.sections[index] = component;
}));
}
return entry;
}));
return { data, meta };
},
}));
/components/services/components.js
'use strict';
/**
* components service.
*/
module.exports = () => ({
getComponent: async (input) => {
// Latest products
if(input.__component === 'sections.latest-products'){
input.products = 'customdatahere';
}
return input;
}
});

Apollo Client reactive variable state is not kept in cache after refreshing the page

I have Apollo Client running on my React app, and trying to keep authentication info in a Reactive Variable using useReactiveVar. Everything works in the dummy function when I first set the variable, however it resets the state after refreshing the app.
Here's my cache.js:
import { InMemoryCache, makeVar } from "#apollo/client";
export const cache = new InMemoryCache({
typePolicies: {
Query: {
fields: {
isLoggedIn: {
read() {
return isLoggedInVar();
},
},
},
},
},
});
export const isLoggedInVar = makeVar();
export default cache;
Here's the component that reads the variable and renders different elements based on its state:
import React from "react";
import { useReactiveVar, useMutation } from "#apollo/client";
import MainButton from "../common/MainButton";
import { isLoggedInVar, userAddressVar } from "../../cache";
import { CREATE_OR_GET_USER } from "../../mutations/User";
const Profile = () => {
const isLoggedIn = useReactiveVar(isLoggedInVar);
const [createOrGetUser] = useMutation(CREATE_OR_GET_USER);
const handleCreateOrGetUser = () => {
const loginInput = {
address: 'text',
};
createOrGetUser({
variables: {
loginInput: loginInput,
},
}).then((res) => {
isLoggedInVar(true);
});
};
const profileComponent = isLoggedIn ? (
<div>Logged In</div>
) : (
<div onClick={handleCreateOrGetUser} className="profile-image"></div>
);
return (
<div className="profile-container">
{profileComponent}
</div>
);
};
export default Profile;
This component gets re-rendered properly when I invoke handleCreateOrGetUser, however, when I refresh the page, it resets the isLoggedInVar variable.
What would be the proper way to use Reactive Variables here to persist the cache?
It's not currently achievable using Apollo API according to their documentation.
There is currently no built-in API for persisting reactive variables,
but you can write variable values to localStorage (or another store)
whenever they're modified, and initialize those variables with their
stored value (if any) on app load.
There is a PR for that. https://github.com/apollographql/apollo-client/pull/7148

Apollo useQuery() - "refetch" is ignored if the response is the same

I am trying to use Apollo-client to pull my users info and stuck with this problem:
I have this Container component responsible for pulling the user's data (not authentication) once it is rendered. User may be logged in or not, the query returns either viewer = null or viewer = {...usersProps}.
Container makes the request const { data, refetch } = useQuery<Viewer>(VIEWER);, successfully receives the response and saves it in the data property that I use to read .viewer from and set it as my current user.
Then the user can log-out, once they do that I clear the Container's user property setUser(undefined) (not showed in the code below, not important).
The problem occurred when I try to re-login: Call of refetch triggers the graphql http request but since it returns the same data that was returned during the previous initial login - useQuery() ignores it and does not update data. Well, technically there could not be an update, the data is the same. So my code setUser(viewer); does not getting executed for second time and user stucks on the login page.
const { data, refetch } = useQuery<Viewer>(VIEWER);
const viewer = data && data.viewer;
useEffect(() => {
if (viewer) {
setUser(viewer);
}
}, [ viewer ]);
That query with the same response ignore almost makes sense, so I tried different approach, with callbacks:
const { refetch } = useQuery<Viewer>(VIEWER, {
onCompleted: data => {
if (data.viewer) {
setUser(data.viewer);
}
}
});
Here I would totally expect Apollo to call the onCompleted callback, with the same data or not... but it does not do that. So I am kinda stuck with this - how do I make Apollo to react on my query's refetch so I could re-populate user in my Container's state?
This is a scenario where apollo's caches come handy.
Client
import { resolvers, typeDefs } from './resolvers';
let cache = new InMemoryCache()
const client = new ApolloClient({
cache,
link: new HttpLink({
uri: 'http://localhost:4000/graphql',
headers: {
authorization: localStorage.getItem('token'),
},
}),
typeDefs,
resolvers,
});
cache.writeData({
data: {
isLoggedIn: !!localStorage.getItem('token'),
cartItems: [],
},
})
LoginPage
const IS_LOGGED_IN = gql`
query IsUserLoggedIn {
isLoggedIn #client
}
`;
function IsLoggedIn() {
const { data } = useQuery(IS_LOGGED_IN);
return data.isLoggedIn ? <Pages /> : <Login />;
}
onLogin
function Login() {
const { data, refetch } = useQuery(LOGIN_QUERY);
let viewer = data && data.viewer
if (viewer){
localStorage.setItem('token',viewer.token)
}
// rest of the stuff
}
onLogout
onLogout={() => {
client.writeData({ data: { isLoggedIn: false } });
localStorage.clear();
}}
For more information regarding management of local state. Check this out.
Hope this helps!

custom render connected react component with mock axios response - getBy* query misleading exception

I have a problem with updating props in my test after some code refactor. I use custom render and mock axios request but my component doesn't rerender (?). In my component in async ComponentDidMount() I do POST request. When I do manual test in browser everything works fine.
I receive exception produced by getByText():
Unable to find an element with the text: /Tasty Metal Keyboard/i. This
could be because the text is broken up by multiple elements. In this
case, you can provide a function for your text matcher to make your
matcher more flexible.
/** import React, mockAxios etc. */
const middleware = applyMiddleware(thunk);
const inputRootPath = document.createElement('input');
inputRootPath.id = 'rootPath';
inputRootPath.hidden = true;
inputRootPath.value = 'http://localhost/';
/**
*
* #param {*} ui komponent
* #param {*} param { initialState, store }
*/
export function renderWithRedux(
ui,
{ initialState, store = createStore(rootReducer, initialState, compose(middleware)) } = {},
) {
return {
...render(
<Provider store={store}>
{ui}
</Provider>,
{ container: document.body.appendChild(inputRootPath) }
),
store,
};
}
test('should render annex list', async () => {
const agBuilder = () => {
return {
ID: faker.random.number(),
NM: faker.commerce.productName(),
};
};
const agreements = [agBuilder(), agBuilder(), agBuilder(), agBuilder()];
mockAxios.post.mockResolvedValueOnce({ data: { ANLST: agreements } });
const { getByText, } = await renderWithRedux(<ConnectedAgreements />);
const optionRE = new RegExp(`${agreements[0].NM}`, 'i');
expect(getByText(optionRE)).toBeInTheDocument();
mockAxios.post.mockClear();
});
mocks/axios.js
export default {
get: jest.fn().mockResolvedValue({ data: {} }),
post: jest.fn().mockResolvedValue({ data: {} }),
};
I found solution. It turns out that after some code refactor I have another reducer which takes dispatch action invoked in CDM. It destructure axios response so my test code should have:
mockAxios.post.mockResolvedValueOnce({ data: { ANLST: agreements, CLS: {}, EXLDT: {} } });
Missing CLS and EXLDT properties casue test fail. Jest however doesn't print error that something is missing or undefined ¯_(ツ)_/¯ . Exception produced by getByText() was misleading.

Resources