게으른개발너D

[React] Handsontable 3 - useRef, hooks, and closure issue 본문

프로젝트/Side Project

[React] Handsontable 3 - useRef, hooks, and closure issue

lazyhysong 2023. 11. 16. 16:50

 

2023.11.04 - [프로젝트/Side Project] - [React] Handsontable 1 - 설치 및 데이터 추가

 

[React] Handsontable 1 - 설치 및 데이터 추가

Handsontable을 이용하여 간단한 기능을 구현하려다 의외로 개고생을 하여 기록한다. https://handsontable.com/docs/react-data-grid/ React Data Grid - Documentation | Handsontable Handsontable documentation What is Handsontable? Hand

lazyhysong.tistory.com

2023.11.05 - [프로젝트/Side Project] - [React] Handsontable 2 - headers and columns for readonly

 

[React] Handsontable 2 - headers and columns for readonly

2023.11.04 - [프로젝트/Side Project] - [React] Handsontable 1 - 설치 및 데이터 추가 [React] Handsontable을 이용한 스프레드시트 구현 Handsontable을 이용하여 간단한 기능을 구현하려다 의외로 개고생을 하여 기

lazyhysong.tistory.com

 

 

이제 본격적으로 원하는 기능들을 구현하려고 한다.

구현할 내용

이름 생년월일 모임 만남 횟수
James 1999-08-30 알고리즘 2
Emily 2000-12-24 프론트엔드 5
Amber 1988-06-14 알고리즘 3
Lisa 1994-07-03 돼지파티 13

 

1. 해당 내용을 table data로 넣는다.

2. 이름, 생년월일, 모임 열은 내용을 수정할 수 없다.

3. 컨텍스트메뉴 기능을 넣어 행과 열을 추가하거나 삭제할 수 있다. (1행은 삭제하지 못한다.)

4. 저장 버튼을 클릭 시 console.log로 삭제된 행과 열에 대한 정보를 출력한다.

5. 새로운 행이 추가 되었을 시, 저장 버튼을 누르면 해당 행의 이름, 생년월일, 모일 열은 수정할 수 없게된다.

 

1. useRef

Handsontable에서는 HotTable 컴포넌트를 이용한 데이터 처리가 많기때문에 useRef를 자주 이용한다.

const hotRef = useRef<HotTable>(null);

...

return <HotTable ref={hotRef} />

 

typescript를 사용할 경우 HotTable의 type은 HotTable로 지정하면 된다.

 

https://handsontable.com/docs/react-data-grid/api/core/

 

Core methods API reference - React Data Grid | Handsontable

Core Description The Handsontable class (known as the Core) lets you modify the grid's behavior by using Handsontable's public API methods. To use these methods, associate a Handsontable instance with your instance of the HotTable component, by using React

handsontable.com

여기를 보면 useRef를 이용하여 HotTable에 적용할 method를 부르는 방법이 자세하게 설명되어 있다.

 

이전 글에서처럼 나는 해당 core를 readonly를 적용하는데 썼다.

function App() {
  const hotRef = useRef<HotTable>(null);
  const [readOnlyCols, setReadOnlyCols] = useState([true, true, true, false]);
  const [nowAddedRowsIdx, setNowAddedRowsIdx] = useState<number[]>([]);

  useEffect(() => {
    const hot = hotRef?.current?.hotInstance;
    // 여기서 hot은 Handsontable 또는 null이기 때문에 hot이 존재할 경우라는 조건문을 추가해야한다.
    if (hot) {
      setReadOnlyColumns(hot, readOnlyCols, nowAddedRowsIdx);
    }
  }, [readOnlyCols, nowAddedRowsIdx]);

  const setReadOnlyColumns = (
    hot: Handsontable,
    cols: boolean[],
    rows: number[]
  ) => {
    hot.updateSettings({
      cells(row: number, col: number) {
        // 각 cell들의 property 객체 차입은 Handsontable.CellMeta이다.
        const cellProperties: Handsontable.CellMeta = {};
        if (!rows.includes(row) && cols[col]) {
          // cellProperties.readonly = true; 로 해주어도 됨
          cellProperties.editor = false;
          cellProperties.allowRemoveColumn = false;
        }
        // 0번째 행은 제목이므로 삭제되어선 안된다.
        if (row === 0) {
          cellProperties.allowRemoveColumn = false;
        }
        return cellProperties;
      },
    });
  };
  
  ...
  
  return (
    <HotTable ref={hotRef} />
  );
 };

 

 

2. Hooks

2.1 dir structure

