inflearn logo
강의

강의

N
챌린지

챌린지

멘토링

멘토링

N
클립

클립

로드맵

로드맵

지식공유

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

하트 누를 때 optimistic update 적용하기

추천탭 게시글작성 오류

319

Jinhye Kim

작성한 질문수 3

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>
  )
}

react next.js react-query next-auth msw

답변 3

0

제로초(조현영)

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

1

제로초(조현영)

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

1

Jinhye Kim

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

0

제로초(조현영)

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

0

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 인턴

안녕하세요, 인프런 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의 공식 문서를 참고하거나, 구체적인 오류 메시지나 행동을 포함하여 질문을 구체화해 주시면 더 도움을 드리기 쉬울 것 같습니다.

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

캡처링부분 질문있습니다.

0

74

2

깃에 소스코드를 찾을 수 없습니다.

0

113

2

useInfiniteQuery promise와 react use 사용시 페이지 무한 로딩

0

98

1

import 파일 경로를 찾지 못 해서 에러가 발생합니다.

0

109

2

css 라이브러리 추천 부탁드립니다

0

140

2

팔로우 추천 목록이 빈 배열로 들어옵니다.

0

130

1

게시물 업로드 시 userId가 undefined로 들어가는 오류

0

119

1

useSuspenseQuery 사용 시 SSR 401 이슈 관련

0

171

1

리액트 쿼리 useinfinitequery 무한스크롤 구현 시 페이지가 이동할 경우 데이터가 보존되게 할 수 있나요??

0

184

3

폴링이 필요없는 이유

0

93

2

next Request Memoization과 react cache

0

108

2

seo 최적화 기준은 데이터 fetching인가요 아님 데이터 렌더링인가요?

0

84

2

next.js 서버fetch 에러 fallback ui 구현 방법

0

173

2

프레임워크 여론 파악법

0

125

2

필터옵션이 많은 페이지에서 서버 fetch를 하는게 맞는걸까요??

0

103

2

서버 fetch suspense 로 감싸고 새로고침 시 잠시 빈 화면이 노출된 후 fallback ui가 노출됩니다.

0

102

2

template.tsx 내 서버fetch 응답값과 클라이언트 컴포넌트 상태값 싱크가 맞지 않는 이슈

0

66

2

Auth.js 사용 시 authorize 함수가 호출되지 않습니다

0

131

2

next.js 에서 로그인에 관하여

0

138

1

Next의 route handler에 대한 질문이 있습니다.

0

101

2

게시판 리스트 만들때 use client를 어디서부터 집어넣어야할지 모르겠습니다

0

97

2

프라이빗 폴더를 해야 하는 이유가 모호한 것 같아요.

0

85

2

vanilla-extract 못찾는 문제

0

229

2

fetch 캐싱과 revalidate 관련

0

84

2