진행중!
react-query의 기능 개선을 담당하는 브랜치
해당 이슈
개념 정리
server state는 server에서 받아오는 state를 얘기하는게 아니라 얘를 받아서 스토어에 넣은, 리액트 앱 내에서 관리되는 state를 이야기한다. 리액트쿼리에서는 스토어 부분이 훅 안에 들어가있어서 잘 안보이지만 쿼리키나 쿼리 클라이언트로 내부에서 작동하고 있음을 알 수 있다.
따라서 server state도 react state고 useMutation만 state를 바꿀 수 있는게 아니다. mutation은 server로 보내는 state를 바꾸는거고 리액트 내의 state는 query key = store에서 관리될 수 있다.
query key는 배열 안에 담긴 문자열 형태로만 사용이 된다.(버전4에서 규칙으로 고정됨)
이 키는 react-query 안에서 직렬화를 거쳐서 1개의 번들링된거 같은 형태가 된다. 그리고 키 : 값의 형태로 데이터를 담아두었다가, 다시 뿌려주고, 수정해주고 하는 역할을 한다. 리덕스를 왜 뜯어보라고 하셨는지 알겠다.
query key는 key <-- 이다. 오타가 나면 안되고 계속 쓰인다. 따라서 action 지정해줄때처럼 상수로 만들어서 따로 관리하는 것이 좋다.
객체에 상수를 담는 법 : as const!! 이렇게 하면 진짜 상태트리처럼 하나로 묶을 수 있다!
const Keys = {
all = ['todos'] as const,
detail: (id: number) => [...todoKeys.details(), id] as const
}
print : all ==> ['todos']
모든 useQuery에서 onError를 처리할 필요는 없다. 공통적인 부분은 빼서 에러바운더리에서 처리하고 뭔가 추가하고 싶은거에 추가해주기
onError에서 하고 싶은 일 : 백엔드에서 보내온 에러메시지를 띄워주고 navigate를 쓰게하기!
리셋 함수에다가 공통적인걸 넣어주고 QueryErrorResetBoundary 레이어를 위에 덮어쓰는 방식으로 사용할 수 있다. 이 안에는 에러 바운더리를 넣어야 하는데 이건 리액트쿼리에서 제공하는게 아닌, 리액트에서 제공하는 클래스형 컴포넌트의 예시이다.
함수형으로 만들기 : https://gist.github.com/andywer/800f3f25ce3698e8f8b5f1e79fed5c9c
useErrorBoundary는 index.tsx에서 쿼리클라이언트 생성할때 캐시에다가 걸어준 온에러에 이 쿼리의 에러들을 위임하는 훅이다.
일단은 react-error-boundary를 써서 빌드한 후, 시간이 남으면 자체제작하는 방향으로 가기로 했다.
export function getToDos() {
return useQuery(
["todos"],
() => ToDosAPI.getToDos().then((response) => response.data),
{
useErrorBoundary: (error: AxiosError) =>
error instanceof AxiosError &&
error.response?.status !== undefined &&
error.response.status >= 500,
}
);
}
const Keys = {
all: ["todos"] as const,
details: () => [...Keys.all, "detail"] as const,
detail: (id: string) => [...Keys.details(), id] as const,
};
export function useGetToDos() {
return useQuery(
Keys.all, <--- 이렇게 넣어준다!
() => ToDosAPI.getToDos().then((response) => response.data),
{
onSuccess: () => {
console.log("로딩 완료");
},
useErrorBoundary: (error: AxiosError) =>
error instanceof AxiosError &&
error.response?.status !== undefined &&
error.response.status >= 500,
}
);
}
useGetToDos()
useGetToDoById(id)
useCreateTodo()
useUpdateToDo(id)
useDeleteToDo(id)
export function getToDoById() {
const navigate = useNavigate();
const queryClient = useQueryClient();
return useMutation((id: string) => ToDosAPI.getToDoById(id), {
onSuccess: () => queryClient.invalidateQueries(["todo"]),
onError: (error: AxiosError) => {
if (error !== undefined && error instanceof AxiosError) {
alert(Object.values(error?.response?.data)[0]);
navigate("/todo");
}
},
});
}
// store.ts
export const toDoDetail = atom<ToDoDetail | null>({
key: "toDoDetail",
default: null,
}); <--- 여기서 리코일을 해놓고
// card.tsx
<el.Button isSmall={true} onClick={() => navigate(`/todo/${data.id}`)}>
상세
</el.Button> <--- 여기서 주소만 변경
// detail.tsx
const location = useLocation();
const id = location.pathname.split("/")[2];
const [detail, setDetail] = useRecoilState(toDoDetail); <-- 받아와서 리코일에 넣어줌
useEffect(() => {
id !== undefined && <-- 여기서 주소창에 아이디값이 있을때만 돌리기
mutateAsync(id).then((response) => setDetail(response.data));
}, [location]);
// card.tsx
const [cleanData, setCleanData] = useRecoilState(toDoDetail); <- 이름만 바꾼 같은 데이터
const deleteHandler = () => {
console.log("삭제완료");
deleteById.mutateAsync();
setCleanData(null); <-- 카드에서 삭제를 하면 그냥 얘를 밀었다.
};
리액트쿼리의 특성을 완벽하게 무시하고 돌아가는 코드였다.
다행히 강의를 잘 듣고 리액트쿼리에 대한 관점을 다시 잡을 수 있었다.
리액트쿼리는 조건이 충족되는 한 자동으로 계속 돌아간다. "이 비동기 요청은 state가 x인 경우의 결과물이다" 라고 생각하면서 state를 변경해서 리액트쿼리가 따라오게 만들어주자.
const useParams = new URLSearchParams();
const queryClient = new QueryClient({
defaultOptions: {
queries: {
suspense: true,
staleTime: 1000 * 60,
},
},
queryCache: new QueryCache({
onError: (error) => {
if (error !== undefined && error instanceof AxiosError) {
console.log(Object.values(error?.response?.data)[0]);
useParams.delete("id");
}
},
}),
});
//card.tsx
<el.Button
isSmall={true}
onClick={() => navigate(`/todo/?id=${data.id}`)}
>
상세
</el.Button>
// detail.tsx
const { search } = useLocation();
const [searchParams, setSearchParams] = useSearchParams();
const idState = searchParams.get("id") || "";
const [id, setId] = React.useState("");
const { data, isFetching, isLoading } = useGetToDoById(id);
React.useEffect(() => {
setId(idState);
}, [data, search]);
이렇게 useState값을 하나 만든 후, 주소창이 변경되면 자동으로 setId를 시킨다. 그리고 메인에서는 에러가 발생할 경우 URLSearchParams에 있는 결과값을 삭제해주도록 했다.
과제의 조건인 -> 뒤로가기를 했을때 순서대로 한일들을 볼수 있을것
그리고 중간에 삭제된 카드가 있더라도 오류가 나지 않고 새로고침도 되지 않고 데이터의 정합성을 유지할 것, 2가지를 지키기 위해서였다.
<div className="w-1/2">
<DateBox>
<el.Text className="text-lg md:text-2xl ml-3 dark:text-white self-end">
What To Do!
</el.Text>
<div className="flex flex-col items-end dark:text-white">
<el.Text className="text-sm">
created :{" "}
{!isFetching && data && idState && id
? formatDistanceToNow(new Date(data?.data.data.createdAt), {
addSuffix: true,
includeSeconds: true,
})
: "생성한 날짜"}
</el.Text>
<el.Text className="text-sm">
updated :{" "}
{!isFetching && data && idState && id
? formatDistanceToNow(new Date(data?.data.data.updatedAt), {
addSuffix: true,
includeSeconds: true,
})
: "수정한 날짜"}
</el.Text>
</div>
</DateBox>
<div className="w-full bg-white p-4 m-2 overflow-y-scroll h-32">
{isFetching ? (
<el.Text variant="text">삭제중...</el.Text>
) : (
<>
<el.Text variant="title">{data?.data.data.title}</el.Text>
<el.Text variant="text">{data?.data.data.content}</el.Text>
</>
)}
</div>
</div>
리코일을 안썼는데도 isFetching을 이용하니 "삭제중..." 표시를 띄워줄수 있어서 css도 깨지지 않고 깔끔하게 처리되었다.
useQuery를 이용해 다시 제작한 useGetToDoById
이상하게 onSuccess를 하면 1초에 한번씩 서버로 가는데 아직 이유를 찾지 못했다.
다른 에러의 경우 토큰이 없으면 자동으로 navigate를 해주므로 이 훅에 대해서만 useErrorBoundary를 무효화하고 메인페이지로 돌아가도록 해주었다.
export function useGetToDoById(id: string) {
const navigate = useNavigate();
const queryClient = useQueryClient();
return useQuery(Keys.detail(id), () => ToDosAPI.getToDoById(id), {
retry: 1,
useErrorBoundary: false,
// onSuccess: () => {
// queryClient.invalidateQueries(Keys.detail(id));
// },
onError: (error: AxiosError) => {
if (error !== undefined && error instanceof AxiosError) {
queryClient.invalidateQueries(Keys.detail(id));
navigate("/todo");
}
},
});
}
react-error-boundary를 설치해서 에러바운더리와 서스펜스를 달고 느린3G 네트워크에서 상단바(디테일), 투두리스트가 따로따로 잘 로딩되는 것을 확인했다.
에러가 하단에서 상단으로 전파되는 것도 체크한 후 일반 쿼리들에 useBoundary를 달아주고 onError함수들을 제거했다.
스피너에 onClick기능을 달긴 했지만 역시 버튼 있는 페이지를 하나 만들어주는게 좋겠다. 스피너는 계속 돌아가니까.
return (
<>
<Layout>
<QueryErrorResetBoundary>
{({ reset }) => (
<ErrorBoundary
onReset={reset}
fallbackRender={({ resetErrorBoundary }) => (
<el.Spinner onClick={() => resetErrorBoundary()} />
)}
>
<el.HiddenBox close={close}>
<React.Suspense fallback={<el.Spinner />}>
<div className="flex flex-row w-full">
<CreateToDoForm />
<DetailPage />
</div>
</React.Suspense>
<button
className="mt-5 -mb-10 flex
justify-center items-center mx-auto w-8
hover:fill-yellow-300 active:fill-yellow-500
active:scale-110"
onClick={() =>
window.scrollY > 120 && setClose((state) => !state)
}
>
<Arrow />
</button>
</el.HiddenBox>
<React.Suspense fallback={<el.Spinner />}>
<div className="flex p-10">
{data?.data && <List {...data} />}
</div>
</React.Suspense>
</ErrorBoundary>
)}
</QueryErrorResetBoundary>
</Layout>
</>
);
const [close, setClose] = React.useState(false);
const handleTopSideBar = () => {
window.addEventListener("scroll", handleTopSideBar);
if (window.scrollY < 120) {
return setClose(false);
}
};
useDebounce(handleTopSideBar, 700);
그런데 리액트 쿼리 리팩토링을 하다가 어느 순간부터 네트워크 요청이 폭증하는 것을 확인했고, 원인을 찾다가 useEffect 관련한 설명을 보았다.
useEffect에 event listener를 달았을때는 사라지지 않고 계속 남아 장기적으로 메모리에 해를 끼치기 때문에 반드시 clean up 기능으로 제거해 주어야 한다고 한다!
const [close, setClose] = React.useState(false);
const handleTopSideBar = () => {
if (window.scrollY < 120) {
return setClose(false);
}
};
const Throttle = useThrottle(handleTopSideBar, 700);
React.useEffect(() => {
window.addEventListener("scroll", handleTopSideBar);
return () => {
window.removeEventListener("scroll", Throttle);
};
}, []);
setIsSubmitting((state) => !state);
useEffect(() => {
if (isSubmitting && !isError) {
callback();
}
}, [isSubmitting]);
const Submits = () => {
if (values.title === "" && createRef.current) {
return createRef?.current.focus();
}
setValues({
title: "",
content: "",
});
mutateAsync(values);
};
조건 2 : 제목만 입력하고 엔터치는 경우 - 이 칸을 입력해주세요와 함께 내용칸으로 포커스가 이동
조건 3 : 내용만 입력하고 엔터치는 경우 - 이 칸을 입력해주세요와 함께 제목칸으로 포커스가 이동
조건 4 : 제목 칸에서 시프트 + 엔터 ==> 먹지 않음
조건 5 : 내용 칸에서 시프트 + 엔터 ==> 줄바꿈이 되지만 등록시에 줄바꿈이 적용되지는 않음. 문법을 같이 전달하고 출력하려면 html 텍스트 에디터가 필요하다고 한다.
혹시몰라 submit과 키보드 이벤트를 둘다 만들고 예외처리를 달아두었다.
<QueryErrorResetBoundary>
{({ reset }) => (
<ErrorBoundary
onReset={reset}
fallbackRender={({ error, resetErrorBoundary }) => (
<Modal
isShown={isShown}
hide={toggle}
modalContent={<el.ModalContent content={error} />}
contentText={"확인"}
callback={() => resetErrorBoundary()}
/>
)}
>
export const Modal: React.FunctionComponent<ModalProps> = ({
isShown,
hide,
modalContent,
headerText,
callback,
contentText = "확인",
}) => {
const Modal = (
<React.Fragment>
<Backdrop onClick={() => hide()} />
<Wrapper>
<StyledModal>
<Content>
{modalContent}
<div className="flex flex-row divide-x ">
<button
className="bg-white mx-auto flex items-center justify-center font-bold text-center w-44 max-w-md"
onClick={callback}
>
{contentText}
</button>
<button
onClick={contentText === "확인" ? callback : hide}
className="bg-white mx-auto flex items-center justify-center font-bold text-center w-44 max-w-md"
>
{contentText === "확인" ? "닫기" : "취소"}
</button>
</div>
</Content>
</StyledModal>
</Wrapper>
</React.Fragment>
);
return isShown ? ReactDOM.createPortal(Modal, document.body) : null;
};
근데 만들다가 보니 오류가 발생하는거 아니면 굳이 수정 컨펌을 제작할 필요가 없을것 같았다.
왜냐하면 수정페이지를 따로 만들지 않고 수정-수정완료 토글로 새로고침이나 페이지 이동 없이 처리되고 있기 때문.
오히려 사용자경험이 안좋아질 것 같아서 삭제, 오류, 가입확인 모달까지만 제작하기로 했다.
어떤 형태의 에러라도 들어와야 하니까 any를 쓰고, 그 중에서 axios Error일때만 메시지를 보여준다.
오류가 없을때는 준비된 메시지를 뱉는데 저거 두 줄을 위해 컴포넌트를 분리하기엔 작은 프로젝트라서 그냥 저기에 로그인과 회원가입의 경우를 고정으로 집어넣어주었다.
import React from "react";
import { AxiosError } from "axios";
interface LabelProps {
content?: any;
confirm?: boolean;
}
const ModalContent = (content: LabelProps, confirm: boolean) => {
const message = confirm
? "로그인에 성공했습니다."
: "회원가입에 성공했습니다. 자동으로 로그인합니다.";
const [errorMessage, setErrorMessage] = React.useState<any>();
React.useEffect(() => {
content.content instanceof AxiosError &&
setErrorMessage(content.content?.response?.data);
}, []);
return (
<div className="h-32 flex justify-center items-center">
{errorMessage?.details || message}
</div>
);
};
export default ModalContent;
존재하는 alert 모두 모달로 교체(메인스레드가 멈추게 하는 alert은 쓰지 않는게 좋음)
파괴적 버튼 : 삭제같은걸 하는 버튼에는 꼭 해당 동작의 중요도를 선명하게 알려줄 수 있는 색상의 버튼을 배치하자
방어적 버튼과 파괴적 버튼 css
<el.HiddenBox close={close}>
<button
type="button"
className="absolute bottom-1 left-2 md:hidden
hover:text-yellow-300 dark:text-white font-bold"
onClick={() => setOpenCreateToDo((state) => !state)}
>
{openCreateToDo ? "make to Do" : "detail to Do"}
</button>
<div className="flex flex-row w-full">
{matches && (
<>
<CreateToDoForm />
<DetailPage />
</>
)}
{!matches && !openCreateToDo && <DetailPage />}
{!matches && openCreateToDo && <CreateToDoForm />}
</div>
<button
className="mt-5 -mb-10 flex justify-center items-center mx-auto w-8
hover:fill-yellow-300 active:fill-yellow-500 active:scale-110"
onClick={() =>
window.scrollY > 120 && setClose((state) => !state)
}
>
<Arrow />
</button>
</el.HiddenBox>
예전에 노마드코더에서 들었던 실전형 리액트 훅 10개에 있던 것이 생각났다. <-- 이강의 좋다 조금 옛날강의라 그대로 하면 오류가 좀 나는게 단점이지만..
분명히 타입스크립트 버전으로 누군가 만든게 있을것 같다는 강력한 느낌이 와서 검색해보았다.
있었다. 그래서 이건 쉬웠다.
return (
<>
<div className=" w-full flex flex-col">
{data ? (
data?.data.map((todos: ToDoProps, i: number) => (
<div key={`${todos.id}-${i}`}>
<React.Suspense fallback={<el.Skeleton />}>
<Card {...todos} />
</React.Suspense>
</div>
))
) : (
<div className="flex shadow-md bg-white mb-10 h-44 justify-between flex-col md:flex-row dark:bg-gray-700 dark:text-white">
<div className="flex justify-center items-center m-auto text-2xl ">
아직 todo가 없습니다 얼른 만들어보세요!
</div>
</div>
)}
</div>
</>
);
import React from "react";
/** @jsxImportSource @emotion/react */
import tw, { css, styled, theme } from "twin.macro";
export const Skeleton = () => {
return (
<>
<div className="flex shadow-md bg-white mb-10 h-44 flex-col md:flex-row dark:bg-gray-700 dark:text-white animate-pulse">
<div className="animate-pulse">
<div className="w-3/5 bg-gray-400 h-10 m-5 mt-6 opacity-70"></div>
<div className="w-2/5 bg-gray-400 h-7 ml-5 -mt-1 opacity-60"></div>
<div className="w-2/5 bg-gray-400 h-7 ml-5 mt-3 opacity-60"></div>
</div>
<div className="absolute right-10 animate-pulse">
<div className="w-14 bg-gray-400 h-10 mr-5 mt-5 opacity-70"></div>
<div className="w-14 bg-gray-400 h-10 mr-5 mt-2 opacity-70"></div>
<div className="w-14 bg-gray-400 h-10 mr-5 mt-2 opacity-70"></div>
</div>
</div>
</>
);
};
export default Skeleton;