원하는 기능들을 모두 App.tsx에 넣기에는 코드가 너무 길다. 해당 코드들을 재활용할 수 있도록 hook으로 따로 빼내어 사용하려고 한다.

또한 테이블의 default 데이터나 readonly를 적용할 열에대한 정보는 처음이 항상 고정되어 있으니 default_data.ts 파일을 만들어 해당 데이터를 따로 빼낼려고 한다.

 

// default_data.ts

export const DEFAULT_DATA = [
  ['이름', '생년월일', '모임', '만남 횟수'],
  ['James', '1999-08-30', '알고리즘', 2],
  ['Emily', '2000-12-24', '프론트엔드', 5],
  ['Amber', '1988-06-14', '알고리즘', 3],
  ['Lisa', '1994-07-03', '돼지파티', 13],
];

export const READONLY_STATE = [true, true, true, false];

 

이렇게 한 후 App.tsx에서 해당 데이터들을 import하여 사용한다.

 

hook은 hooks라는 디렉토리를 만든 후 useHotHooks.ts 파일을 만들어 그 안에 코드를 넣기로 했다.

 

2.2 hook initial setting

useHotHook은 initialData와 readOnlys를 매개변수로 받아 테이블 데이터들을 관리한다.

여기서 initialData는 defalut_data.ts에서 DEFAULT_DATA이며, readOnlys는 READONLY_STATE이다.

// useHotHooks.ts

import { useRef, useState } from 'react';

export const useHotHooks = (
  initialData: (string | number | undefined)[][],
  readOnlys: boolean[]
) => {
  const hotRef = useRef<HotTable>(null);
  const [initData, setInitData] = useState(initialData);
  const [readOnlyCols, setReadOnlyCols] = useState(readOnlys);
  const [nowAddedRowsIdx, setNowAddedRowsIdx] = useState<number[]>([]);

  return {
    hotRef,
    setInitData,
  };
};

 

데이터를 관리한 후 hotRef를 HotTable에서 사용할 수 있도록 hotRef를 return 한다.

그리고 저장버튼을 클릭했을 때 initialData를 변경할 수 있도록 setInitData도 반환값으로 넘겨주었다.

 

App.tsx에서는 이렇게 사용하면 된다.

// App.tsx

import { DEFAULT_DATA, READONLY_STATE } from './default_data';
import { useHotHooks } from './hooks/useHotHooks';

// ...

function App() {
  const { hotRef, setInitData } = useHotHooks(
    DEFAULT_DATA,
    READONLY_STATE
  );

  // ...
  
  return (
    <div className="App">
      <Button callback={handleClickSave}>저장</Button>

      <HotTable
        ref={hotRef}
        data={DEFAULT_DATA}
        contextMenu={CONTEXT_MENU}
        {...HOTTABLE_PROPS}
      />
    </div>
  );
}

export default App;

 

2.3 hook setting

이제 해당 기능들을 추가하려고 한다.

1. 저장 버튼을 눌렀을 때 table의 데이터과 변화된 데이터간의 차이를 편하게 알기 위한 compare state 선언

행이나 열을 추가하였을 때 compare엔 '' 비어있는 string 객체를 넣어주려고 한다.

App.tsx에서 사용해야하므로 return 값에 추가해준다.

2. 앞에서 구현하였던 readonly property를 update하는 useEffect 추가

3. addHook 메소드를 이용하여 table에 필요한 함수들 정의

행, 열을 추가하거나 삭제할 때 변경시켜야할 state를 업데이트한다.

처음 한번만 정의하면 되므로 useEffect 안에 필요한 함수들을 정의하였다.

4. App.tsx에서 setInitData를 변경하였을 때 compare state는 initData와 같도록, nowAddedRowsIdx는 빈 배열로 업데이트한다.

// useHotHooks.ts

import { useEffect, useRef, useState } from 'react';
import Handsontable from 'handsontable/base';
import { HotTable } from '@handsontable/react';

type HandleBeforeRenderer = (TD: HTMLTableCellElement, row: number) => void;
type HandleCols = (col: number) => void;
type HandleRows = (row: number) => void;

