Post

리액트 합성 컴포넌트와 커스텀훅

리액트 합성 컴포넌트와 커스텀훅

💡 합성 컴포넌트의 의미


합성 컴포넌트를 많은 리액트 개발자들이 선택하는 이유를 먼저 생각해보자. 핵심 정의에서부터 시작하자!

Sub-컴포넌트들이 Main-컴포넌트 내부의 상태를 공유하면서 비즈니스 로직과 UI 관련된 부분을 구분하는 리액트 패턴. 여러 개의 작은 컴포넌트들이 각각의 책임을 분담하여 이를 자유롭게 조합하여 하나의 큰 컴포넌트를 만드는 것. // from 링크 //

핵심은 “책임”과 “조합”이다. 재사용성이 높게 만들기 위해서는, 책임을 컴포넌트들이 나눠가져야 다양한 케이스로 조합할 수 있기 때문이다. 상황에 따라 모양을 다르게 표기하거나, 이벤트 발생시 동작이 달라진다면?

하나의 컴포넌트에서 조건 분기가 매우 복잡해지거나, 요구사항이 바뀌고 추가할 때마다 props가 쌓이는 불편한 문제가 발생할 것이다.

🐝 HoneyFlow에 적용해보기


HoneyFlow에서 재사용될 수 있는 컴포넌트가 어떤 것이 있을까? 일단 기본적인 Node를 생각해보자.

Node의 종류, 형태, 기능을 최대한 나열해보면 다음과 같이 정리할 수 있다.

  • HeadNode

    → 스페이스명 & 원 표시

    → 노드 생성(드래그) 시작점

  • ContentNode

    • NoteNode

      → 노트명 & 원 표시

      → 노드 생성(드래그) 시작점

      → 위치이동

    • ImageNode

      → 이미지 & 사각형 표시

      → 노드 생성(드래그) 시작점

      → 위치 이동

    • UrlNode

      → url & 원 표시

      → 노드 생성(드래그) 시작점

      → 위치 이동

    • SubspaceNode

      → 스페이스명 & 원 표시

      → 노드 생성(드래그) 시작점

      → 위치 이동

1️⃣ 기본 Node


  • Node는 상대 좌표를 받아서 노드의 위치를 잡는다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
export default function Node({
  x,
  y,
  draggable,
  children,
  ...rest
}: NodeProps) {

  return (
    <Group
      x={x}
      y={y}
      draggable={draggable}
      onDragStart={handleDragStart}
      {...rest}
    >
      {children}
    </Group>
  );
}

그럼 형태는 어떻게 잡는가? 여기서부터 합성 컴포넌트 패턴을 적용하여 Node.Circle, Node.Text, Node.Rect 등을 통해 실제 화면에 표시할 “형태”의 책임을 담당한다.

// Node.Circle (반지름, 색을 받는다)

1
2
3
Node.Circle = function NodeCircle({ radius, fill }: NodeCircleProps) {
  return <Circle x={0} y={0} radius={radius} fill={fill} />;
};

// Node.Text (내용, 글씨크기, 폰트, 너비를 받는다. 기본적으로 중앙 정렬)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
Node.Text = function NodeText({
  content,
  fontSize,
  fontStyle,
  width,
}: NodeTextProps) {
  ...

  return (
    <Text
      ref={ref}
      fontSize={fontSize}
      fontStyle={fontStyle}
      offset={offset}
      text={content}
      width={width}
      align="center"
      wrap="none"
      ellipsis
    />
  );
};

2️⃣ Node 종류


이제 구현 단계에서 로직과 결합된 실제 Main 컴포넌트들을 정의할 차례다. 크게 HeadNode와 ContentNode의 종류들인 NoteNode, UrlNode… 등으로 나뉜다.

// HeadNode 좌표가 고정되어있으므로, 스페이스명만 받는다.

  • Node로 위치 고정
    • Node.Circle로 원의 형태 표시
    • Node.Text로 스페이스명 표시
