Redux toolkit mutation access to state across multiple components - reactjs

I'm start using RTQ.
To access query data is possible via generated hook. But for mutations, it doesn't work.
I have this simple example.
This component is responsible for loading "users" from API.
const Page = () => {
const {
isSuccess: isSuccessUsers
isLoading: isLoadingUsers
} = useGetUsersQuery({})
if (isLoadingUsers) return <LoadingSpinner />
if (isSuccessUsers) return <UsersTable />
...
}
This component display users, which are obtained from cache - was filled by the API call in previous component "Page".
Also in that component I want display loading spinner for deleting state.
const UsersTable = () => {
const {
data: dataUsers,
isFetching: isFetchingUsers,
} = useGetUsersQuery({})
const [, {
data: dataDeleteUser,
isLoading: isLoadingDeleteUser,
isSuccess: isSuccessDeleteUser,
isError: isErrorDeleteUser,
error: errorDeleteUser,
}] = useDeleteUserMutation()
useEffect(() => {
if (isSuccess) {
console.log(data.data.message)
}
}, [isSuccess])
useEffect(() => {
if (isError) {
console.log(error.data.message)
}
}, [isError])
if (usersIsFetching || isLoadingDeleteUser) return <LoadingSpinner />
dataUsers.map(user=>(<UsersTableRow user={user}/>))
}
In this component I only want call delete function. But here, when use same pattern just like for query, it doesnt work.
const UsersTableRow = ({user}) => {
const [deleteUser] = useDeleteUserMutation()
const handleDeleUser = () => {
deleteUser(user.id)
}
...
}
I could solve this by pass deleteUser function as prop from UsersTable component to UsersTableRow component.
const [ deleteUser, {
data: dataDeleteUser,
isLoading: isLoadingDeleteUser,
isSuccess: isSuccessDeleteUser,
isError: isErrorDeleteUser,
error: errorDeleteUser,
}] = useDeleteUserMutation()
dataUsers.map(user=>(<UsersTableRow deleteUser={deleteUser} user={user}/>))
I would like to avoid this, because if there are more child components, I will have to pass to every child.
Is there some better solution?

You can use a fixed cache key to "tell" redux-toolkit that you want to use the same result for both components:
export const ComponentOne = () => {
// Triggering `deleteUser` will affect the result in both this component,
// but as well as the result in `ComponentTwo`, and vice-versa
const [deleteUser, result] = useDeleteUserMutation({
fixedCacheKey: 'shared-delete-user',
})
return <div>...</div>
}
export const ComponentTwo = () => {
const [deleteUser, result] = useDeleteUserMutation({
fixedCacheKey: 'shared-delete-user',
})
return <div>...</div>
}

Related

Loader that doesn't render children unless data is fetched

I am trying to make a component that renders "children" prop only "and only if" a boolean is true, now i noticed if i do something like this
const QueryLoader = (props) => {
if (!props.isSuccess) return <h2>Loading</h2>;
return props.children;
};
and use it as follows
const Main = (props) => {
const {isSuccess,data} = fetcher("api");
return (
<QueryLoader isSuccess={isSuccess}>
<div>{data.arrayOfData.innerSomething}</div>
</QueryLoader>
);
};
the data.arrayOfData.innerSomething is still triggered which is causing me issues, i thought about instead of sending children i send a component as a function and then call it inside the QueryLoader but i dont know if this has any side-effects.
Any suggestions?
This is called render prop pattern:
const QueryLoader = ({ isSuccess, children }) => {
return isSuccess ? children() : <h2>Loading</h2>;
};
const Main = () => {
const { isSuccess, data } = fetcher("api");
return (
<QueryLoader isSuccess={isSuccess}>
{() => <div>{data.arrayOfData.innerSomething}</div>}
</QueryLoader>
);
};
For data fetching I hightly recommend using react-query library.

useEffect to rerender this component