export const useHotHooks = (
  initialData: (string | number | undefined)[][],
  readOnlys: boolean[]
) => {
  const hotRef = useRef<HotTable>(null);
  const [initData, setInitData] = useState(initialData);
  // 1. compare state 선언
  const [compare, setCompare] = useState(initialData);
  const [readOnlyCols, setReadOnlyCols] = useState(readOnlys);
  const [nowAddedRowsIdx, setNowAddedRowsIdx] = useState<number[]>([]);

  // 4. App.tsx에서 setInitData를 변경하였을 때
  useEffect(() => {
    setNowAddedRowsIdx([]);
    setCompare(initData);
  }, [initData]);

  // 2. readonly property를 update하는 useEffect
  useEffect(() => {
    const hot = hotRef?.current?.hotInstance;
    if (hot) {
      setReadOnlyColumns(hot, readOnlyCols, nowAddedRowsIdx);
    }
  }, [readOnlyCols, nowAddedRowsIdx]);

  const setReadOnlyColumns = (
    hot: Handsontable,
    cols: boolean[],
    rows: number[]
  ) => {
    hot.updateSettings({
      cells(row: number, col: number) {
        const cellProperties: Handsontable.CellMeta = {};
        if (!rows.includes(row) && cols[col]) {
          cellProperties.editor = false;
          cellProperties.allowRemoveColumn = false;
        }
        if (row === 0) {
          cellProperties.allowRemoveColumn = false;
        }
        return cellProperties;
      },
    });
  };

  // 3. addHook 메소드를 이용하여 table에 필요한 함수들 정의
  useEffect(() => {
    const hot = hotRef?.current?.hotInstance;
    if (hot) {
      hot.addHook('beforeRenderer', handleBeforeRenderer);
      hot.addHook('afterCreateCol', handleAfterCreateCol);
      hot.addHook('afterCreateRow', handleAfterCreateRow);
      hot.addHook('beforeRemoveCol', handleBeforeRemoveCol);
      hot.addHook('beforeRemoveRow', handleBeforeRemoveRow);
    }
  }, []);

  const handleBeforeRenderer: HandleBeforeRenderer = (TD, row) => {};
  const handleAfterCreateCol: HandleCols = (col) => {};
  const handleAfterCreateRow: HandleRows = (row) => {};
  const handleBeforeRemoveCol: HandleCols = (col) => {};
  const handleBeforeRemoveRow: HandleRows = (row) => {};

  return {
    hotRef,
    compare,
    setInitData,
  };
};

 

2.4 handle callbacks

이제 ref에 추가해준 hook의 callback 함수들을 구현할 것이다.

https://handsontable.com/docs/react-data-grid/api/hooks/

 

Hooks API reference - React Data Grid | Handsontable

 

handsontable.com

여기서 각 hook들을 사용하는 방법과 callback 함수의 매개변수들을 알 수 있다.

 

1. beforeRenderer: HotTable이 render되기 전 호출

type HandleBeforeRenderer = (TD: HTMLTableCellElement, row: number) => void;

const handleBeforeRenderer: HandleBeforeRenderer = (TD, row) => {
    const parentElement = TD.parentElement;
    if (parentElement === null) {
      return;
    }
    if (row === 0) {
      parentElement.classList.add('head');
    }
};

 

여기서 TD는 각각의 cell들을 의미한다. TD.parentElement는 tr이다.

0번째 행이 제목들이므로  css에 head 라는 className으로 스타일을 적용시켰다.

 

cf) 해당 기능을 이용하여 readonly property를 넣을 수도 있겠지만 말 그대로 HotTable이 render되기전 구현이다. 그래서 행이나 열을 추가하면 처음 readonly를 적용한 index에만 readonly가 적용되어, 원하지 않는 열이 수정될 경우가 있다.

 

 

2. afterCreateCol, afterCreateRow: 각각 열과 행이 추가된 후에 호출

type HandleCols = (col: number) => void;
type HandleRows = (row: number) => void;

const handleAfterCreateCol: HandleCols = (col) => {
   setReadOnlyCols((prev) => [
      ...prev.slice(0, col),
      false,
      ...prev.slice(col),
   ]);
   setCompare((prev) =>
      [...prev].map((com) => [...com.slice(0, col), '', ...com.slice(col)])
   );
};

const handleAfterCreateRow: HandleRows = (row) => {
   setNowAddedRowsIdx((prev) => [...prev, row]);
   setCompare((prev) => {
      const rowLen = [...prev][0].length;
      const addArr = Array(rowLen).fill('');
      return [...prev.slice(0, row), addArr, ...prev.slice(row)];
   });
};

 

새로운 열이 추가된 후에 해당 열의 readOnlyCols는 false로 추가되어야하므로 setReadOnlyCols로 업데이트한다.

그리고 compare는 해당 열에 ''를 추가하여 새롭게 배열을 만들어 업데이트한다.

 

새로운 행이 추가되면 nowAddedRowsIdx에 해당 행의 Index를 추가한다.

compare는 해당 행에 ''로 이루어진 새로운 배열을 추가하여 업데이트한다.

 

 

