React 컴포넌트를 잘 만들어보자!

코린·2024년 1월 10일
0

리액트

목록 보기
19/22
post-thumbnail

리액트의 장점

  • 다양한 라이브러리
  • Virtual DOM

등 많은 장점들이 있는데요

오늘은 그 중에서도

컴포넌트 재사용성이 높음 을 중심적으로 살펴보고자 합니다!

우선 리액트는 컴포넌트 기반 구조를 가지고 있으며
PropsState로 컴포넌트 간 데이터를 효율적으로 전달하고 관리할 수 있습니다.

잘 만든 컴포넌트?

  1. 재사용성

  2. 관심사 분리

  3. 응집도가 높은

-> 변경에 유연한 코드!

React 컴포넌트를 어떻게 더 잘 추상화 할 수 있느냐! 가 관건입니다.

Headless

소프트웨어 개발에서 주로 사용되는 용어로, 사용자 인터페이스(UI)를 가지지 않은 컴포넌트 또는 서비스를 이야기

예시 Headless Input Component

Headless 컴포넌트 만들기

Compound Component

여러 개의 작은 컴포넌트들이 각각의 역할을 분담하도록 하고 이를 조립해 하나의 큰 컴포넌트를 만드는 것이다.

이렇게 다양한 형태의 카드를 작성하기 위해선 어떻게 해야 할까요?

Props로 입력받는 경우

// prop 추가로 복잡해진 카드 컴포넌트
const CardItem = ({ 
  imageUrl, 
  tagNumber, 
  name,
  description,
  rounded,
  lineCnt,
  className,
  ...
}: Props) => {
  ...
  return (
    <div className={className}>
      {imageUrl && <img src={imageUrl} style={rounded ? { borderRadius: '50%' } : undefined}/>}
      <div>
        <span>{tagNumber}</span>
        <span>{name}</span>
      </div>
      {description && <div>...</div>}
      ...
    </div>
  );
}

보통 이렇게 Props 로 입력받아서 처리해야 겠다~ 라고 생각합니다. 하지만 너무 복잡하다는 단점이 있죠

카드 형태의 아이템을 보여준다 라는 책임을 더 세분화하여 문제를 해결할 수 있습니다.

  • 카드 형태의 아이템을 보여준다.
    • 썸네일이 둥근
    • 썸네일이 사각형
    • 썸네일 여러개
      ...

CardItem의 책임을 RoundCardItem,SquareCardItem,MultiThumnailCard 등으로 적절히 나누면 복잡한 컴포넌트도, 불필요한 의존성도 없이 문제를 해결할 수 있게 됩니다.

책임을 적절하게 나눈 경우

const CardThumbnail = ({ url, size, rounded, className }: Props) => ...
const CardBody = ({ className, align, children }: Props) => ...
const CardTitle = ({ className, lineCnt, children }: Props) => ...
const CardIcon = () => ...

const RoundCardItem = (...) => {
  return (
    <div>
      <CardThumbnail url={imageUrl} rounded />
      <CardBody align="center">
        <CardTitle>{tagNumber}</CardTitle>
        <CardTitle lineCnt={2}>{name}</CardTitle>
      </CardBody>
    </div>
  )
}
...

카드의 구성 요소들을 CardThumbnail, CardBody, CardTitle, CardIcon으로 나누어 추상화하였고, 이것들을 원하는 대로 조합하여 RoundCardItem, SquareCardItem등과 같은 다양한 카드 컴포넌트를 만들 수 있게 되었습니다. 책임을 세분화하여 다양한 카드 형태에 대응할 수 있는 유연한 구조가 되었습니다.

Compound Component와 Context API를 함께 이용한 예시

Context API

const InputContext = React.createContext({
  id: "",
  value: "",
  type: "text",
  onchange: () => {},
});

Context API를 이용해 컴포넌트 내부에서 공유할 데이터를 정의

부모 컴포넌트

export const InputWrapper = ({ id, value, type, onChange, children }) => {
  const contextValue = { id, value, type, onChange };
  return (
    <InputContext.Provider value={contextValue}>
      {children}
    </InputContext.Provider>
  );
};

Context API를 통해 데이터를 공유할 수 있도록 설정

자식 컴포넌트