1
2
3
4
5
6
7
8
9
10
11
12
13
14
export function HeadNode({ name }: HeadNodeProps) {
  const radius = 64;
  return (
    <Node x={0} y={0}>
      <Node.Circle radius={radius} fill="#FFCC00" />
      <Node.Text
        width={radius * 2}
        fontSize={16}
        fontStyle="700"
        content={name}
      />
    </Node>
  );
}

// NoteNode 좌표, 노트명만 받는다.

  • Node로 위치 고정
    • Node.Circle로 원의 형태 표시
    • Node.Text로 노트명 표시

팀이 초기 설계할 때 원했던 대로 Node.Circle, Node.Text를 자유롭게 조합하여 정의하는 형태가 되었다.

별도의 컴포넌트로 분리하기보다는, 커스텀훅으로 분리하여 사용하기로 했다. 사실 나는 아직 커스텀훅을 많이 사용해본 적이 없다 🥲

데이터 패칭하는 부분만 분리해본 정도. 따라서, 이번 기회로 커스텀훅 Best Practice를 살펴보자.

3️⃣ 이벤트 핸들링 로직 커스텀 훅으로 분리하기


📒 기초바탕 쌓기

공식 문서의 정의 “Hook” 정의는 아래와 같다.

훅이란, 컴포넌트의 위계를 변경하지 않으면서도 state 변화가 들어 있는 로직(stateful logic)을 재사용하도록 해 준다.

실제로 적용하기에 유용한 상황을 통해 이해를 더 구체화해보자. 내가 읽은 아티클에서는 다음의 상황을 가정한다.

  • 스크린 사이즈에 따라 UI 변경
  • 사용자가 브라우저 창을 변경할 때 창의 크기 추적
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
const ScreenDimensions = () => {
	
	// 창의 크기를 상태로 관리 
  const [windowSize, setWindowSize] = useState({
    width: undefined,
    height: undefined,
  });
  
	// 스크린 사이즈 조정
  useEffect(() => {
    function handleResize() {
      setWindowSize({
        width: window.innerWidth,
        height: window.innerHeight,
      });
    }
    window.addEventListener('resize', handleResize);
    handleResize();
    return () => window.removeEventListener('resize', handleResize);
  }, []);
  
  return (
  	<>
    	<p>Current screen width: {windowSize.width}</p>
        <p>Current screen height: {windowSize.height}</p>
    </>
  )
}

이때 스마트폰, 데스크톱용으로 사이즈마다 UI를 다르게 렌더링할 때 어떻게 적용할 수 있을까? 렌더링을 달리하는 두 컴포넌트에 로직을 그대로 복사해서 사용할 수도 있지만, 해당 로직이 변경되었을 때 수정 비용이 커진다.

따라서 아래와 같이 useWindowSize로 재사용되는 로직을 분리해보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const **useWindowSize** = () => {
  const [windowSize, setWindowSize] = useState({
    width: undefined,
    height: undefined,
  });
  
  useEffect(() => {
    function handleResize() {
      setWindowSize({
        width: window.innerWidth,
        height: window.innerHeight,
      });
    }
    window.addEventListener('resize', handleResize);
    handleResize();
    return () => window.removeEventListener('resize', handleResize);
  }, []);
  
  return windowSize;
}

그러면 해당 로직이 사용되는 곳 어디든, 아래와 같이 간단한 형태로 호출하여 활용할 수 있다.

1
2
3
4
5
6
7
8
9
10
const ScreenDimensions = () => {
  **const windowSize = useWindowSize()**
  
  return (
  	<>
    	<p>Current screen width: {windowSize.width}</p>
        <p>Current screen height: {windowSize.height}</p>
    </>
  )
}

Reference

📒 HoneyFlow에 적용해서 수정하기

