useEffect to rerender this component

Viewed 93

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--
    }
  },
});
2 Answers

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:

  1. 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.

  1. 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.

  2. 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.

  3. 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