커스텀 훅과 상태관리: state(상태) 끌어올리기 (문제 해결)

Devinix·2023년 10월 30일
0

[문제 해결]

목록 보기
6/29
post-thumbnail

개요

UserContentsContainer 컴포넌트 내에서 useCheckAll 커스텀 훅을 사용하여 AllCheckbox 컴포넌트를 선택하면 UserContent 내부의 모든 Checkbox 컴포넌트들이 선택되는 기능을 구현하였다. 그러나 페이지네이션을 통해 페이지를 전환할 때 isCheckedAll 상태가 초기화되지 않는 문제가 발생하였다. (페이지 전환 방식이 아닌 조건부 렌더링을 통해 컨텐츠들을 렌더링하였기 때문.)


코드를 살펴보자.


UserContentsContainer 컴포넌트

interface IProps {
  currentContents: IContent[];
}

function UserContentsContainer({ currentContents }: IProps): JSX.Element {
  const [isCheckedAll, setIsCheckedAll] = useState<number[]>([]);
  const { handleAllCheck, handleSingleCheck } = useCheckAll({
    isCheckedAll,
    setIsCheckedAll,
    currentContents,
  });

  return (
    <>
      <div className={styles.container}>
        <PostManagement />
        <div className={styles.allCheckboxContainer}>
          <AllCheckbox
            checked={
              isCheckedAll.length === currentContents.length ? true : false
            }
            onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
              handleAllCheck(e.target.checked)
            }
          />
          <div className={styles.text}>전체 선택</div>
        </div>
        <div className={styles.userContents}>
          {currentContents?.map((content: IContent) => (
            <UserContent
              key={content.id}
              content={content}
              checked={isCheckedAll.includes(content.id) ? true : false}
              onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
                handleSingleCheck(e.target.checked, content.id)
              }
            />
          ))}
        </div>
        <Button
          variant="contained"
          startIcon={<DeleteIcon />}
          className={styles.deleteButton}
        >
          삭제
        </Button>
      </div>
    </>
  );
}

export default UserContentsContainer;

UserContent 컴포넌트

interface IProps {
  content: IContent;
  checked: boolean;
  onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
}

function UserContent({ content, checked, onChange }: IProps): JSX.Element {
  return (
    <>
      <div className={styles.container}>
        <div className={styles.checkboxContainer}>
          <Checkbox checked={checked} onChange={onChange}/>
        </div>
        <div className={styles.contentContainer}>
          <div className={styles.title}>{content.title}</div>
          <div className={styles.reactionAndDateContainer}>
            <Reaction>
              <DeleteIcon className={styles.deleteButton} />
            </Reaction>
            <div className={styles.date}>{content.date}</div>
          </div>
        </div>
      </div>
    </>
  );
}

export default UserContent;

useCheckAll 커스텀 훅

interface IProps {
  isCheckedAll: number[];
  setIsCheckedAll: React.Dispatch<React.SetStateAction<number[]>>;
  currentContents: IContent[];
}

interface IUseCheckAll {
  handleSingleCheck: (checked: boolean, id: number) => void;
  handleAllCheck: (checked: boolean) => void;
}

function useCheckAll({
  isCheckedAll,
  setIsCheckedAll,
  currentContents,
}: IProps): IUseCheckAll {
  const handleSingleCheck = (checked: boolean, id: number): void => {
    if (checked) {
      setIsCheckedAll((prev: number[]) => [...prev, id]);
    } else {
      setIsCheckedAll(isCheckedAll.filter((el) => el !== id));
    }
  };

  const handleAllCheck = (checked: boolean): void => {
    if (checked) {
      const idArray: number[] = [];
      currentContents.forEach((el) => idArray.push(el.id));
      setIsCheckedAll(idArray);
    } else {
      setIsCheckedAll([]);
    }
  };

  return { handleSingleCheck, handleAllCheck };
}

export default useCheckAll;

usePagination 커스텀 훅

import { useState } from "react";
import useDivideContent from "../useDivideContent/useDivideContent";

interface IUsePagination {
  currentPage: number;
  maxPageNum: number;
  visiblePageNumbers: number[];
  handleClick: (event: React.MouseEvent, number: number) => void;
  goToFirstPage: (event: React.MouseEvent) => void;
  goToLastPage: (event: React.MouseEvent) => void;
  totalContents: number;
}

