Invalidating a cached query conditionally in React with useQueryClient hook - reactjs

I am quite new to react and am struggling with a subtle problem with data fetching/re-fetching.
We have a data source with its own UI, which lets you create multiple discussion topics and is open to users. These topics can be updated with comments and other activities(deletion of comments/attachments/links) etc from the UI. This data source also exposes endpoints that list all topics currently in the system and the details of user activities on each of them.
Our UI, which is a React app talks to these end points and:
lists out the topics.
upon clicking on one of the items shows the activity counts on the item.(in a separate panel with two tabs - one for comment and one for other user activities)
I am responsible for number 2 above.
I wrote a custom hook to achieve this goal which is called by the panel and used the useQueryClient to invalidate my query inside the hook, but unfortunately, every time the component(panel) re-renders or I switch between the tabs a new call is made to fetch the count which is deemed unnecessary. Instead we want the call to fetch the counts to go out only when the user clicks on the item and the panel opens up. But I am unable to achieve this without violating the rules of hooks(calling it inside conditionals/ calling it outside of a react component).
export const useTopicActivityCounts = (
topicId: string | undefined,
): ITopicActivityCounts | undefined => {
useQueryClient().invalidateQueries(['TopicActivitytCounts', { topicId }]);
const { data } = useQuery(
['TopicActivityCounts', { topicId }],
() =>
fetchAsync<IResult<ITopicActivityCount>>(endpointUrl, {
method: 'GET',
params: {
id,
},
}).then(resp => resp?.value),
{
enabled: !!topicId,
staleTime: Infinity,
},
);
return data;
this hook is called from here:
export const TopicDetails = memo(({ item, setItem }: ITopicDetails): JSX.Element => {
const counts = useTopicActivityCounts(item?.id);
const headerContent = (
<Stack>
/* page content */
</Stack>
);
const items = [
/* page content */,
];
const pivotItems = [
{
itemKey: 'Tab1',
headerText: localize('Resx.Comms', { count: counts?.commentsCount ?? '0' }),
},
{
itemKey: 'Tab2',
headerText: localize('Resx.Activities', { count: counts?.activitiesCount ?? '0' }),
},
];
return (
/*
page content
*/
);
});
I have tried placing it inside an onSuccess inside the hook and that did not work.

Related

Infinite scroll with RTK (redux-toolkit)

I'm struggling to implement full support of infinite scroll with cursor pagination, adding new elements and removing. I have used great example from github discussion https://github.com/reduxjs/redux-toolkit/discussions/1163#discussioncomment-876186 with few adjustments based on my requirements. Here is my implementation of useInfiniteQuery.
export function useInfiniteQuery<
ListOptions,
ResourceType,
ResultType extends IList<ResourceType> = IList<ResourceType>,
Endpoint extends QueryHooks<
QueryDefinition<any, any, any, ResultType, any>
> = QueryHooks<QueryDefinition<any, any, any, ResultType, any>>,
>(
endpoint: Endpoint,
{
options,
getNextPageParam,
select = defaultSelect,
inverseAppend = false,
}: UseInfiniteQueryOptions<ListOptions, ResultType, ResourceType>,
) {
const nextPageRef = useRef<string | undefined>(undefined);
const resetRef = useRef(true);
const [pages, setPages] = useState<ResourceType[] | undefined>(undefined);
const [trigger, result] = endpoint.useLazyQuery();
const next = useCallback(() => {
if (nextPageRef.current !== undefined) {
trigger(
{ options: { ...options, page_after: nextPageRef.current } },
true,
);
}
}, [trigger, options]);
useEffect(() => {
resetRef.current = true;
trigger({ options }, true);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, Object.values(options));
useEffect(() => {
if (!result.isSuccess) {
return;
}
nextPageRef.current = getNextPageParam(result.data);
if (resetRef.current) {
resetRef.current = false;
setPages(select(result.data));
} else {
setPages((pages) =>
inverseAppend
? select(result.data).concat(pages ?? [])
: (pages ?? []).concat(select(result.data)),
);
}
}, [result.data, inverseAppend, getNextPageParam, select, result.isSuccess]);
return {
...result,
data: pages,
isLoading: result.isFetching && pages === undefined,
hasMore: nextPageRef.current !== undefined,
next,
};
}
This example works great with pagination, but the problem when I'm trying to add new elements. I can't distinguish are new elements arrived from new pagination batch or when I called mutation and invalidated tag (in this case RTK refetch current subscriptions and update result.data which I'm listening in second useEffect).
I need to somehow to identify when I need to append new arrived data (in case of next pagination) or replace fully (in case when mutation was called and needs to reset cursor and scroll to top/bottom).
My calling mutation and fetching the data placed in different components. I tried to use fixedCacheKey to listening when mutation was called and needs to reset data, but I very quickly faced with a lot of duplication and problem when I need to reuse mutation in different places, but keep the same fixed cache key.
Someone has an idea how to accomplish it? Perhaps, I need to take another direction with useInfiniteQuery implementation or provide some fixes to current implementation. But I have no idea to handle this situation. Thanks!

How can I make transient/one-off state changes to a React component from one of its ancestors?

I want to modify the state of a child component in React from a parent component a couple levels above it.
The child component is a react-table with pagination.
My use case is changing the data in the table with some client-side JS filtering.
The problem is, the table uses internal state to keep track of which page is being shown, and does not fully update in response to my filtering.
It is smart enough to know how much data it contains, but not smart enough to update the page it is on.
So, it might correctly say "Showing items 21-30 of 85", and then the user filters the data down to only four total items, and the table will say "Showing items 21-30 of 4".
I tried implementing something like what the FAQ suggests for manual state control, but that caused its own problem.
I was passing the new page index in as a prop, and that did set the page correctly, but it broke the ability for the user to navigate between pages, because any changes were immediately overwritten by the value of the prop.
Those instructions seem to work for a situation where all page index control gets handled by the parent, but not when some control should still be retained by the pagination mechanism.
I think what I need is an exposed function that lets me modify the value of the table's state.pageIndex as a one-off instead of passing a permanent prop. Is there a way to do that? Or any other way to solve my underlying problem?
Code follows. I apologize in advance I couldn't make this a real SSCCE, it was just too complicated, I tried to at least follow the spirit of SSCCEs as much as I could.
My page that lists stuff for the user looks like this:
// ...
const [searchTerms, setSearchTerms] = useState<Array<string>>([]);
// ...
const handleFilterRequestFromUser = function (searchTerms): void {
// ...
setSearchTerms(processedSearchTerms);
};
// ...
const visibleData = useMemo(() => {
// ...
}, [searchTerms]);
// ...
return (
<div>
// ...
<ImmediateParentOfTable
id={"Results"}
visibleData={visibleData} // User actions can affect the size of this
// ...
>
// ...
</div>
);
export default ListDatabaseResults;
Here's ImmediateParentOfTable:
import { Table, Pagination } from "#my-company/react";
// ...
return (
<Table
id={id}
pagination={{
render: (
dataSize,
{
pageCount,
pageOptions,
// ...
}
) => (
<Pagination
dataSize={dataSize}
pageCount={pageCount}
pageOptions={pageOptions}
gotoPage={gotoPage}
previousPage={previousPage}
nextPage={nextPage}
setPageSize={setPageSize}
canPreviousPage={canPreviousPage}
canNextPage={canNextPage}
pageIndex={pageIndex}
pageSize={pageSize}
pageSizeOptions={[10, 20, 50, 100]}
/>
),
manual: {
onPageChange: ({
pageIndex,
pageSize,
}: {
pageIndex: number;
pageSize: number;
}) => {
setPageIndex(pageIndex);
setPageSize(pageSize);
},
rowCount,
pageCount: tablePageCount,
},
isLoading: !!dataLoading,
}}
/>
);
The custom Table inside #my-company/react (already in use in other places, so, difficult to modify):
import {
CellProps,
Column,
Hooks,
Row,
SortingRule,
TableState,
useFlexLayout,
usePagination,
UsePaginationInstanceProps,
UsePaginationState,
useRowSelect,
useSortBy,
useTable,
} from 'react-table';
// ...
export interface TableProps<D extends Record<string, unknown>> {
id: string;
// ...
pagination?: Pagination<D>;
pageIndexOverride?: number; // This is the new prop I added that breaks pagination
}
const Table = <D extends Record<string, unknown>>({
id,
columns,
data,
// ...
pageIndexOverride,
}: TableProps<D>): JSX.Element => {
const {
state: { pageIndex, pageSize, sortBy },
// ...
} = useTable(
{
columns,
data,
autoResetPage,
initialState,
useControlledState: (state) => {
return React.useMemo(
() => ({
...state,
pageIndex: pageIndexOverride || state.pageIndex, // This always resets page index to the prop value, so changes from the pagination bar no longer work
}),
[state],
);
},
// ...
I've encountered a similar problem with react-table where most of my functionality (pagination, sorting, filtering) is done server-side and of course when a filter is changed I must set the pageIndex back to 0 to rectify the same problem you have mentioned.
Unfortunately, as you have discovered, controlled state in v7 of react-table is both poorly documented and apparently just completely non-functional.
I will note that the example code you linked from the docs
const [controlledPageIndex, setControlledPage] = React.useState(0)
useTable({
useControlledState: state => {
return React.useMemo(
() => ({
...state,
pageIndex: controlledPageIndex,
}),
[state, controlledPageIndex]
)
},
})
is actually invalid. controlledPageIndex cannot be used as a dep in that useMemo because it is in the outer scope and is accessed through closure. Mutating it will do nothing, which is actually noted by eslint react/exhaustive-deps rule so it's quite surprising that this made it into the docs as a way of accomplishing things. There are more reasons why it is unusable, but the point is that you can forget using useControlledState for anything.
My suggestion is to use the stateReducer table option and dispatch a custom action that will do what you need it to. The table reducer actions can have arbitrary payloads so you can do pretty much whatever you want. ajkl2533 in the github issues used this approach for row selection (https://github.com/TanStack/react-table/issues/3142#issuecomment-822482864)
const reducer = (newState, action) => {
if (action.type === 'deselectAllRows') {
return { ...newState, selectedRowIds: {} };
}
return newState;
}
...
const { dispatch, ... } = useTable({ stateReducer: reducer }, ...);
const handleDeselectAll = () => {
dispatch({ type: 'deselectAllRows' });
}
It will require getting access to the dispatch from the useTable hook though.

Electrode doesn't show dynamic data in page source

Using electrode, I noticed this weird behaviour -
When I view the page source after the page fully loads with all the api calls and data, I am only able to view the content that is static for example, the hyper links, headings, footer links etc.
I have created a custom token handler which checks the context object and populates the custom tokens present in the index.html.
So, whenever, I console.log(context.user.content), only the data that is static such as hyperlinks, headings, footer links are logged.
I guess this is the problem but I am not able to wrap my head around as to why electrode doesn't recognise the content being rendered dynamically.
Token-Handler.js file
import Helmet from 'react-helmet';
const emptyTitleRegex = /<title[^>]*><\/title>/;
module.exports = function setup(options) {
// console.log({ options });
return {
INITIALIZE: context => {
context.user.helmet = Helmet.renderStatic();
},
PAGE_TITLE: context => {
const helmet = context.user.helmet;
const helmetTitleScript = helmet.title.toString();
const helmetTitleEmpty = helmetTitleScript.match(emptyTitleRegex);
return helmetTitleEmpty ? `<title>${options.routeOptions.pageTitle}</title>` : helmetTitleScript;
},
REACT_HELMET_SCRIPTS: context => {
const scriptsFromHelmet = ["link", "style", "script", "noscript"]
.map(tagName => context.user.helmet[tagName].toString())
.join("");
return `<!--scripts from helmet-->${scriptsFromHelmet}`;
},
META_TAGS: context => {
console.log(context,'123') //this is where I am checking
return context.user.helmet.meta.toString();
}
};
};
default.js
module.exports = {
port: portFromEnv() || "3000",
webapp: {
module: "electrode-react-webapp/lib/express",
options: {
prodBundleBase: '/buy-used-car/js/',
insertTokenIds: false,
htmlFile: "./{{env.APP_SRC_DIR}}/client/index.html",
paths: {
"*": {
content: {
module: "./{{env.APP_SRC_DIR}}/server/views/index-view"
},
}
},
serverSideRendering: true,
tokenHandler: "./{{env.APP_SRC_DIR}}/server/token-handler"
}
}
};
Any clue anyone?
EDIT 1
However, any following updates that occur on the meta tags are rendered. I'm not sure that is something electrode allows or is a feature of react-helmet.
EDIT 2
SSR is enabled in electrode.
After digging in the docs, realised that there was a slight misunderstanding. So, if data needs to be present in the page source, it needs to be pre-rendered by the server.
Why it wasn't showing at the time I asked the question? Because, data was being evaluated at run-time due ot which the page source only rendered the static content.
Electrode already provides an abstraction, each component that is being rendered has an option to load with pre-fetched data. The catch here is, you have to evaluate what all data needs to be present at runtime because more data is directly proportional to page loading time (as the server won't resolve unless the api you are depending on returns you with either a success or failure )
In terms of implementation, each route has a parameter called init-top which is executed before your page loads.
const routes = [
{
path: "/",
component: withRouter(Root),
init: "./init-top",
routes: [
{
path: "/",
exact: true,
component: Home,
init: "./init-home"
},
in init-home, you can define it something on the lines of -
import reducer from "../../client/reducers";
const initNumber = async () => {
const value = await new Promise(resolve => setTimeout(() => resolve(123), 2000));
return { value };
};
export default async function initTop() {
return {
reducer,
initialState: {
checkBox: { checked: false },
number: await initNumber(),
username: { value: "" },
textarea: { value: "" },
selectedOption: { value: "0-13" }
}
};
}
So,now whenever you load the component, it is loaded with this initialState returned in init-home
I'll just post it here, in case anyone is stuck.

React/Redux - fetching and filtering a large amount of data

In my database I have more than 1,000,000 stored items. When I want showing them in my React App, I use a simple Component that fetch first 100 items from database, store them in Redux Store and display in the list (it uses react-virtualized to keep a few items in the DOM). When user scrolls down and the item 50 (for example) is visualized, the Component fetchs next 100 items from database and store them in Redux store again. At this point, I have 200 items stored in my Redux store. When the scroll down process continues, it gets to the point where the Redux store keeps a lot of items:
ItemsActions.js
const fetchItems = (start=0, limit=100) => dispatch => {
dispatch(fetchItemsBegin())
api.get(`items?_start=${start}&_limit=${limit}`).then(
res => {
dispatch(fetchItemsSuccess(res.data))
},
error => {
dispatch(fetchItemsError(error))
}
)
}
const fetchItemsBegin = () => {
return {
type: FETCH_ITEMS_BEGIN
}
}
const fetchItemsSuccess = items => {
return {
type: FETCH_ITEMS_SUCCESS,
payload: {
items
}
}
}
MyComponent.jsx
state = {
currentIndex: 0,
currentLimit: 100
}
const mapStateToProps = ({ items }) => ({
data: Object.values(items.byId)
})
const mapDispatchToProps = dispatch => ({
fetchItems: (...args) => dispatch(fetchItems(...args)),
})
componentDidMount() {
this.props.fetchItems(0,100)
}
onScroll(index) {
...
if (currentIndex > currentLimit - 50) {
this.props.fetchItem([newIndex],100)
}
}
render() {
return (
this.props.data.map(...)
)
}
First Question:
Is necessary to store all items in Redux?? If current index of the list displayed is 300,000, should I have 300,000 items in Redux Store? or should I have stored the 200 displayed items? How I have to handle the FETCH_ITEMS_SUCCESS action in the reducer file?
ItemsReducer.js
case FETCH_ITEMS_SUCCESS:
return {
...state, ????
...action.payload.items.reduce((acc, item) => ({
...acc,
[item.id]: item
}), {})
}
Second Question:
In the same Component, I have also a filter section to display items which meet the indicated conditions. I have only a few items active in the DOM and, depending on first question, others items in Redux Store, but filters must be applied to 1,000,000 items stored in the database. How I handle this situation?
Conclusion
I don't know how I should handle a large amount of items stored in the backend. How many items should have the Redux store in each situation?
Thanks in advance.

Get ID from 1st withTracker query and pass to a 2nd withTracker query? (Meteor / React)

Im using React with Meteor. Im passing data to my Event React component with withTracker, which gets and ID from the URL:
export default withTracker(props => {
let eventsSub = Meteor.subscribe('events');
return {
event: Events.find({ _id: props.match.params.event }).fetch(),
};
})(Event);
This is working but I now need to get data from another collection called Groups. The hard bit is that I need to get an ID from the event that I'm already returning.
The code below works when I hardcode 1. However 1 is actually dynamic and needs to come from a field return from the event query.
export default withTracker(props => {
let eventsSub = Meteor.subscribe('events');
let groupsSub = Meteor.subscribe('groups');
return {
event: Events.find({ _id: props.match.params.event }).fetch(),
group: Groups.find({ id: 1 }).fetch(),
};
})(Event);
#Yasser's answer looks like it should work although it will error when event is undefined (for example when the event subscription is still loading).
When you know you're looking for a single document you can use .findone() instead of .find().fetch(). Also when you're searching by _id you can just use that directly as the first parameter. You should also provide withTracker() with the loading state of any subscriptions:
export default withTracker(props => {
const eventsSub = Meteor.subscribe('events');
const groupsSub = Meteor.subscribe('groups');
const loading = !(eventsSub.ready() && groupsSub.ready());
const event = Events.findOne(props.match.params.event);
const group = event ? Groups.findOne(event.id) : undefined;
return {
loading,
event,
group,
};
})(Event);
There's another issue from a performance pov. Your two subscriptions don't have any parameters; they may be returning more documents than you really need causing those subscriptions to load slower. I would pass the event parameter to a publication that would then return an array that includes both the event and the group.
export default withTracker(props => {
const oneEventSub = Meteor.subscribe('oneEventAndMatchingGroup',props.match.params.event);
const loading = !oneEventSub.ready();
const event = Events.findOne(props.match.params.event);
const group = event ? Groups.findOne(event.id) : undefined;
return {
loading,
event,
group,
};
})(Event);
The oneEventAndMatchingGroup publication on the server:
Meteor.publish(`oneEventAndMatchingGroup`,function(id){
check(id,String);
const e = Events.findOne(id);
return [ Events.find(id), Groups.find(e.id) ];
});
Note that a publication has to return a cursor or array of cursors hence the use of .find() here.
It's not very clear which field of the event object should be supplied to Groups.find. But I'll try answering the question.
Try using something like this -
export default withTracker(props => {
let eventsSub = Meteor.subscribe('events');
let groupsSub = Meteor.subscribe('groups');
event = Events.find({ _id: props.match.params.event }).fetch();
return {
event,
group: Groups.find({ id: event['id'] }).fetch(),
};
})(Event);
Note this line -
group: Groups.find({ id: event['id'] }).fetch(),
can be modified to use whichever field you need.
group: Groups.find({ id: event['whichever field'] }).fetch(),

Resources