I am trying to use useEffect to rerender postList (to make it render without the deleted post) when postsCount change, but I can't get it right. I tried to wrap everything inside useEffect but I couldn't execute addEventListener("click", handlePost) because I am using useEffect to wait for this component to mount first, before attaching the evenListener.
Parent component:
function Tabs() {
const [posts, setPosts] = useState([]);
const dispatch = useDispatch();
const postsCount = useSelector((state) => state.posts.count);
useEffect(() => {
document.getElementById("postsTab").addEventListener("click", handlePost);
}, [handlePost]);
const handlePost = async (e) => {
const { data: { getPosts: postData }} = await refetchPosts();
setPosts(postData);
dispatch(postActions.getPostsReducer(postData));
};
const { data: FetchedPostsData, refetch: refetchPosts } = useQuery( FETCH_POSTS_QUERY, { manual: true });
const [postList, setPostsList] = useState({});
useEffect(() => {
setPostsList(
<Tab.Pane>
<Grid>
<Grid.Column>Title</Grid.Column>
{posts.map((post) => (
<AdminPostsList key={post.id} postId={post.id} />
))}
</Grid>
</Tab.Pane>
);
console.log("changed"); //it prints "changed" everytime postCount changes (or everytime I click delete), but the component doesn't remount
}, [postsCount]);
const panes = [
{ menuItem: { name: "Posts", id: "postsTab", key: "posts" }, render: () => postList }
];
return (<Tab panes={panes} />);
}
child/AdminPostsList component:
function AdminPostsList(props) {
const { postId } = props;
const [deletePost] = useMutation(DELETE_POST_MUTATION, {variables: { postId } });
const dispatch = useDispatch();
const deletePostHandler = async () => {
dispatch(postActions.deletePost(postId));
await deletePost();
};
return (
<>
<Button icon="delete" onClick={deletePostHandler}></Button>
</>
);
}
The Reducers
const PostSlice = createSlice({
name: "storePosts",
initialState: {
content: [],
count: 0,
},
reducers: {
getPostsReducer: (state, action) => {
state.content = action.payload;
state.count = action.payload.length
},
deletePost: (state, action) => {
const id = action.payload
state.content = current(state).content.filter((post) => (post.id !== id))
state.count--
}
},
});
Okay, let discuss this in separate comment. Key point is to decouple posts logic from wrapper component(Tabs). You should create component dedicated only to posts and render it in wrapper. Like that you can easily isolate all posts-related logic in posts-related component, for example to avoid attaching some listeners from wrapper(because it is not intuitive what you are doing and who listens for what because button is not in that same component). In separated component you will have only one useEffect, to fetch posts, and you will have one selector(to select posts from redux), and then just use that selection to output content from component.
That part <Tab panes={...} /> was the source of most of your problems, because like that you are forced to solve everything above <Tab../> and then just to pass it, which is not best practice in you case since it can be too complicated(especially in case when you could have multiple tabs). That is why you need to decouple and to create tab-specific components.
This would be an idea of how you should refactor it:
function PostsTab() {
const posts = useSelector((state) => state.posts?.content ?? []);
useEffect(() => {
// Here dispatch action to load your posts
// With this approach, when you have separated component for PostsTab no need to attach some weird event listeners, you can do everything here in effect
// This should be triggered only once
// You can maybe introduce 'loading' flag in your reducer so you can display some loaders for better UX
}, []);
return (
<div>
{/* Here use Tab components in order to create desired tab */}
<Tab.Pane>
<Grid>
<Grid.Column>Title</Grid.Column>
{posts.map((post) => (
<AdminPostsList key={post.id} postId={post.id} />
))}
</Grid>
</Tab.Pane>
</div>
);
}
function Tabs() {
return (
<div>
<PostsTab/>
{/** HERE you can add more tabs when you need to
* Point is to create separate component per tab so you can isolate and maintain tab state in dedicated component
and to avoid writing all logic here in wrapper component
* As you can see there is no need to attach any weird listener, everything related to posts is moved to PostsTab component
*/}
</div>
);
}
Ok, let's discuss what I did wrong for the future reader:
There is no need to use this weird spaghetti
useEffect(() => {
document.getElementById("postsTab").addEventListener("click", handlePost);
}, [handlePost]);
const panes = [
{ menuItem: { name: "Posts", id: "postsTab", key: "posts" }, render: () => postList }
];
for I could've used a <Menu.Item onClick={handleClick}>Posts</Menu.Item> to attach the onClick directly.
I had to use useEffect to monitor posts dependency, but .map() will automatically update its content if the array I am mapping had any changes so there is no need to use it use useEffect in this context.
I think I can use lifting state to setPosts from the child component and the change will trigger .map() to remap and pop the deleted element, but I couldn't find a way to so, so I am using a combination of redux (to store the posts) and useEffect to dispatch the posts to the store than I am mapping over the stored redux element, idk if this is the best approach but this is all I managed to do.
The most important thing I didn't notice when I almost tried everything is, I must update apollo-cache when adding/deleting a post, by using proxy.readQuery
this is how I did it
const [posts, setPosts] = useState([]);
const handlePosts = async () => {
const { data: { getPosts: postData } } = await refetchPosts();
setPosts(postData);
};
const handlePosts = async () => {
const { data } = await refetchPosts();
setPosts(data.getPosts);
};
// Using useEffect temporarily to make it work.
// Will replace it with an lifting state when refactoring later.
useEffect(() => {
posts && dispatch(postsActions.PostsReducer(posts))
}, [posts]);
const [deletePost] = useMutation(DELETE_POST_MUTATION, {
update(proxy) {
let data = proxy.readQuery({
query: FETCH_POSTS_QUERY,
});
// Reconstructing data, filtering the deleted post
data = { getPosts: data.getPosts.filter((post) => post.id !== postId) };
// Rewriting apollo-cache
proxy.writeQuery({ query: FETCH_POSTS_QUERY, data });
},
onError(err) {
console.log(err);
},
variables: { postId },
});
const deletePostHandler = async () => {
deletePost();
dispatch(postsActions.deletePost(postId))
};
Thanks to #Anuj Panwar #Milos Pavlovic for helping out, kudos to #Cptkrush for bringing the store idea into my attention