3. beforeRemoveCol, beforeRemoveRow: 각각 열과 행을 삭제하기 전에 호출

false를 반환하면 삭제가 실행되지 않는다.

type HandleCols = (col: number) => void;
type HandleRows = (row: number) => void;

const handleBeforeRemoveCol: HandleCols = (col) => {
   if (readOnlyCols[col]) {
      return false;
   }
   setCompare((prev) =>
      [...prev].map((com) => [...com.slice(0, col), ...com.slice(col + 1)])
   );
   setReadOnlyCols((prev) => [...prev.slice(0, col), ...prev.slice(col + 1)]);
};

const handleBeforeRemoveRow: HandleRows = (row) => {
   if (row === 0) {
      return false;
   }
   if (nowAddedRowsIdx.includes(row)) {
      setNowAddedRowsIdx((prev) => {
        const idx = prev.indexOf(row);
        return [...prev.slice(0, idx), ...prev.slice(idx + 1)];
      });
   }
   setCompare((prev) => [...prev.slice(0, row), ...prev.slice(row + 1)]);
};

 

열을 삭제할 경우, 해당 열의 index가 readOnlyCols에서 true이면 readonly에 해당되므로 삭제되면 안된다. 따라서 이 경우엔 fasle를 반환하여 삭제되지 못하도록 한다.

그리고 삭제할 열에 대해 compare과 readOnlyCols를 업데이트한다.

 

행을 삭제할 경우, 해당 행이 0번째 행이면 삭제되지 못하도록 false를 return 한다.

그리고 삭제될 행에 대해 compare과 nowAddedRowsIdx를 업데이트한다.

 

 

2.5 App.tsx에서의 hook 사용

App.tsx에서는 저장 버튼을 클릭시 handleClickSave를 실행하여 변경된 데이터에 대한 정보를 콘솔에 출력한다.

1. 필요한 type 정의

2. handleClickSave 함수 정의

  getData로 현재 table 데이터를 받아온 후 compare과 비교

// App.tsx

import { DEFAULT_DATA, READONLY_STATE } from './default_data';
import { useHotHooks } from './hooks/useHotHooks';

// ...

// 1. 필요한 type 정의
type Data = (string | number | undefined)[][];
type TDate<T> = { [key: number]: T };
interface IHead {
  added: TDate<string | undefined>;
  deleted: TDate<undefined>;
  updated: TDate<string | undefined>;
}
interface IBody {
  added: TDate<(string | number)[]>;
  deleted: TDate<undefined>;
  updated: TDate<TDate<string | number>>;
}
interface IData {
  head: IHead;
  body: IBody;
}

function App() {
  const { hotRef, compare, setInitData } = useHotHooks(
    DEFAULT_DATA,
    READONLY_STATE
  );

  // ...
  
  // 2. handleClickSave 함수 정의
  const handleClickSave = () => {
    const hot = hotRef?.current?.hotInstance;
    const printData: IData = {
      head: { added: {}, deleted: {}, updated: {} },
      body: { added: {}, deleted: {}, updated: {} },
    };
    if (hot) {
      const nowData = hot.getData();
      const compareData = [...compare];
      const headData = setHeadData(nowData, compareData);
      const bodyData = setBodyData(nowData, compareData);
      const newPrintData = {
        ...printData,
        head: headData,
        cody: bodyData,
      };

      setInitData(nowData);
      console.log(newPrintData);
    }
  };
  const setHeadData = (data: Data, compare: Data) => {};
  const setBodyData = (data: Data, compare: Data) => {};
  
  return (
    <div className="App">
      <Button callback={handleClickSave}>저장</Button>

      <HotTable
        ref={hotRef}
        data={DEFAULT_DATA}
        contextMenu={CONTEXT_MENU}
        {...HOTTABLE_PROPS}
      />
    </div>
  );
}

export default App;

 

여기서 table의 현재 data는 getData로 받아올 수 있다.

 

 

 

3. closure issue

테스트를 하다가 버그를 발견하였다.

바로 beforeRemoveCol과 beforeRemoveRow hook이 제대로 적용되지 않는다는 점이다.

예를들어 1번째 인덱스에 새로운 열을 추가하고 해당 열을 삭제하면 삭제가 되어야하는데 안된다는 점!

 

원인을 알기위해 handleBeforeRemoveCol 함수 안과 바깥에 readOnlyCols state를 찍어보았다.

console.log('1번', readOnlyCols);

