React Data Table 구현하기

서나무·2022년 11월 25일
14

React

목록 보기
1/5
post-thumbnail

(번역) 데이터 구조를 개선하여 코드 43% 줄이기를 보고, 나도 Set 자료 구조를 사용해서 데이터 테이블을 직접 구현해볼까? 하는 생각이 들어서 후다닥 만들어봤습니다.

React로 데이터 테이블을 구현하는 과정을 기록했으며, 잘못된 정보가 있거나 설명이 부족한 부분이 있다면 댓글로 남겨주시면 감사하겠습니다! 🤗

동작하는 코드는 stackblitz에서 바로 확인해볼 수 있습니다.

컴포넌트 설계

가장 먼저 할 일은 컴포넌트를 설계하는 것입니다.

컴포넌트 설계 단계에서는 머리속으로 생각하고 있는 추상적인 기능들을 구체화합니다. 컴포넌트 설계가 미흡하면 추후 컴포넌트를 수정하는 일이 발생하고, 코드가 복잡해질 가능성이 생기기 때문에 매우 중요한 단계입니다.

저는 컴포넌트를 설계할 때 커스텀 가능성을 중점적으로 고려하는 편이며, 커스텀 가능성에 따라 장단점이 나뉘지기 때문에 컴포넌트의 목적에 따라 설정해야 합니다.

커스텀 가능성장점단점
높다다양한 옵션으로 사용 가능러닝커브가 높아짐, 컴포넌트가 무거워질 수 있음
낮다러닝커브가 낮음기능이 적고, 유연하지 못한 컴포넌트가 될 수 있음

러닝커브가 높고 낮다는 의미는 내가 만든 컴포넌트를 사용하는 다른 개발자의 입장에서 적은 것 입니다. 커스텀 가능성이 높을수록 컴포넌트에 대한 깊은 이해가 있어야 의도에 맞게 컴포넌트를 사용할 수 있기 때문이죠.

기능 목록

  1. 헤더 순서에 맞게 데이터 객체에서 값 넣어주기
  2. 행 선택 기능 사용여부
    행 선택 기능을 사용하면 checkbox가 표시되며, 사용하지 않으면 표시되지 않습니다.
  3. 행 비활성화 가능
    데이터 객체에 disabled: true 요소를 추가하면 행을 비활성화 시킬 수 있습니다.

이해를 돕기 위해 사진을 첨부했습니다!

Props

props데이터 타입설명필수여부
headersarray테이블 헤더 행을 정의
itemsarray테이블 바디에 보여지는 데이터 목록
itemKeystring선택 행의 키값
selectableboolean행 선택 기능 사용여부
updateSelectionfunction선택된 행이 변경되면 실행할 함수

데이터 타입이 배열인 header와 items는 객체를 담는 배열로 아래에 자세한 설명을 작성했습니다.

headers

[
  {
    text: '컬럼명',
    value: '데이터명'
  }
]

각 컬럼을 객체로 정의하며, 컬럼의 수 만큼 배열에 넣어줘야 합니다.

위 사진을 보면 테이블 헤더에 컬럼이 3개가 있습니다.

const headers = [
  {
    text: 'Name',
    value: 'name'
  },
  {
    text: 'Version',
    value: 'version'
  },
  {
    text: 'Launch Date',
    value: 'launch'
  }
];

테이블 헤더는 이렇게 정의해줄 수 있습니다.

items

headers에서 지정한 컬럼의 수 만큼 데이터를 입력합니다. 객체 하나가 열 하나에 해당합니다.

const items = [
  {
    name: 'React',
    version: '18.2.0',
    launch: '2013-05-29'
  },
  {
    name: 'Vue',
    version: '3.2.45',
    launch: '2014-02'
  },
  {
    name: 'jQuery',
    version: '3.3',
    disabled: true,
    launch: '2006-08-26'
  },
  {
    name: 'Svelte',
    version: '3.53.1',
    launch: '2016-11-26'
  }
 ];

headers에서 name, version, launch 세 개의 컬럼을 지정했기 때문에, 데이터 객체에서 지정한 데이터명에 데이터를 넣어줍니다.