const Input = ({ ...props }) => {
  const { id, value, type, onChange } = useContext(InputContext);
  return (
    <input id={id} value={value} type={type} onChange={onChange} {...props} />
  );
};

const Label = ({ children, ...props }) => {
  const id = useContext(InputContext);
  return (
    <label id={id} {...props}>
      {children}
    </label>
  );
};

Context API를 통해 데이터를 사용ㅇ함
부모 컴포넌트가 props로 받아서 Context에 저장시킨 데이터 가져옴
추가로 자신만의 props도 정의 가능

Props 설정

InputWrapper.Input = Input;
InputWrapper.Label = Label;

자식 컴포넌트를 부모 컴포넌트의 props로 등록

전체코드

import React, { useContext } from "react";

/** context api를 이용해서 컴포넌트 내부에서 공유할 데이터를 정의함 */
const InputContext = React.createContext({
  id: "",
  value: "",
  type: "text",
  onchange: () => {},
});

/** 부모 컴포넌트, context api를 통해 데이터를 공유할 수 있도록 설정 */
export const InputWrapper = ({ id, value, type, onChange, children }) => {
  const contextValue = { id, value, type, onChange };
  return (
    <InputContext.Provider value={contextValue}>
      {children}
    </InputContext.Provider>
  );
};

/** 자식 컴포넌트, context api를 통해 데이터 사용함
	부모 컴포넌트가 props로 받아서 context에 저장시킨 기본 데이터도 가져오고, 추가로 자신만의 props를 받아서 사용할 수도 있음
*/
const Input = ({ ...props }) => {
  const { id, value, type, onChange } = useContext(InputContext);
  return (
    <input id={id} value={value} type={type} onChange={onChange} {...props} />
  );
};

const Label = ({ children, ...props }) => {
  const id = useContext(InputContext);
  return (
    <label id={id} {...props}>
      {children}
    </label>
  );
};

/** 자식 컴포넌트를 부모 컴포넌트의 props로 등록함 */
InputWrapper.Input = Input;
InputWrapper.Label = Label;

실제로 불러와서 사용하게 되면 아래와 같습니다.

App.js

/** 데이터 관리 */
  const [name, setName] = useState("");

  const handleChange = (event) => {
    setName(event.target.value);
  };

<InputWrapper id="name" value={name} type="text" onChange={handleChange}>
        <InputWrapper.Label>Name</InputWrapper.Label>
        <InputWrapper.Input />
</InputWrapper>

Function as Child

자식 요소에 어떤 것이 들어올지 모른다고 가정
부모 요소는 오직 데이터 로직만 가짐
자식 요소를 컴포넌트 통째로 받도록 구성하는 컴포넌트 입니다.

functionAsChildInput.js

import React, { useState } from "react";

const FunctionAsChildInput = ({ children }) => {
  const [value, setValue] = useState("");

  const handleChange = (event) => {
    setValue(event.target.value);
  };

  return children({
    value,
    onchange: { handleChange },
  });
};

export default FunctionAsChildInput;

children의 타입이 function임!
-> 자식의 어떤것이 들어올지 모르기 때문에

App.js

<FunctionAsChildInput>
        {({ value, onChange }) => {
          return (
            <div className="input-container">
              <label id="1">Name</label>
              <input type={"text"} id="1" value={value} onChange={onChange} />
            </div>
          );
        }}
</FunctionAsChildInput>

children에서는 매개변수로 받은 로직을 마크업에 따라서 마음대로 사용할 수 있게 된다.

Custom Hook

위의 Function as Child에서 바디에 들어갈 로직들을 use** 커스텀 훅으로 정의해 사용처에서 실행시켜 사용하게 됩니다. 어느곳에서나 사용할 수 있게 됩니다!

useInput.js
-> 커스텀 훅에 해당

function useInput() {
  const [value, setValue] = useState('');

  const onChange = (event) => {
    setValue(event.target.value);
  };

  return { value, onChange };
}

App.js

const {value : name, onChange : onChangeName} = useInput()

<label htmlFor='1'>Name</label>
<input type="text" id='1' value={name} onChange={onChangeName} />

value 속성은 name 변수에 할당, onChange 속성은 onChangeName 변수에 할당되게 됩니다.

