인프런 커뮤니티 질문&답변

Jinhye Kim님의 프로필 이미지
Jinhye Kim

작성한 질문수

Next + React Query로 SNS 서비스 만들기

하트 누를 때 optimistic update 적용하기

추천탭 게시글작성 오류

작성

·

230

0

추천탭에서 게시글을 작성하면 새로고침 시 사라져버리고
추천탭에서 게시한 게시글이 "팔로우 중" 탭에서 존재하여
좋아요 버튼을 클릭하면 새로고침 시 다시 추천탭에도 해당 게시글이 나타납니다.
도무지 뭐가문제인지 모르겠네요ㅠㅠ

"use client"
import { MouseEventHandler } from 'react';
import style from './post.module.css';
import cx from 'classnames';
import { useMutation, useQueryClient } from '@tanstack/react-query'; 
import { Post } from '@/model/Post';

type Props = {
  white? : boolean;
  postId: number;
}

export default function ActionButtons({white, postId}: Props) {
  const queryClient = useQueryClient();
  const commented = true;
  const reposted = true;
  const liked = false;
  const heart = useMutation({
    mutationFn: () => {
      return fetch(`${process.env.NEXT_PUBLIC_BASE_URL}/api/posts/${postId}/heart`, {
        method: 'post',
        credentials: 'include',
      })
    },
    // 클릭한 하트 상태를 실시간으로 true로 만들어줌
    // post에서 검색결과,추천,팔로잉,답글 등등 쿼리키가 다양한대 어떤 쿼리키인지 무슨상황인지 알 수가 없다.
    // 그래서 전부 다 해줘야 한다.
    onMutate() {
      const queryCache = queryClient.getQueryCache();
      const queryKeys = queryCache.getAll().map(cache => cache.queryKey);
      console.log('queryKeys',queryKeys);
      queryKeys.forEach((querykey) => {
        if(querykey[0] === 'posts') {
          const value:Post | Post[] | undefined = queryClient.getQueryData(querykey); // 게시글
          console.log(value)
          // 싱글포스트 일 수도 있기때문에 조건문 걸어줌.
          if(Array.isArray(value)){
            const index = value.findIndex((v) => postId == v.postId);
            
            // 찾고자 하는 게시글이 있는지 확인
            if(index > -1) {
              const shallow = [...value];

              shallow[index] = {
                ...shallow[index],
              }
              // 옅은 복사해준것을 쿼리에 전송
              queryClient.setQueryData(querykey, shallow);
            }
          }else {
            // 싱글 포스트인 경우
          }
        }
      });
    },
    onError() {

    },
    onSettled() {

    }
  })

  // 댓글
  const onClickComment = () => {}

  // 리트윗
  const onClickRepost = () => {
    
  }

  // 좋아요
  const onClickHeart:MouseEventHandler<HTMLButtonElement> = (e) => {
    e.stopPropagation();
    if(liked){
      // unheart.mutate();
    }else{
      heart.mutate();
    }
  }

  return (
    <div className={style.actionButtons}>
      <div className={cx(style.commentButton, { [style.commented]: commented }, white && style.white)}>
        <button onClick={onClickComment}>
          <svg width={24} viewBox="0 0 24 24" aria-hidden="true">
            <g>
              <path
                d="M1.751 10c0-4.42 3.584-8 8.005-8h4.366c4.49 0 8.129 3.64 8.129 8.13 0 2.96-1.607 5.68-4.196 7.11l-8.054 4.46v-3.69h-.067c-4.49.1-8.183-3.51-8.183-8.01zm8.005-6c-3.317 0-6.005 2.69-6.005 6 0 3.37 2.77 6.08 6.138 6.01l.351-.01h1.761v2.3l5.087-2.81c1.951-1.08 3.163-3.13 3.163-5.36 0-3.39-2.744-6.13-6.129-6.13H9.756z"></path>
            </g>
          </svg>
        </button>
        <div className={style.count}>{1 || ''}</div>
      </div>
      <div className={cx(style.repostButton, reposted && style.reposted, white && style.white)}>
        <button onClick={onClickRepost}>
          <svg width={24} viewBox="0 0 24 24" aria-hidden="true">
            <g>
              <path
                d="M4.5 3.88l4.432 4.14-1.364 1.46L5.5 7.55V16c0 1.1.896 2 2 2H13v2H7.5c-2.209 0-4-1.79-4-4V7.55L1.432 9.48.068 8.02 4.5 3.88zM16.5 6H11V4h5.5c2.209 0 4 1.79 4 4v8.45l2.068-1.93 1.364 1.46-4.432 4.14-4.432-4.14 1.364-1.46 2.068 1.93V8c0-1.1-.896-2-2-2z"></path>
            </g>
          </svg>
        </button>
        <div className={style.count}>{1 || ''}</div>
      </div>
      <div className={cx([style.heartButton, liked && style.liked, white && style.white])}>
        <button onClick={onClickHeart}>
          <svg width={24} viewBox="0 0 24 24" aria-hidden="true">
            <g>
              <path
                d="M16.697 5.5c-1.222-.06-2.679.51-3.89 2.16l-.805 1.09-.806-1.09C9.984 6.01 8.526 5.44 7.304 5.5c-1.243.07-2.349.78-2.91 1.91-.552 1.12-.633 2.78.479 4.82 1.074 1.97 3.257 4.27 7.129 6.61 3.87-2.34 6.052-4.64 7.126-6.61 1.111-2.04 1.03-3.7.477-4.82-.561-1.13-1.666-1.84-2.908-1.91zm4.187 7.69c-1.351 2.48-4.001 5.12-8.379 7.67l-.503.3-.504-.3c-4.379-2.55-7.029-5.19-8.382-7.67-1.36-2.5-1.41-4.86-.514-6.67.887-1.79 2.647-2.91 4.601-3.01 1.651-.09 3.368.56 4.798 2.01 1.429-1.45 3.146-2.1 4.796-2.01 1.954.1 3.714 1.22 4.601 3.01.896 1.81.846 4.17-.514 6.67z"></path>
            </g>
          </svg>
        </button>
        <div className={style.count}>{0 || ''}</div>
      </div>
    </div>
  )
}