function usePagination({
  currentPage,
  setCurrentPage,
}: IPaginationProps): IUsePagination {
  const { handlePaginate, totalContents, contentsPerPage } = useDivideContent({
    currentPage,
    setCurrentPage,
  });
  const pageNumbers: number[] = [];
  const maxPageNum = Math.ceil(totalContents / contentsPerPage);

  for (let i = 1; i <= maxPageNum; i++) {
    pageNumbers.push(i);
  }

  const updatePageNumbers = (page: number) => {
    let startPage = page - 4 > 1 ? page - 4 : 1;
    let endPage = startPage + 9 > maxPageNum ? maxPageNum : startPage + 9;

    while (endPage - startPage < 9 && startPage > 1) {
      startPage--;
    }

    return pageNumbers.slice(startPage - 1, endPage);
  };

  const [, setCurrentNumbers] = useState(updatePageNumbers(1));

  const visiblePageNumbers = updatePageNumbers(currentPage);

  const handleClick = (event: React.MouseEvent, number: number): void => {
    event.preventDefault();
    setCurrentPage(number);
    setCurrentNumbers(updatePageNumbers(number));
    handlePaginate(number);
  };

  const goToFirstPage = (event: React.MouseEvent): void => {
    event.preventDefault();
    setCurrentPage(1);
    handlePaginate(1);
  };

  const goToLastPage = (event: React.MouseEvent): void => {
    event.preventDefault();
    setCurrentPage(maxPageNum);
    handlePaginate(maxPageNum);
  };

  return {
    currentPage,
    maxPageNum,
    visiblePageNumbers,
    handleClick,
    goToFirstPage,
    goToLastPage,
    totalContents,
  };
}

export default usePagination;

문제 상황

UserContentsContainer라는 컴포넌트 내에서 여러 개의 체크박스 상태(isCheckedAll)를 관리하면서 동시에 페이지네이션 기능을 적용하려 했을 때, 페이지가 변경될 때마다 체크박스 상태를 초기화하는 기능의 구현에 어려움을 겪었다.

(UserContentsContainer는 UserMain의 하위 컴포넌트이다. UserMain에는 페이지네이션 기능을 갖는 다른 하위 컴포넌트인 Pagination이 존재한다.)

원인

상태의 범위:
isCheckedAll 상태는 UserContentsContainer 내부에 위치했으므로, 페이지네이션 로직을 담당하는 별도의 custom hook에서는 이 상태에 직접 접근할 수 없었다.

상태 관리의 분산:
여러 custom hook들이 각각의 로직과 관련된 상태만을 관리하고 있었기 때문에, 상호작용하는 로직 간에 상태 공유가 어려웠다.

문제 해결

상태 끌어올리기 (Lifting State Up):
상위 컴포넌트에서 isCheckedAll 상태를 생성하고, 이 상태와 그 변경 함수를 하위 컴포넌트와 custom hook에 props로 전달하였다.


수정된 코드를 보자


UserMain (해당 상태를 공유하는 최상위 컴포넌트)

function UserMain(): JSX.Element {
  const [currentPage, setCurrentPage] = useState<number>(1);
  
  // isCheckedAll 상태를 상위 컴포넌트인 UserMain에서 관리
  const [isCheckedAll, setIsCheckedAll] = useState<number[]>([]);
 
  const contentsPerPage = 6;

  const { currentContents } = useDivideContent({
    contentsPerPage,
    currentPage,
    setCurrentPage,
  });

  const content = {
    id: 1,
    userName: "김범수",
    title: "Next.Js 13",
    date: "2023-10-30",
    content: "",
  };

  return (
    <>
      <div className={styles.container}>
        <div className={styles.userImageContainer}>
          <div className={styles.userImageAndName}>
            <UserImage type="userPage" />
            <div className={styles.userNameContainer}>
              <UserName content={content} />
            </div>
          </div>
        </div>
        <div className={styles.userContentsContainer}>
          <UserContentsContainer
            currentContents={currentContents}
            
            // props로 내려주기.
            isCheckedAll={isCheckedAll}
            setIsCheckedAll={setIsCheckedAll}
          />
        </div>
      </div>
      
      // Pagination에도 setIsCheckedAll을 props로 내려준다.
      <Pagination currentPage={currentPage} setCurrentPage={setCurrentPage} setIsCheckedAll={setIsCheckedAll}/>
    </>
  );
}

export default UserMain;

Pagination 컴포넌트