내가 생각한 수정의 방향을 다이어그램으로 그려달라고 claude에게 부탁했다 ㅎ.ㅎ 객체지향 다이어그램 그릴 때처럼, -는 함수 내부에서만 알고, +는 props와 같이 외부와 공유하는 값이다.

  • deprecated

      classDiagram
          SpaceView --> useNodeDrag
          SpaceView --> HeadNode
          SpaceView --> NoteNode
          SpaceView --> PaletteMenu
          HeadNode --> Node
          NoteNode --> Node
        
          class SpaceView {
              -stageSize
              +render()
          }
        
          class useNodeDrag {
              -dragStartNodeId
              -palettePosition
              +handleDragStart()
              +handleDragEnd()
              +handlePaletteSelect()
          }
        
          class Node {
              +x
              +y
              +draggable
              +children
          }
        
          class HeadNode {
              +name
          }
        
          class NoteNode {
              +x
              +y
              +name
          }
        
          class PaletteMenu {
              +items
              +onSelect
          }
    
      classDiagram
          SpaceView --> useNodeDrag
          SpaceView --> useNodeOperations
          SpaceView --> HeadNode
          SpaceView --> NoteNode
          SpaceView --> PaletteMenu
          HeadNode --> Node
          NoteNode --> Node
          useNodeDrag ..> useNodeOperations : uses operations
          PaletteMenu ..> Node : uses Node["type"]
        
          class SpaceView {
              -stageSize
              -nodes
              -setNodes
              +render()
          }
        
          class useNodeOperations {
              -nodes
              -setNodes
              -stageSize
              +onCreateNode()
          }
        
          class useNodeDrag {
              -dragState
              -nodeOperations
              +handleDragStart()
              +handleDragEnd()
              +handlePaletteSelect()
          }
        
          class Node {
              +x
              +y
              +draggable
              +children
          }
        
          class PaletteMenu {
              +items
              +onSelect
              -buttonConfig
              -getPositionByIndex()
          }
        
          class HeadNode {
              +name
              +onDragStart
          }
        
          class NoteNode {
              +x
              +y
              +name
              +onDragStart
          }
        
          note for SpaceView "상태 관리 및\n컴포넌트 조율"
          note for useNodeOperations "노드 조작 로직"
          note for useNodeDrag "드래그 관련 로직"
    
      classDiagram
          SpaceView --> useNodeDrag
          SpaceView --> useNodeOperations
          SpaceView --> HeadNode
          SpaceView --> NoteNode
          SpaceView --> Edge
          SpaceView --> PaletteMenu
          HeadNode --> Node
          NoteNode --> Node
          useNodeDrag ..> useNodeOperations : uses operations
          PaletteMenu ..> NodeType : uses type
          Edge ..> NodeType : uses for positions
        
          class SpaceView {
              -stageSize
              -nodes
              -edges
              -setNodes()
              -setEdges()
              +render()
          }
        
          class useNodeOperations {
              -setNodes
              -setEdges
              -stageSize
              +onCreateNode()
          }
        
          class useNodeDrag {
              -dragState
              -nodeOperations
              +handleDragStart()
              +handleDragEnd()
              +handlePaletteSelect()
          }
        
          class Node {
              +id
              +type
              +name
              +x
              +y
          }
        
          class PaletteMenu {
              +items
              +onSelect()
              -buttonConfig
              -getPositionByIndex()
          }
        
          class HeadNode {
              +name
              +onDragStart()
          }
        
          class NoteNode {
              +x
              +y
              +name
              +onDragStart()
          }
        
          class Edge {
              +from
              +to
              +nodes
          }
        
          note for SpaceView "스테이지 크기 관리\n노드/엣지 상태 관리"
          note for useNodeOperations "노드/엣지 생성 로직"
          note for useNodeDrag "드래그 시작/종료 처리\n팔레트 선택 처리"
          note for PaletteMenu "노드 타입 선택 UI"
    
This post is licensed under CC BY 4.0 by the author.