답변 3

0

제로초(조현영)님의 프로필 이미지
제로초(조현영)
지식공유자

아, 지금 생각하니 그 동작이 맞습니다. 트위터 따라한 건데요. 원래 게시글 작성하면 추천 탭에 뜨는데, 새로고침하면 사라집니다. 그 이유가 추천 탭은 인기글 목록이 뜨는 거라 자기가 방금 작성한 글은 인기글이 아니라 사라지는 겁니다. 팔로우 탭은 자기 게시글 포함해서 최신으로 보여주는거라 새로고침 후에도 거기에 있는거고요.

제로초(조현영)님의 프로필 이미지
제로초(조현영)
지식공유자

좋아요 누르면 다시 나온다는 건, 아직 다른 게시글들 좋아요 수가 부족해서 좋아요 1개로도 추천글 최상단으로 올라오나봅니다.

Jinhye Kim님의 프로필 이미지
Jinhye Kim
질문자

아하 그런것이였군요 학업하고 병행하다보니 연속해서 강의를 들을시간이 없어서 저도 깜빡하고 있었나봅니다. ㅋㅋㅋㅋ 오늘도 정성스러운답변 너무감사합니다 좋은하루보내세요~~~!

0

제로초(조현영)님의 프로필 이미지
제로초(조현영)
지식공유자

게시글 작성 코드부터 봐야겠습니다. 혹시 리액트쿼리 데브툴 설치하셨나요? 거기서 게시글 작성 시 추천글 리스트 쿼리 데이터 업데이트되는지 확인해보셔야 합니다.

Jinhye Kim님의 프로필 이미지
Jinhye Kim
질문자

"use client"

import {ChangeEventHandler, FormEvent, FormEventHandler, useRef, useState} from "react";
import style from './postForm.module.css';
import {Session} from "@auth/core/types";
import TextareaAutosize from 'react-textarea-autosize';
import {useMutation, useQueryClient} from "@tanstack/react-query";
import {Post} from "@/model/Post";