function Pagination({
  currentPage,
  setCurrentPage,
  
  // props로 받은 setIsCheckedAll
  setIsCheckedAll,
}: IPaginationProps) {
  const {
    handleClick,
    goToFirstPage,
    goToLastPage,
    visiblePageNumbers,
    maxPageNum,
  } = usePagination({ currentPage, setCurrentPage, setIsCheckedAll });
  // props로 받은 setIsCheckedAll을 usePagination 훅에 전달

  return (
    <nav className={styles.container}>
      <ul className={styles.pagination}>
        <li>
          <PageLink
            onClick={(event) => goToFirstPage(event)}
            disabled={currentPage === 1}
            symbol={"<<"}
          />
        </li>
        <li>
          <PageLink
            onClick={(event) => handleClick(event, currentPage - 1)}
            disabled={currentPage === 1}
            symbol={"<"}
          />
        </li>
        <PageList
          numbers={visiblePageNumbers}
          currentPage={currentPage}
          handleClick={handleClick}
        />
        <li>
          <PageLink
            onClick={(event) => handleClick(event, currentPage + 1)}
            disabled={currentPage === maxPageNum}
            symbol={">"}
          />
        </li>
        <li>
          <PageLink
            onClick={(event) => goToLastPage(event)}
            disabled={currentPage === maxPageNum}
            symbol={">>"}
          />
        </li>
      </ul>
    </nav>
  );
}

export default Pagination;

usePagination 커스텀 훅

interface IUsePagination {
  currentPage: number;
  maxPageNum: number;
  visiblePageNumbers: number[];
  handleClick: (event: React.MouseEvent, number: number) => void;
  goToFirstPage: (event: React.MouseEvent) => void;
  goToLastPage: (event: React.MouseEvent) => void;
  totalContents: number;
}

function usePagination({
  currentPage,
  setCurrentPage,
  
  // setIsCheckedAll을 전달받음
  setIsCheckedAll,
}: IPaginationProps): IUsePagination {
  const { handlePaginate, totalContents, contentsPerPage } = useDivideContent({
    currentPage,
    setCurrentPage,
  });
  const pageNumbers: number[] = [];
  const maxPageNum = Math.ceil(totalContents / contentsPerPage);

  for (let i = 1; i <= maxPageNum; i++) {
    pageNumbers.push(i);
  }

  const updatePageNumbers = (page: number) => {
    let startPage = page - 4 > 1 ? page - 4 : 1;
    let endPage = startPage + 9 > maxPageNum ? maxPageNum : startPage + 9;

    while (endPage - startPage < 9 && startPage > 1) {
      startPage--;
    }

    return pageNumbers.slice(startPage - 1, endPage);
  };

  const [, setCurrentNumbers] = useState(updatePageNumbers(1));

  const visiblePageNumbers = updatePageNumbers(currentPage);

  const handleClick = (event: React.MouseEvent, number: number): void => {
    event.preventDefault();
    setCurrentPage(number);
    setCurrentNumbers(updatePageNumbers(number));
    handlePaginate(number);
    
    // isCheckedAll 상태 초기화
    setIsCheckedAll([]);
  };

  const goToFirstPage = (event: React.MouseEvent): void => {
    event.preventDefault();
    setCurrentPage(1);
    handlePaginate(1);
    
    // isCheckedAll 상태 초기화
    setIsCheckedAll([]);
  };

  const goToLastPage = (event: React.MouseEvent): void => {
    event.preventDefault();
    setCurrentPage(maxPageNum);
    handlePaginate(maxPageNum);
    
    // isCheckedAll 상태 초기화    
    setIsCheckedAll([]);
  };

  return {
    currentPage,
    maxPageNum,
    visiblePageNumbers,
    handleClick,
    goToFirstPage,
    goToLastPage,
    totalContents,
  };
}

export default usePagination;

결론

React(Next.Js)의 컴포넌트 구조는 모듈화와 재사용성을 증진시키지만, 상태 관리에 있어서 복잡한 상황이 발생할 수 있다. 이번의 사례처럼 여러 컴포넌트나 custom hook 간에 상태를 공유하거나 서로의 상태에 영향을 주어야 할 때, 상태 끌어올리기는 그 해결의 핵심이 될 수 있다. 이 방법은 상위 컴포넌트에서 중요한 상태를 중앙에서 관리하면서 필요한 로직과 상태를 하위 컴포넌트나 hook에 전달함으로써, 구조적이고 일관된 상태 관리를 가능하게 한다.

profile
프론트엔드 개발

0개의 댓글