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
Related
so i have 2 redux state and selectors that is working well. but i want to call the second selector (get the detail list based on category.id) inside map() loop. how can i do that?
const Dashboard = () => {
const [data, setData] = useState([]);
const categories = useSelector(viewFinalCategories);
// categories is loaded well
const createFinalData = () => {
const finalData = categories.map((category) => {
return {
title: category.label,
category: category,
data: useSelector(viewInventoriesByCategory(category.id)), // <- error hook cannot called here..
};
});
setData(finalData);
};
useEffect(() => {
createFinalData();
}, [categories]);
return (
// SectionList of RN here...
)
Since it violates the hook rule, you can't call useSelector inside a function.
the solution is to get the data in the component level and do the filtering inside the function
const {inventories} = useSelector(state => state)
const createFinalData = () => {
const finalData = categories.map((category) => {
return {
title: category.label,
category: category,
data: inventories.filter((item) => item.idCategory === idCategory)
};
});
setData(finalData);
};
useSelector is a hook and it has to follow hook rules, one of them is it can't be used inside a loop. Instaed, you need move all this logic from component to yet another selector. I would use createSelector from reselect in this case since you can combine selecting categories into newly created selectFinalData:
import { createSelector } from 'reselect'
const selectFinalData = () =>
createSelector(
viewFinalCategories,
(state, categories) => categories.map((category) => ({
title: category.label,
category: category,
data: viewInventoriesByCategory(category.id)),
})
)
)
and use it in component :
const finalData = useSelector(selectFinalData())
setData(finalData);
Here, are three components User Details and its two Childs are UserSpecificData1 and UserSpecificData2.
In User Details component im getting User Details with userId by api calling.
Now i declared Two childs by passing that user id.
Problem is: Two child api is calling two times! Why? React strict mode is off.
Note: I noticed that child components are rendering two times by console.log
`
export const UserDetails = () => {
const params = useParams(); // {userId: 223}
useEffect(() => {
if(params?.userId){
getCustomerDetails(params.userId) // 223
}
}, [params.userId]);
return (
<div>
<UserSpecificData1 userId={params.userId}/>
<UserSpecificData2 userId={params.userId}/>
</div>
);
};
// Component 1
const UserSpecificData1 = props => {
const [currentPage, setCurrentPage] = useState(0);
const [filteredBy, setFilteredBy] = useState({});
const [sortBy, setSortBy] = useState('ASC');
useEffect(() => {
getSpecificDataOne({
id: props.userId, //223
filteredBy: filteredBy,
page: currentPage,
size: 10,
sortBy: sortBy,
})
}, [sortBy, currentPage, filteredBy]);
return <div>
</div>
};
// Component 2
const UserSpecificData2 = props => {
const [currentPage, setCurrentPage] = useState(0);
const [filteredBy, setFilteredBy] = useState({});
const [sortBy, setSortBy] = useState('ASC');
useEffect(() => {
getSpecificDataTwo({
id: props.userId, //223
filteredBy: filteredBy,
page: currentPage,
size: 10,
sortBy: sortBy,
})
}, [sortBy, currentPage, filteredBy]);
return <div>
</div>
};
`
Hey i just reviewed your code and i came up with conclusion that you have to add a condition on both child useEffect where api is being called and check for prop.userId exist or not and don't forgot to passed it as dependency array.
useEffect(()=>{
if(props?.userId){
getSpecificDataTwo({
id: props.userId, //223
filteredBy: filteredBy,
page: currentPage,
size: 10,
sortBy: sortBy,
});
}
},[sortBy, currentPage, filteredBy,props.userId]);
let me know if this works for you otherwise we will go for another way.
My guess is that the code isn't quite complete?
So I'm assuming you also have a [content, setContent] somewhere in the first component UserDetails - and if so, it'll first render the child components, and then, if params.userId exists, after the content has loaded it'll re-render.
A couple of ways to stop this, probably the best being surrounding your child components with { content && <Child 1 />...}
So complete code would be:
export const UserDetails = () => {
const params = useParams(); // {userId: 223}
const [content, setContent] = useState(null)
useEffect(() => {
if(params?.userId){
getCustomerDetails(params.userId)
.then(result => {
setContent(result);
}) // 223
}
}, [params.userId]);
return (
<div>
{ content &&
<>
<UserSpecificData1 userId={params.userId}/>
<UserSpecificData2 userId={params.userId}/>
</>
}
</div>
);
};
Personally I'd probably also put the userId into a hook and use that as the check, up to you which works better.
Suppose I have a list of items I would like to render and select (like a Todo app).
I'd like to keep the selection logic inside custom react hook and have items live somewhere else in local state.
Now, I would like to update the selection list, kept in the custom hook, whenever I fetch some more items. For this task I am passing data as parameter to selection hook and I am using useEffect to update the selection:
import { useEffect, useState } from "react";
const itemsArrayToObject = (items) =>
Object.fromEntries(items.map((i) => [i.id, { ...i, selected: false }]));
export function useSelection({ data }) {
const [selection, setSelection] = useState(itemsArrayToObject(data));
useEffect(() => {
setSelection((selection) => {
return {
...itemsArrayToObject(data),
...selection
};
});
}, [data]);
const isSelected = (itemId) => selection?.[itemId]?.selected ?? false;
const toggle = (itemId) => {
setSelection((s) => {
const item = s[itemId];
return {
...s,
[itemId]: {
...item,
selected: !item.selected
}
};
});
};
return {
isSelected,
toggle
};
}
This almost works but the problem is if I want to synchronize two things: fetching data and toggling items. Eg.
const onLoadAndToggle = async () => {
await load();
toggle(0);
};
load is a async function that fetches the data. It also triggers state update so that data is updated and the selection can be updated inside useSelection hook.
Example how it all can work:
const [data, setData] = useState([]);
const addItems = (items) => {
setData((state) => [...state, ...items]);
};
const { load } = useFetch({ addItems });
const { isSelected, toggle } = useSelection({ data });
const onLoadAndToggle = async () => {
await load();
toggle(0);
};
Now, the problem is that when calling toggle(0) my custom hook has a stale selection, even when using setState(state => ... singature.
It is because the whole fetching and updating data in state takes too long.
I can see some ugly ways to solve that problem but I wonder what would be the elegant or idiomatic react way to solve that.
I have made a code sandbox, if it helps: https://codesandbox.io/s/selection-fetch-forked-nyl0kt?file=/src/App.js:376-512
Try clicking "Load and toggle first" first to see how the app crashed because the selection is not yet updated.
What you need is to initialize toogled items from the code itself. We can do this by providing the id's of the items that we want to toggle to the hook itself.
Updated hook -
const itemsArrayToObject = (items, itemsToggled) => {
if (Array.isArray(itemsToggled)) {
return Object.fromEntries(
items.map((i) => [i.id, { ...i, selected: itemsToggled.includes(i.id) }])
);
}
return Object.fromEntries(
items.map((i) => [i.id, { ...i, selected: false }])
);
};
export function useSelection({ data }, itemsToggled) {
const [selection, setSelection] = useState(
itemsArrayToObject(data, itemsToggled)
);
useEffect(() => {
setSelection((selection) => {
return {
...itemsArrayToObject(data, itemsToggled),
...selection
};
});
}, [data, itemsToggled]);
Now call to hook becomes -
const { isSelected, toggle } = useSelection({ data }, [0, 1]);
Updated codesandbox
This also decouples loading data & toggling of an item initially.
I'm quite new to React and I don't always understand when I have to use hooks and when I don't need them.
What I understand is that you can get/set a state by using
const [myState, setMyState] = React.useState(myStateValue);
So. My component runs some functions based on the url prop :
const playlist = new PlaylistObj();
React.useEffect(() => {
playlist.loadUrl(props.url).then(function(){
console.log("LOADED!");
})
}, [props.url]);
Inside my PlaylistObj class, I have an async function loadUrl(url) that
sets the apiLoading property of the playlist to true
gets content
sets the apiLoading property of the playlist to false
Now, I want to use that value in my React component, so I can set its classes (i'm using classnames) :
<div
className={classNames({
'api-loading': playlist.apiLoading
})}
>
But it doesn't work; the class is not updated, even if i DO get the "LOADED!" message in the console.
It seems that the playlist object is not "watched" by React. Maybe I should use react state here, but how ?
I tested
const [playlist, setPlaylist] = React.useState(new PlaylistObj());
React.useEffect(() => {
//refresh playlist if its URL is updated
playlist.loadUrl(props.playlistUrl).then(function(){
console.log("LOADED!");
})
}, [props.playlistUrl]);
And this, but it seems more and more unlogical to me, and, well, does not work.
const [playlist, setPlaylist] = React.useState(new PlaylistObj());
React.useEffect(() => {
playlist.loadUrl(props.playlistUrl).then(function(){
console.log("LOADED!");
setPlaylist(playlist); //added this
})
}, [props.playlistUrl]);
I just want my component be up-to-date with the playlist object. How should I handle this ?
I feel like I'm missing something.
Thanks a lot!
I think you are close, but basically this issue is you are not actually updating a state reference to trigger another rerender with the correct loading value.
const [playlist, setPlaylist] = React.useState(new PlaylistObj());
React.useEffect(() => {
playlist.loadUrl(props.playlistUrl).then(function(){
setPlaylist(playlist); // <-- this playlist reference doesn't change
})
}, [props.playlistUrl]);
I think you should introduce a second isLoading state to your component. When the effect is triggered whtn the URL updates, start by setting loading true, and when the Promise resolves update it back to false.
const [playlist] = React.useState(new PlaylistObj());
const [isloading, setIsLoading] = React.useState(false);
React.useEffect(() => {
setIsLoading(true);
playlist.loadUrl(props.playlistUrl).then(function(){
console.log("LOADED!");
setIsLoading(false);
});
}, [props.playlistUrl]);
Use the isLoading state in the render
<div
className={classNames({
'api-loading': isLoading,
})}
>
I also suggest using the finally block of a Promise chain to end the loading in the case that the Promise is rejected your UI doesn't get stuck in the loading "state".
React.useEffect(() => {
setIsLoading(true);
playlist.loadUrl(props.playlistUrl)
.then(function() {
console.log("LOADED!");
})
.finally(() => setIsLoading(false));
}, [props.playlistUrl]);
Here you go:
import React from "react";
class PlaylistAPI {
constructor(data = []) {
this.data = data;
this.listeners = [];
}
addListener(fn) {
this.listeners.push(fn);
}
removeEventListener(fn) {
this.listeners = this.listeners.filter(prevFn => prevFn !== fn)
}
setPlayList(data) {
this.data = data;
this.notif();
}
loadUrl(url) {
console.log("called loadUrl", url, this.data)
}
notif() {
this.listeners.forEach(fn => fn());
}
}
export default function App() {
const API = React.useMemo(() => new PlaylistAPI(), []);
React.useEffect(() => {
API.addListener(loadPlaylist);
/**
* Update your playlist and when user job has done, listerners will be called
*/
setTimeout(() => {
API.setPlayList([1,2,3])
}, 3000)
return () => {
API.removeEventListener(loadPlaylist);
}
}, [API])
function loadPlaylist() {
API.loadUrl("my url");
}
return (
<div className="App">
<h1>Watching an object by React Hooks</h1>
</div>
);
}
Demo in Codesandbox
I have a component where I get repositories like below:
useEffect(() => {
loadRepositories();
}, []);
const loadRepositories = async () => {
const response = await fetch('https://api.github.com/users/mcand/repos');
const data = await response.json();
setUserRepositories(data);
};
return (
<Repository repositories={userRepositories} />
)
The child component only receives the repositories and prints them. It's like that:
const Repository = ({ repositories }) => {
console.log('repository rendering');
console.log(repositories);
const [repos, setRepos] = useState(repositories);
useEffect(() => {
setRepos(repositories);
}, [ ]);
const getRepos = () => {
repos.map((rep, idx) => {
return <div key={idx}>{rep.name}</div>;
});
};
return (
<>
<RepositoryContainer>
<h2>Repos</h2>
{getRepos()}
</>
);
};
export default Repository;
The problem is that, nothing is being displayed. In the console.log I can see that there're repositories, but it seems like the component cannot update itself, I don't know why. Maybe I'm missing something in this useEffect.
Since you're putting the repository data from props into state, then then rendering based on state, when the props change, the state of a Repository doesn't change, so no change is rendered.
The repository data is completely handled by the parent element, so the child shouldn't use any state - just use the prop from the parent. You also need to return from the getRepos function.
const Repository = ({ repositories }) => {
const getRepos = () => {
return repositories.map((rep, idx) => {
return <div key={idx}>{rep.name}</div>;
});
};
return (
<>
<RepositoryContainer>
<h2>Repos</h2>
{getRepos()}
</>
);
};
export default Repository;
You can also simplify
useEffect(() => {
loadRepositories();
}, []);
to
useEffect(loadRepositories, []);
It's not a bug yet, but it could be pretty misleading - your child component is named Repository, yet it renders multiple repositories. It might be less prone to cause confusion if you named it Repositories instead, or have the parent do the .map instead (so that a Repository actually corresponds to one repository array item object).
Change this
useEffect(() => {
setRepos(repositories);
}, [ ]);
to
useEffect(() => {
setRepos(repositories);
}, [repositories]);