1. 컴포넌트 초기 세팅

export default function DataTable({}) {
  return (
    <table>
      <thead>
        {/* TODO 테이블 헤드 바인딩 */}
      </thead>
      <tbody>
        {/* TODO 테이블 데이터 바인딩 */}
      </tbody>
    </table>
  )
}

컴포넌트 테이블의 기본 구성 요소들을 배치합니다.

2. headers 바인딩

Props를 보면 headers는 필수 props입니다. 따라서 headers가 없다면 에러를 발생시키고, headers가 있을 경우에만 map 함수로 순환하며 테이블에 추가해줍니다.

export default function DataTable({ headers }) {
  // headers가 있는지 체크하고, 없다면 에러를 던짐
  if (!headers || !headers.length) {
    throw new Error('<DataTable /> headers is required.')
  }
  return (
    <table>
      <thead>
        <tr>
          {
            headers.map((header) => 
              <th key={header.text}>
                {header.text} {/* 컬럼명 바인딩 */}
              </th> 
            )
          }
        </tr>
      </thead>
      <tbody>
        {/* TODO 테이블 데이터 바인딩 */}
      </tbody>
    </table>
  )
}

이제 App.js에서 컴포넌트를 사용해볼까요? props로 넘겨주는 headers 배열은 headers에 있습니다.

import DataTable from "./components/dataTable";
function App() {
  return (
    <div className="App">
      <DataTable 
        headers={headers} {/* headers props 보내기 */} 
      />
    </div>
  );
}

테이블의 헤더 부분이 잘 나오네요! 😄

3. items 바인딩

데이터를 표시할 때 신경써야 하는 부분은 데이터가 헤더에 설정한 순서대로 정의되어서 오지 않을 수 있다는 점입니다.

따라서 데이터 객체에 들어있는 순서에 상관 없이 헤더에 맞는 데이터를 보여줘야 합니다.

저는 header의 value들이 담겨있는 headerKey 배열을 추가로 만들었고, 행을 표시할 때 headerKey 배열을 순회하면서 행의 요소들에 접근해서 데이터를 바인딩해줬습니다.

export default function DataTable(
  { 
    headers, 
    items = [], // items props 받기, default parameter 빈 배열로 설정
  }) {
  if (!headers || !headers.length) {
    throw new Error('<DataTable /> headers is required.')
  }
  // value 순서에 맞게 테이블 데이터를 출력하기 위한 배열
  const headerKey = headers.map((header) => header.value);
  return (
    <table>
      <thead>
        {/* ... */}
      </thead>
      <tbody>
        {
          items.map((item, index) => (
            <tr key={index}>
              {/* headerKey를 순회하면서 key를 가져옴 */}
              { 
                headerKey.map((key) => 
                  <td key={key + index}>
                    {item[key]} {/* key로 객체의 값을 출력 */}
                  </td>
                )
              }
            </tr>
          ))
        }
      </tbody>
    </table>
  )
}

데이터가 잘 출력되는지 App.js에서 데이터를 넘겨볼까요? props로 넘겨주는 items 배열은 items에 있습니다.

function App() {
  return (
    <div className="App">
      <DataTable 
        headers={headers} 
        items={items} {/* items props 보내기 */} 
      />
    </div>
  );
}

아직 비활성 행에 대한 코드를 작성하지 않아서 모두 활성화된 행으로 보여주고 있지만, 잘 출력되고 있네요! 👍

4. selectable 체크박스 바인딩

이제 행 선택 기능 사용여부에 따라서 체크박스를 보여줘야 합니다.

selectable props를 받아서 true일 경우에만 체크박스를 보여주면 되겠죠?