type Props = {
  me: Session | null
}
export default function PostForm({ me }: Props) {
  const imageRef = useRef<HTMLInputElement>(null);
  const [preview, setPreview] = useState<Array<{ dataUrl: string, file: File } | null>>([]);
  const [content, setContent] = useState('');
  const queryClient = useQueryClient();

  const mutation = useMutation({
    mutationFn: async (e: FormEvent) => {
      e.preventDefault();
      const formData = new FormData();
      formData.append('content', content);
      preview.forEach((p) => {
        p && formData.append('images', p.file);
      })
      return fetch(`${process.env.NEXT_PUBLIC_BASE_URL}/api/posts`, {
        method: 'post',
        credentials: 'include',
        body: formData,
      });
    },
    async onSuccess(response, variable) {
      const newPost = await response.json();
      setContent('');
      setPreview([]);
      if (queryClient.getQueryData(['posts', 'recommends'])) {
        queryClient.setQueryData(['posts', 'recommends'], (prevData: { pages: Post[][] }) => {
          const shallow = {
            ...prevData,
            pages: [...prevData.pages],
          };
          shallow.pages[0] = [...shallow.pages[0]];
          shallow.pages[0].unshift(newPost);
          return shallow;
        });
      }
      if (queryClient.getQueryData(['posts', 'followings'])) {
        queryClient.setQueryData(['posts', 'followings'], (prevData: { pages: Post[][] }) => {
          const shallow = {
            ...prevData,
            pages: [...prevData.pages],
          };
          shallow.pages[0] = [...shallow.pages[0]];
          shallow.pages[0].unshift(newPost);
          return shallow;
        })
      }
    },
    onError(error) {
      console.error(error);
      alert('업로드 중 에러가 발생했습니다.');
    }
  })

  const onChange: ChangeEventHandler<HTMLTextAreaElement> = (e) => {
    setContent(e.target.value);
  }

  const onClickButton = () => {
    imageRef.current?.click();
  }

  const onRemoveImage = (index: number) => () => {
    setPreview((prevPreview) => {
      const prev = [...prevPreview];
      prev[index] = null;
      return prev;
    })
  };

  const onUpload: ChangeEventHandler<HTMLInputElement> = (e) => {
    e.preventDefault();
    if (e.target.files) {
      Array.from(e.target.files).forEach((file, index) => {
        const reader = new FileReader();
        reader.onloadend = () => {
          setPreview((prevPreview) => {
            const prev = [...prevPreview];
            prev[index] = {
              dataUrl: reader.result as string,
              file,
            };
            return prev;
          })
        };
        reader.readAsDataURL(file);
      });
    }
  };

  return (
    <form className={style.postForm} onSubmit={mutation.mutate}>
      <div className={style.postUserSection}>
        <div className={style.postUserImage}>
          <img src={me?.user?.image as string} alt={me?.user?.email as string} />
        </div>
      </div>
      <div className={style.postInputSection}>
        <TextareaAutosize value={content} onChange={onChange} placeholder="무슨 일이 일어나고 있나요?"/>
        <div style={{ display: 'flex' }}>
          {preview.map((v, index) => (
            v && (<div key={index} style={{ flex: 1 }} onClick={onRemoveImage(index)}>
              <img src={v.dataUrl} alt="미리보기" style={{ width: '100%', objectFit: 'contain', maxHeight: 100 }} />
            </div>)
          ))}
        </div>
        <div className={style.postButtonSection}>
          <div className={style.footerButtons}>
            <div className={style.footerButtonLeft}>
              <input type="file" name="imageFiles" multiple hidden ref={imageRef} onChange={onUpload} />
              <button className={style.uploadButton} type="button" onClick={onClickButton}>
                <svg width={24} viewBox="0 0 24 24" aria-hidden="true">
                  <g>
                    <path
                      d="M3 5.5C3 4.119 4.119 3 5.5 3h13C19.881 3 21 4.119 21 5.5v13c0 1.381-1.119 2.5-2.5 2.5h-13C4.119 21 3 19.881 3 18.5v-13zM5.5 5c-.276 0-.5.224-.5.5v9.086l3-3 3 3 5-5 3 3V5.5c0-.276-.224-.5-.5-.5h-13zM19 15.414l-3-3-5 5-3-3-3 3V18.5c0 .276.224.5.5.5h13c.276 0 .5-.224.5-.5v-3.086zM9.75 7C8.784 7 8 7.784 8 8.75s.784 1.75 1.75 1.75 1.75-.784 1.75-1.75S10.716 7 9.75 7z"></path>
                  </g>
                </svg>
              </button>
            </div>
            <button className={style.actionButton} disabled={!content}>게시하기</button>
          </div>
        </div>
      </div>
    </form>
  )
}