Custom Hook 예시


(이미지출처: https://fe-developers.kakaoent.com/2022/221020-component-abstraction/)

특정 아티스트의 앨범을 테이블 형태로 보여주는 AlbumList 컴포넌트를 예시로 봅니다.

const AlbumList = ({ artistId }: Props) => {
  const [albums, setAlbums] = useState([]);
  const [dataLoaded, setDataLoaded] = useState(false);
  const ref = useRef<HTMLDivElement>(null);
  const mountedRef = useRef(true);
  
  useEffect(() => {
    const fetchAlbums = async () => {  
      const response = await fetch('/getAlbums');
      const data = await response.json();

      setAlbums(data);
      setDataLoaded(true);
    }
    fetchAlbums();
  }, []);

  useEffect(() => {
    // 뒤로 가기로 페이지를 이동하여 앨범들을 보여줄 경우 마지막 스크롤 위치를 계산하여 맞춰줍니다.
    if (mountedRef.current && dataLoaded) {
      const scrollTop = ...
      ref.current.scrollTop = scrollTop;
      mountedRef.current = false;
    }
  }, [dataLoaded]);

  return (
    <div ref={ref}>
      {albums.map(album => (
        <SquareCardItemList ... />
      ))}
    </div>
  );
}

AlbumList의 책임은 특정 아티스트의 앨범 정보들을 보여준다 입니다. 이 책임 수행을 위해 아래처럼 특정 도메인과 얽혀있는 기능들이 함께 수행됩니다.

  • API 호출을 통해 필요한 앨범 정보를 가져옴
  • 뒤로 가기로 페이지를 이동하여 앨범들을 보여줄 경우 마지막 스크롤 위치로 이동

위 코드의 문제

  • 두 기능 수행을 위한 코드가 모두 작성되 있어 가독성이 떨어짐
  • 앨범 리스트가 아닌 아티스트 리스트에서도 동일한 UI로 정보만 바꿔 렌더링 하고 싶을 수 있음!

이때 사용하는 것이 바로 커스텀 훅 입니다.

커스텀 훅

const useFetchAlbums = (artistId: string) => {
  const [albums, setAlbums] = useState([]);
  const [dataLoaded, setDataLoaded] = useState(false);
  
  useEffect(() => {
    const fetchAlbums = async () => {  
      const response = await fetch('/getAlbums');
      const data = await response.json();

      setAlbums(data);
      setDataLoaded(true);
    }
    fetchAlbums();
  }, []);

  return [albums, dataLoaded]
}

const useLatestScrollTop = ({ ref, dataLoaded }: Props) => {
  const mountedRef = useRef(true);

  useEffect(() => {
    // 뒤로 가기로 페이지를 이동하여 앨범들을 보여줄 경우 마지막 스크롤 위치를 계산하여 맞춰줍니다.
    if (mountedRef.current && dataLoaded) {
      const scrollTop = ...
      ref.current.scrollTop = scrollTop;
      mountedRef.current = false;
    }
  }, [dataLoaded]);
}

AlbumList 컴포넌트

const AlbumList = ({ artistId }: Props) => {
  const [albums, dataLoaded] = useFetchAlbums(artistId);
  const ref = useRef<HTMLDivElement>(null);

  useLatestScrollTop({ ref, dataLoaded });
  
  return (
    <div ref={ref}>
      {albums.map(album => (
        <SquareCardItemList ... />
      ))}
    </div>
  );
}

useFetchAlbums, useLatestScrollTop 두 가지 훅으로 각각의 기능을 추상화 했습니다.

장점

  • 로직들을 훅으로 분리해 컴포넌트에서는 렌더링을 위한 코드만 응집되고 가독성이 좋아짐
  • 다른 컴포넌트에서도 앨범 데이터 조회를 위한 API 호출이 필요할 시 useFetchAlbums 훅을 사용하여 쉽게 대응 가능 (useLatestScrollTop도 동일)
  • 별도의 훅으로 분리해 테스트 검증에 용이함

참고

[10분 테코톡] 호프의 프론트엔드에서 컴포넌트
React 컴포넌트와 추상화
React Headless 컴포넌트 개발 패턴

profile
안녕하세요 코린입니다!

0개의 댓글