export default function DataTable(
  { 
    headers, 
    items = [],
    selectable = false, // selectable props 받기
  }) {
  if (!headers || !headers.length) {
    throw new Error('<DataTable /> headers is required.')
  }
  const headerKey = headers.map((header) => header.value);

  return (
    <table>
      <thead>
        <tr>
          {/* 선택 기능을 사용할 때만 바인딩 */}
          {
            selectable && <th><input type="checkbox" /></th>
          }
          {
            headers.map(
              (header) => <th key={header.text}>{ header.text }</th>
            )
          }
        </tr>
      </thead>
      <tbody>
        {
          items.map((item, index) => (
            <tr key={index}>
              {/* 선택 기능을 사용할 때만 바인딩 */}
              {
                selectable && <td><input type="checkbox" /></td>
              }
              {
                headerKey.map((key) => 
                  <td key={key + index}>{item[key]}</td>
                )
              }
            </tr>
          ))
        }
      </tbody>
    </table>
  )
}

App.js에서 props로 selectable={true}를 보내서 체크박스가 잘 출력되는지 확인해볼까요?

function App() {
  return (
    <div className="App">
      <DataTable 
        headers={headers} 
        items={items} 
        selectable={true} {/* selectable props 보내기 */} 
      />
    </div>
  );
}

체크박스가 잘 보이네요! 이제 데이터 테이블 컴포넌트를 사용하는 개발자가 원하는 경우에 따라 행 선택 여부를 제어할 수 있게 되었습니다.

5. selectable 기능 구현

드디어 Set을 사용해서 행 선택 기능을 구현할 차례입니다.

코드가 길어지는 것을 방지하기 위해 DataTable의 기존 코드들은 생략했습니다.

export default function DataTable(
  { 
    headers, 
    items = [], 
    selectable = false,
    itemKey // itemKey props 받기
  }
) {
  // itemKey가 없다면 headers의 첫번째 요소를 선택 
  if (!itemKey) {
    itemKey = headerKey[0];
  }
  // 선택한 row의 itemKey를 담은 배열
  const [selection, setSelection] = useState(new Set());
}

먼저 선택한 행의 데이터를 담을 배열을 선언해줍니다.

행 전체의 데이터를 담지 않고, itemKey에 해당하는 데이터만 배열에 담으려고 합니다.

itemKey는 props가 없을 경우 headerKey첫번째 요소를 선택하는데, 위의 데이터 테이블의 itemKey는 name이므로 행을 선택하면 ['React', 'Vue', 'jQuery', 'Svelte'] 와 같은 데이터가 담기게 됩니다.

단일 행 선택

단일 행을 선택했을 경우 데이터 객체에서 itemKey로 값을 받아와 넣어주게 됩니다.

export default function DataTable({ /* ... */ }) {
  const [selection, setSelection] = useState(new Set());
  const onChangeSelect = (value) => {
    // 기존의 selection으로 새로운 Set 생성
    const newSelection = new Set(selection);
    if (newSelection.has(value)) {
      // value가 있으면 삭제 (checked가 false이기 때문)
      newSelection.delete(value);
    } else {
      // value가 없으면 추가 (checked가 true이기 때문)
      newSelection.add(value);
    }
    // 새로운 Set으로 state 변경
    setSelection(newSelection);
  };
}

특정 행 체크박스 클릭시 selectionvalue가 없다면 value를 추가해줍니다. 이 상태에서 또 같은 행 체크박스를 클릭하면 selection에 이미 value가 있기 때문에 기존에 있던 value를 제거하게 되는거죠.

HTML 코드

return (
    <table>
      <thead>{/* ... */}</thead>
      <tbody>
        {
          items.map((item, index) => (
            <tr 
              key={index} 
              className={
              `
                ${selection.has(item[itemKey]) ? 'select_row': ''} 
                ${item.disabled ? 'disabled_row' : ''}
              `
            }>
              {/* 속성 넣어주기 */}
              {
                selectable && 
                  <td>
                    <input 
                      type="checkbox"
                      disabled={item.disabled}
                      checked={selection.has(item[itemKey])}
                      onChange={() => onChangeSelect(item[itemKey])}   
                    />
                  </td>
              }
              { 
                headerKey.map((key) => 
                  <td key={key + index}>
                    {item[key]} {/* key로 객체의 값을 출력 */}
                  </td>
                )
              }
            </tr>
          ))
        }
      </tbody>
    </table>
  )

onChange이벤트와 checked, disabled 등의 속성 값도 넣어줍니다. 참고로 trclassName은 css를 위해 클래스를 바인딩하는 것입니다.