Context API values are being reset too late in the useEffect of the hook

I have a FilterContext provider and a hook useFilter in filtersContext.js:
import React, { useState, useEffect, useCallback } from 'react'
const FiltersContext = React.createContext({})
function FiltersProvider({ children }) {
const [filters, setFilters] = useState({})
return (
<FiltersContext.Provider
value={{
filters,
setFilters,
}}
>
{children}
</FiltersContext.Provider>
)
}
function useFilters(setPage) {
const context = React.useContext(FiltersContext)
if (context === undefined) {
throw new Error('useFilters must be used within a FiltersProvider')
}
const {
filters,
setFilters
} = context
useEffect(() => {
return () => {
console.log('reset the filters to an empty object')
setFilters({})
}
}, [setFilters])
{... do some additional stuff with filters if needed... not relevant }
return {
...context,
filtersForQuery: {
...filters
}
}
}
export { FiltersProvider, useFilters }
The App.js utilises the Provider as:
import React from 'react'
import { FiltersProvider } from '../filtersContext'
const App = React.memo(
({ children }) => {
...
...
return (
...
<FiltersProvider>
<RightSide flex={1} flexDirection={'column'}>
<Box flex={1}>
{children}
</Box>
</RightSide>
</FiltersProvider>
...
)
}
)
export default App
that is said, everything within FiltersProvider becomes the context of filters.
Now comes the problem description: I have selected on one page (Page1) the filter, but when I have to switch to another page (Page2), I need to flush the filters. This is done in the useFilters hook in the unmount using return in useEffect.
The problem is in the new page (Page2), during the first render I'm still getting the old values of filters, and than the GraphQL request is sent just after that. Afterwards the unmount of the hook happens and the second render of the new page (Page2) happens with set to empty object filters.
If anyone had a similar problem and had solved it?
first Page1.js:
const Page1 = () => {
....
const { filtersForQuery } = useFilters()
const { loading, error, data } = useQuery(GET_THINGS, {
variables: {
filter: filtersForQuery
}
})
....
}
second Page2.js:
const Page2 = () => {
....
const { filtersForQuery } = useFilters()
console.log('page 2')
const { loading, error, data } = useQuery(GET_THINGS, {
variables: {
filter: filtersForQuery
}
})
....
}
Printout after clicking from page 1 to page 2:
1. filters {isActive: {id: true}}
2. filters {isActive: {id: true}}
3. page 2
4. reset the filters to an empty object
5. 2 reset the filters to an empty object
6. filters {}
7. page 2
As I mentioned in the comment it might be related to the cache which I would assume you are using something like GraphQL Apollo. It has an option to disable cache for queries:
fetchPolicy: "no-cache",
By the way you can also do that reset process within the Page Two component if you want to:
const PageTwo = () => {
const context = useFilters();
useEffect(() => {
context.setFilters({});
}, [context]);
For those in struggle:
import React, { useState, useEffect, useCallback, **useRef** } from 'react'
const FiltersContext = React.createContext({})
function FiltersProvider({ children }) {
const [filters, setFilters] = useState({})
return (
<FiltersContext.Provider
value={{
filters,
setFilters,
}}
>
{children}
</FiltersContext.Provider>
)
}
function useFilters(setPage) {
const isInitialRender = useRef(true)
const context = React.useContext(FiltersContext)
if (context === undefined) {
throw new Error('useFilters must be used within a FiltersProvider')
}
const {
filters,
setFilters
} = context
useEffect(() => {
**isInitialRender.current = false**
return () => {
console.log('reset the filters to an empty object')
setFilters({})
}
}, [setFilters])
{... do some additional stuff with filters if needed... not relevant }
return {
...context,
filtersForQuery: { // <---- here the filtersForQuery is another variable than just filters. This I have omitted in the question. I will modify it.
**...(isInitialRender.current ? {} : filters)**
}
}
}
export { FiltersProvider, useFilters }
What is done here: set the useRef bool varialbe and set it to true, as long as it is true return always an empty object, as the first render happens and/or the setFilters function updates, set the isInitialRender.current to false. such that we return updated (not empty) filter object with the hook.

React When does rendering happen

My project use dvajs(Based on redux and redux-saga), The code below is to send a request after clicking the button, change the status through connect, and then call the ant design component message.error an message.success(Similar to alert) to remind
import type { Dispatch } from 'umi';
import ProForm, { ProFormText } from '#ant-design/pro-form';
import { message } from 'antd';
const tip = (type: string, content: string) => {
if (type === 'error') message.error(content, 5);
else message.success(content, 5);
};
const RegisterFC: React.FC<RegisterProps> = (props) => {
const { registerResponseInfo = {}, submitting, dispatch } = props;
const { status } = registerResponseInfo;
const handleSubmit = (values: RegisterParamsType) => {
dispatch({
type: 'register/register',
payload: { ...values },
});
};
return (
<div>
<ProForm
onFinish={(values) => {
handleSubmit(values as RegisterParamsType);
return Promise.resolve();
}}
>
<ProFormText/>
...
{
status === '1' && !submitting && (
tip('error',
intl.formatMessage({
id: 'pages.register.status1.message',
defaultMessage: 'error'
})
)
)
}
<<ProForm>/>
</div>
)
}
const p = ({ register, loading }: { register: RegisterResponseInfo, loading: Loading; }) => {
console.log(loading);
return {
registerResponseInfo: register,
submitting: loading.effects['register/register'],
};
};
export default connect(p)(RegisterFC);
When I click the button, the console prompts:
Warning: Render methods should be a pure function of props and state;
triggering nested component updates from render is not allowed. If
necessary, trigger nested updates in componentDidUpdate.
Doesn't the component re-render when the state changes? Does the tip function change the state?
Solution: Call tip Outside of return
tip is just a function that you are calling. You should call it outside of the return JSX section of your code. I think it makes the most sense to call it inside of a useEffect hook with dependencies on status and submitting. The effect runs each time that status or submitting changes. If status is 1 and submitting is falsy, then we call tip.
const RegisterFC: React.FC<RegisterProps> = (props) => {
const { registerResponseInfo = {}, submitting, dispatch } = props;
const { status } = registerResponseInfo;
const handleSubmit = (values: RegisterParamsType) => {
dispatch({
type: 'register/register',
payload: { ...values },
});
};
React.useEffect(() => {
if (status === '1' && !submitting) {
tip('error',
intl.formatMessage({
id: 'pages.register.status1.message',
defaultMessage: 'error'
})
);
}
}, [status, submitting]);
return (
<div>...</div>
)
}
Explanation
Render methods should be a pure function of props and state
The render section of a component (render() in class component or return in a function component) is where you create the JSX (React HTML) markup for your component based on the current values of props and state. It should not have any side effects. It creates and returns JSX and that's it.
Calling tip is a side effect since it modifies the global antd messsage object. That means it shouldn't be in the render section of the code. Side effects are generally handled inside of useEffect hooks.
You are trying to conditionally render tip like you would conditionally render a component. The problem is that tip is not a component. A function component is a function which returns a JSX Element. tip is a void function that returns nothing, so you cannot render it.

Infinite scroll updates data twice

I have a lazy loaded table (infinite scroll). Unfortunatelly, I don't know why but it updates on data twice when I scroll to the bottom. So two queries to graphql are made instead of one. If I remove data from fetchMore dependency it works fine (but then, eslint throws warning so it is not a solution). Also when I remove scroll and replace it by manual button and click for fetch also works good, so I dont know if problem is in query or maybe in WithInfiniteScroll
const LIMIT = 10;
const updateQuery = (
previousQueryResult: GetStaffQuery,
options: {
fetchMoreResult?: GetStaffQuery;
variables?: GetStaffQueryVariables;
}
): GetStaffQuery => {
const {fetchMoreResult} = options;
const currentNodes = previousQueryResult.staff.nodes || [];
const newNodes = fetchMoreResult?.staff.nodes || [];
const newResult = {
staff: {
...fetchMoreResult?.staff,
nodes: [...currentNodes, ...newNodes],
},
};
return newResult;
};
export const useUsersList = () => {
const [isInitialFetching, setIsInitialFetching] = useState(true);
const {data, fetchMore: handleFetchMore, loading} = useGetStaffQuery({
variables: {limit: LIMIT, nextToken: null},
onCompleted: () => {
setIsInitialFetching(false);
},
});
useEffect(() => {
debugger; //triggered twice when scrolled to the bottom
}, [data]);
const fetchMore = useCallback(() => {
const nextToken = data?.staff.nextToken || null;
if (nextToken && !loading && !isInitialFetching) {
const queryVariables: GetStaffQueryVariables = {
limit: LIMIT,
nextToken,
};
handleFetchMore({variables: queryVariables, updateQuery});
}
}, [data, handleFetchMore, isInitialFetching, loading]);
return {
isLoading: loading,
canLoadMore: Boolean(data?.staff.nextToken && !loading) || false,
fetchMore,
users: data?.staff.nodes || [],
};
};
Infinite scroll:
import React, {useEffect, ReactNode} from 'react';
import {useInView} from 'react-intersection-observer';
type PropTypes = {
children?: ReactNode;
canLoadMore: boolean;
onLoadMore: () => unknown;
};
const rootMargin = '400px';
export const WithInfiniteScroll = ({
children,
canLoadMore,
onLoadMore,
}: PropTypes) => {
const [ref, isElementInViewport] = useInView({
rootMargin,
skip: !canLoadMore,
});
useEffect(() => {
if (canLoadMore && isElementInViewport) {
onLoadMore();
}
}, [canLoadMore, isElementInViewport, onLoadMore]);
return (
<>
{children}
<div ref={ref} />
</>
);
};
and some draft of component:
<WithInfiniteScroll canLoadMore={canLoadMore} onLoadMore={onLoadMore}>
<div>
{users.map(user => <span>{user.id}</span>)}
</div>
</WithInfiniteScroll>
I had the same issue just now! In my case the problem was that in my onLoad method I forgot "await" to fetch the data with my async service method.. So the data would not have arrived already when done() was executed (and therefore a subsequent get data call to the server)

Resources