const handleBeforeRemoveCol: HandleCols = (col) => {
   console.log('2번', readOnlyCols);
   if (readOnlyCols[col]) {
      return false;
   }
   setCompare((prev) =>
      [...prev].map((com) => [...com.slice(0, col), ...com.slice(col + 1)])
   );
   setReadOnlyCols((prev) => [...prev.slice(0, col), ...prev.slice(col + 1)]);
};

 

3.1 버그 발견

새로운 열을 추가한 후 해당 열 삭제를 클릭하면 결과는 이렇게 나온다.

1번 [true, false, true, true, false]

2번 [true, true, true, fasle]

 

이렇게 1번째 index에 새로운 열을 추가하였을 경우 readOnlyCols는 1번처럼 [true, false, true, true, false]로 찍힌다.

그리고 해당 열을 삭제하면 handleBeforeRemoveCol가 실행되고 2번 또한 [true, false, true, true, false]로 찍혀야하는데 결과는 [true, true, true, fasle]로 찍한다.

 

마치 handleBeforeRemoveCol 함수가 readOnlyCols의 initial 값을 저장하기라도 하는 것처럼 말이다.

 

3.2 원인

hook을 추가하기위해 useEffect 안에 addHook이라는 method를 이용하여 hook을 추가해주었다.

해당 method가 callback 함수를 인자로 가지는 고차함수인 것이 눈에 보였다.

혹시나싶어서 Handsontable github에 들어가서 addHook 코드를 뜯어보았다.

 

결과는 addHook을 호출하면 globalSingleton이라는 인스턴스를 return 하는데, 이 globalSingleton에서는 callback 함수를 return해주고 있다.

 

결국엔 우리가 정의한 callback 함수가 closure로 쓰인다는 것이다.

https://developer.mozilla.org/ko/docs/Web/JavaScript/Closures

 

클로저 - JavaScript | MDN

클로저는 주변 상태(어휘적 환경)에 대한 참조와 함께 묶인(포함된) 함수의 조합입니다. 즉, 클로저는 내부 함수에서 외부 함수의 범위에 대한 접근을 제공합니다. JavaScript에서 클로저는 함수 생

developer.mozilla.org

 

클로저는 외부함수의 변수 값을 저장한다. 따라서 closure인 handleBeforeRemoveCol은 readOnlyCols의 초기값을 저장하여 해당 함수 스코프 안에서 readOnlyCols를 변화해주지 않는 이상 초기의 readOnlyCols값만 사용하는 것이다.

😫

 

3.4 결과

결론은 addHook 메소드를 사용하지 말고 함수들을 hooks라는 변수로 빼내서 App.tsx에서 사용할 수 있도록 반환해주는 것이다.

// useHotHooks.ts

  // useEffect(() => {
  //   const hot = hotRef?.current?.hotInstance;
  //   if (hot) {
  //     hot.addHook('beforeRenderer', handleBeforeRenderer);
  //     hot.addHook('afterCreateCol', handleAfterCreateCol);
  //     hot.addHook('afterCreateRow', handleAfterCreateRow);
  //     hot.addHook('beforeRemoveCol', handleBeforeRemoveCol);
  //     hot.addHook('beforeRemoveRow', handleBeforeRemoveRow);
  //   }
  // }, []);
  // issue: closer가 함수안의 state값을 저장하므로 바뀐 state가 적용되지 않는다.
  
  // ...
  
  return {
    hotRef,
    compare,
    setInitData,
    hooks: {
      beforeRenderer: handleBeforeRenderer,
      afterCreateCol: handleAfterCreateCol,
      afterCreateRow: handleAfterCreateRow,
      beforeRemoveCol: handleBeforeRemoveCol,
      beforeRemoveRow: handleBeforeRemoveRow,
    },
  };

 

App.tsx에서는 이렇게 사용한다.

// App.tsx

  const { hotRef, compare, setInitData, hooks } = useHotHooks(
     DEFAULT_DATA,
     READONLY_STATE
  );
  
  // ...
  
  return (
     <HotTable
        ref={hotRef}
        data={DEFAULT_DATA}
        contextMenu={CONTEXT_MENU}
        {...HOTTABLE_PROPS}
        {...hooks}
     />
  );

 

이렇게하면 hooks method들을 바로 component에 적용시키기때문에 closure issue로 버그를 일으키지 않는다.

 

 

 

 

 

소스 코드

https://github.com/eee0930/manage_crew

 

GitHub - eee0930/manage_crew: Manage crew using handsontable for react data grid

Manage crew using handsontable for react data grid - GitHub - eee0930/manage_crew: Manage crew using handsontable for react data grid

github.com

 

Comments