이미지와 같은 관리자용 테이블을 td, th와 같은 Cell에서부터 Page까지 구현하며, 고려한 3가지의 이야기를 나누려합니다.
⭐️ table을 revalidate하라
- tableFilters
- table은 검색 조건인 이름(nickname), 키워드(title), 생성일(createdAfter, createdBefore), 이메일(email), 페이지네이션 조건인 현재 페이지(page), n개씩 보기(limit)에 따라 새로운 테이블 데이터를 가져와야 합니다.
type TableQueries = Partial<{ nickname: string; title: string; createdAfter: string; createdBefore: string; email : string; page: number; limit : string | number }>
b. 따라서 table을 필터링하고 refetch하는 트리거로 사용하기 위하여, tableFilters 객체를 state로 사용합니다. 이 state는 테이블의 검색 UI, 테이블 UI, 페이지네이션 UI 3개가 공유해야 하므로, 이를 감싸는 상위 컴포넌트에서 Provider로 제공하여 사용성을 높입니다.
export function TableProvider({ children }: PropsWithChildren) { const [tableFilters, setTableFilters] = useState<TableQueries>({ page: TABLE_DEFAULT_PAGE, limit: TABLE_DEFAULT_LIMIT, }); return ( <TableContext.Provider value={{ tableFilters, setTableFilters, }} > {children} </TableContext.Provider> ); }
c. tableFilters 객체는 앞서 말했듯 테이블 데이터를 fetch할 때 params로 곧바로 사용되며, 관리하는 queryKey에서 params의 인자로 사용되어 invalidation 또한 트리거합니다. membersQueryKeys.filters(params ← tableFilters)
//사용 const { tableFilters, tableSort } = useTableContext(); const { data, isLoading } = useGetMembers(tableFilters); export const useGetMembers = (params: { page: number; limit: string | number; nickname?: string; title?: string; createdAfter?: string; createdBefore?: string; }) => { return useQuery({ queryKey: membersQueryKeys.filters(params), queryFn: () => memberApi.getMemberList(params), select: (data) => data.data, }); };
⭐️ table의 행들을 관리하라
- 행으로 나열된 테이블 데이터는 하나의 회원, 그룹, 게시글 아이템과 같습니다. 관리자는 각 아이템을 복수 선택하여 삭제할 수 있어야 합니다. 이를 위해 각 행은 고유한 id와 isChecked 값을 가진 별도의 객체로 관리됩니다
왜 별도의 checklist 클라이언트 상태가 있어야 하죠? 서버 상태 그대로 쓸 수 없나요? → 하나의 체크 박스 컴포넌트가 있고, 이를 제어 컴포넌트로 사용할 때 클라이언트 상태를 선언하여 사용합니다. 체크리스트도 마찬가지입니다. 체크박스의 복수 형태로 해당 체크 박스의 고유 식별자와 체크 유무 값을 활용해 체크박스들을 제어합니다.
- 테이블은 엔티티에 상관없이 ids 배열
{id: number, isChecked:boolean}[]
을 state로 가집니다. 이 체크리스트 상태는 Table과 삭제 기능을 가진 tabelToolBar이 공유해야 하므로 상위 컴포넌트에 Provider로 제공합니다
interface TableCheckListProps<T extends WithId> extends PropsWithChildren { tableData: T[]; } export function TableCheckListProvider<T extends WithId>({ children, tableData, }: TableCheckListProps<T>) { const [checkList, setCheckList] = useState< { id: number; isChecked: boolean; }[] >(mapTableDataForCheckList(tableData)); useEffect(() => { setCheckList(mapTableDataForCheckList(tableData)); }, [tableData]); return ( <TableCheckListContext.Provider value={{ checkList, updateCheckList: setCheckList, }} > {children} </TableCheckListContext.Provider> ); } function mapTableDataForCheckList<T extends WithId>(tableData: T[]) { return tableData.map((table) => ({ id: table.id, isChecked: false, })); }
- 주의할 점은 checklist 상태의 초기값, 테이블 데이터가 삭제된 이후의 checklist 상태의 최신값입니다. tableData는 서버 상태로 관리되는 members, groups, posts를 (정렬 계산만 된 채로) 받아 ids 배열로 매핑하여 사용합니다. (title, nickname 등의 값은 체크리스트가 알고싶은 정보가 아니니까요)
- 그런데! 여기서 서버 상태와 클라이언트 상태의 동기화가 필요해요. props로 새로운 tableData(한 아이템이 사라진 후 다시 10개 limit으로 채워진)가 주입되었지만, 이를 ids배열로 매핑한 checklist 상태는 그대로입니다. 새로운 props를 감지했지만, 이는 리렌더링의 조건이 아닙니다. 리렌더링이 되려면 부모 컴포넌트가 리렌더링 되어야 합니다. props는 부모 컴포넌트가 렌더링될 때 주입되기 때문입니다.
props의 경우, 통상 props가 교체될 때 해당 props를 받은 컴포넌트가 렌더링되기는 한다. 그런데 이건 결과에 불과하고 사실 리액트가 props의 교체를 주시(subscribe)하고 있는 것은 아니다.
- 다시 삭제 이후로 돌아가봅시다. table 데이터에서 id가 1,3,7인 목록을 삭제했습니다.(deleteMembers) 이후 revalidation을 거쳐 newIds[2,4,5,6,8,9,10,11,12,13]으로 구성된 한 페이지가 새 데이터로 refetch 됩니다. useGetMembers를 호출한 MemberManagementPage 컴포넌트는 리렌더링 됩니다. 자식 컴포넌트인 tableCheckListProvider도 리렌더링되며, tableData props에도 newIds가 주입됩니다.
- React Query의 내부 동작:
- React Query는 내부적으로 상태를 관리합니다. 쿼리의 결과, 로딩 상태, 에러 상태 등을 자체적으로 저장하고 업데이트합니다.
- 이 과정에서 React Query는 내부적으로
useState
와 유사한 메커니즘을 사용하여 상태를 관리합니다.
- 상태 변경 감지:
- 쿼리가 실행되고 새로운 데이터가 fetch되면, React Query는 이 변경사항을 감지하고 내부 상태를 업데이트합니다.
- 이 상태 업데이트는 React의 상태 업데이트 메커니즘을 트리거합니다.
- 컴포넌트 리렌더링:
- React Query 훅(예:
useQuery
)이 반환하는 값이 변경되면, 이 훅을 사용하는 컴포넌트는 리렌더링됩니다. - 이는 마치
useState
의 값이 변경되었을 때 컴포넌트가 리렌더링되는 것과 유사한 메커니즘입니다.
[예시 - 회원 관리 페이지 (MemberManagementPage)]
export function MemberManagementPage() { const { data: members, isLoading } = useGetMembers(tableFilters); const { mutate: deleteMembers } = useDeleteMembers(); const handleDelete = (ids: number[]) => { (...) deleteMembers(ids); } return ( <div> (...) <TableCheckListProvider tableData={members}> <TableToolBarWithCheck onDelete={handleDelete} searchedCount={data.searchedCount} totalCount={data.totalCount} /> <MembersTable members={members} /> </TableCheckListProvider> </div> ); }
- 그러나 tableCheckListProvider의 내부 state인 checkList는 영향을 받지 않습니다. 최초 렌더링이 아닌 리렌더링이기 때문에, 아무리 newIds를 받아도 이를 반영하지 않습니다. 그래서 이를 연결해줄 무언가가 필요합니다. useEffect를 활용하여, props인 tableData가 변경되는 걸 감지하게 하고, 새 객체가 들어올 때마다 이를 활용하여 다시금 setCheckList를 실행하게 합니다.
⭐️ table의 열을 기준으로 정렬하라
- id, 관리설정 등의 열을 제외하고, ‘회원’이라는 엔티티와 연관 있는 name, email, createdAt, updatedAt, groupCount 를 활용해서, 관리자는 데이터를 정렬해서 볼 수 있어야 합니다.
- 즉 테이블의 th 헤드는 정렬 기능을 추가할지 여부(withSort)와 정렬을 한다면 사용할 기준(criteria)이 필요합니다. 이를 반영하여 회원 테이블의 헤드를 관리할 객체를 생성합니다
- criteria에 따라 정렬은 알파벳, 한글, 숫자, 날짜를 오름차/내림차 순으로 정렬(sortBy)합니다.
- 최종 렌더링 되는 테이블은 이러한 정렬을 거친 sortedMembers로, 서버에서 받은 members 상태를 받지만, 이와 별도로 클라이언트 상태로 저장됩니다.
- sorted된 회원의 상태는 페이지 전 영역이 아닌, 테이블 자체만 알고 있으면 되므로 바로 상단에서 프로바이더로 제공합니다.