위 코드는 PostForm.tsx 코드입니다.스크린샷 2024-04-05 오후 10.10.22.png스크린샷 2024-04-05 오후 10.10.32.png그리고 데이터를 살펴봐도 생성 시 에는 recommends에 게시글이 있는데 새로고침하니 귀신같이 사라지네요

0

안녕하세요, 인프런 AI 인턴입니다.

Jinhye Kim님의 질문에 대해 직접적으로 일치하는 예시는 찾을 수 없었습니다. 하지만, 여러분이 겪고 계신 문제와 비슷한 상황을 다룬 예시들을 기반으로 가능한 원인과 해결 방향에 대해 안내드리겠습니다.

  1. React Query의 useMutation 사용법:

    • useMutation은 서버로 데이터를 보내는 (예: 생성, 업데이트, 삭제 등) 사이드 이펙트를 수행할 때 사용됩니다. 여러분의 코드에서는 useMutation을 사용하여 게시글에 좋아요를 표시하는 API를 호출하고 있습니다. 이 과정에서 실제로 데이터가 올바르게 업데이트되고 있는지, 그리고 백엔드에서 적절한 응답을 보내고 있는지 확인이 필요합니다.
  2. 쿼리 캐시와 상태 업데이트:

    • onMutate 함수 내에서 여러분은 모든 쿼리 키를 가져와서 특정 조건('posts')에 맞는 쿼리 데이터를 직접 수정하고 있습니다. 이 과정에서 각 쿼리 키에 해당하는 데이터가 올바르게 업데이트 되고 있는지, 그리고 해당 데이터가 UI에 즉시 반영되도록 queryClient.setQueryData를 이용해 업데이트하고 있는지 점검해야 합니다.
  3. 환경 변수 관리:

    • mutationFn에서 ${process.env.NEXT_PUBLIC_BASE_URL}/api/posts/${postId}/heart API 주소를 사용하여 요청을 보내고 있습니다. 이 때, .env 파일이나 환경 설정에 NEXT_PUBLIC_BASE_URL이 올바르게 설정되어 있고, 이 URL이 실제로 API 서버를 가리키고 있는지 확인해 주세요.
  4. 서버와의 통신 확인:

    • mutationFn으로 데이터를 보내고 받는 과정에서 네트워크 요청이 성공적으로 이루어지고 있는지, 개발자 도구의 Network 탭을 통해 검토해 보세요. 네트워크 요청이 실패하거나 예상치 못한 응답을 받고 있다면, 문제 해결의 실마리를 제공할 수 있습니다.

이와 관련된 더 자세한 도움이 필요할 경우, React Query의 공식 문서를 참고하거나, 구체적인 오류 메시지나 행동을 포함하여 질문을 구체화해 주시면 더 도움을 드리기 쉬울 것 같습니다.

저는 질문자님의 이해를 돕고자 지식공유자님의 기존 답변을 참고하여 유사한 답변 링크를 추천해드리고 있습니다.
현재 베타 기능으로 답변이 만족스럽지 않을 수 있는 점 양해 부탁드립니다. 🙏
추가적으로 궁금한 점이 있으시면, 이어서 질문해 주세요. 곧 지식공유자께서 답변해 주실 것입니다.

Jinhye Kim님의 프로필 이미지
Jinhye Kim

작성한 질문수

질문하기