단일 행 선택 기능이 완성되었습니다!

전체 선택

전체 선택의 경우 이벤트 객체로 targetchecked 상태로 전체 선택 상태를 파악합니다.

export default function DataTable({ /* ... */}) {
  // disabled가 true인 item만 반환하는 함수
  const getAbledItems = (items) => {
    return items.filter(({ disabled }) => !disabled );
  };
  const onChangeSelectAll = (e) => {
    if (e.target.checked) {
      // checked가 true인 경우 전체 선택
      const allCheckedSelection = new Set(
        // 활성화된 행의 배열을 순회하며 itemKey로 요소에 접근해 데이터를 저장
        getAbledItems(items).map((item) => item[itemKey])
      );
      setSelection(allCheckedSelection);
    } else {
      // checked가 false인 경우 전체 선택 해제
      setSelection(new Set());
    }
  };
  // 전체 선택 상태 여부
  const isSelectedAll = () => {
    return selection.size === getAbledItems(items).length;
  };
}

selectionsize와 활성화 행들의 length가 같다면 전체 선택이 되어있다는 것을 알 수 있습니다. 전체 선택 체크박스의 checked 속성을 사용하기 위해 함수로 만들었습니다.

HTML 코드

  return (
    <table>
      <thead>
        <tr>
          {/* 속성 넣어주기 */}
          {
            selectable && 
            <th>
              <input 
                type="checkbox"
                checked={isSelectedAll()}
                onChange={onChangeSelectAll}
              />
            </th>
          }
          {
            headers.map((header) => 
              <th key={header.text}>
                {header.text} {/* 컬럼명 바인딩 */}
              </th> 
            )
          }
        </tr>
      </thead>
      <tbody>{/* ... */}</tbody>
    </table>
  )

onChange이벤트와 checked 등의 속성 값도 넣어줍니다.

이제 모든 기능을 완성했습니다. 🎉🎉

6. updateSelection

하지만 아직까지는 외부에서 선택된 행의 value 값을 담은 selection을 받을 수 없습니다.

updateSelection 함수를 props로 받아서 selection을 함수에 인자로 넘겨줘서 외부에서 확인할 수 있도록 해볼까요?

const onChangeSelect = (value) => {
  const newSelection = new Set(selection);
  if (newSelection.has(value)) {
    newSelection.delete(value);
  } else {
    newSelection.add(value);
  }
  setSelection(newSelection);
  // updateSelection 함수 호출
  updateSelection([...newSelection]);
};

const onChangeSelectAll = (e) => {
  if (e.target.checked) {
    const allCheckedSelection = new Set(
      getAbledItems(items).map((item) => item[itemKey])
    );
    setSelection(allCheckedSelection);
    // updateSelection 함수 호출
    updateSelection([...allCheckedSelection]);
  } else {
    setSelection(new Set());
    // updateSelection 함수 호출
    updateSelection([]);
  }
};

onChangeSelect함수와 onChangeSelectAll함수에서 호출해서 상태를 업데이트 해줍니다.

마지막으로 App.js에서 selection을 담을 상태를 선언하고, useEffect로 selection이 변경하는 것을 감지해서 콘솔에 출력하도록 해보겠습니다.

function App() {
  const [selection, setSelection] = useState([]);
  useEffect(() => {
    console.log(selection);
  }, [selection]);

  return (
    <div className="App">
      <DataTable 
        headers={headers} 
        items={items} 
        selectable={true} 
        updateSelection={setSelection}
      />
    </div>
  );
}

단일 행 선택과 전체 선택을 해보면 콘솔에 name 값이 담긴 배열이 출력되는 것을 볼 수 있습니다.

이제 정말 완성입니다! 👏👏👏

🔗 Github 링크

React Data Table에 소스코드가 공개되어 있습니다.

profile
주니어 프론트엔드 개발자

2개의 댓글

comment-user-thumbnail
2023년 1월 3일

잘보고갑니다! 이미지, 코드를 같이 보여주셔서 이해하는 데 도움이 많이됐습니다!! 공유해주셔서 감사합니다 : )

1개의 답글