본문 바로가기
프론트엔드/React

리액트 React | 컴포넌트 설계 패턴

by YUNI Heo 2023. 7. 4.
반응형

 

 

✅ 컴포넌트 설계 패턴

  컴포넌트를 역할과 관심사에 따라 분리 시키기 위해서는 코드 내부의 로직을 쪼개고 다른 코드로 위임을 해야된다. 제어를 위임하는 것의 문제는 쪼개고 위임 할수록 사용 코드에 대한 이해 난이도가 높아 가고, 가독성이 떨어진다 또, 커스텀 설정이 요구되는 경우가 많다. 이 역시 절대적인 것은 없고 개발자의 재량에 따라 최적을 찾아야 한다.
유지보수와 확장이 불가능한 스파게티 코드로 끝나버리지 않으려면, 리액트 개발자로서 더 경험이 많아질수록 다양한 컴포넌트 패턴에 대해 학습하는 것이 필요하다.

이것이 끝은 아닌 것이, 여러 가지 패턴을 알아가는 것 자체가 좋은 기초가 된다. 이것의 가장 중요한 면은 어떤 문제에 대해 어떤 패턴을 적용할지 그때를 알게 된다는 점이다.
5 Advanced React Patterns - Alexis Regnaud 가 쓴 5가지 리액트 위임 패턴을 소개한다.

https://www.stevy.dev/react-design-guide/

 

✅ 합성 컴포넌트 패턴

이 패턴은 Prop Drilling 문제의 해결책이 될 수 있다

컴포넌트를 더 커스터마이징하고 싶고 좀 더 가독성 좋은 API를 만들고 싶을 때 이용 할 수 있다

 

💡 세부 컴포넌트 예제

// ./components/Label

import React from 'react';
import styled from 'styled-components';

function Label({children}) {
  return <StyledLabel>{children}</StyledLabel>;
}

const StyledLabel = styled.div`
  background-color: #e9ecef;
  color: #495057;
  padding: 5px 7px;
`;

export {Label};

 

💡 대장 컴포넌트

import React, {useEffect, useRef, useState} from 'react';
import styled from 'styled-components';
import {CounterProvider} from './useCounterContext';
import {Count, Label, Decrement, Increment} from './components';

function Counter({children, onChange, initialValue = 0}) {
  const [count, setCount] = useState(initialValue);

  const firstMounded = useRef(true);
  useEffect(() => {
    if (!firstMounded.current) {
      onChange && onChange(count);
    }
    firstMounded.current = false;
  }, [count, onChange]);

  const handleIncrement = () => {
    setCount(count + 1);
  };

  const handleDecrement = () => {
    setCount(Math.max(0, count - 1));
  };

  return (
    <CounterProvider value={{count, handleIncrement, handleDecrement}}>
      <StyledCounter>{children}</StyledCounter>
    </CounterProvider>
  );
}

const StyledCounter = styled.div`
  display: inline-flex;
  border: 1px solid #17a2b8;
  line-height: 1.5;
  border-radius: 0.25rem;
  overflow: hidden;
`;

Counter.Count = Count;
Counter.Label = Label;
Counter.Increment = Increment;
Counter.Decrement = Decrement;

export {Counter};

 

💡 사용 컴포넌트

import React from 'react';
import {Counter} from './Counter';

function Usage() {
  const handleChangeCounter = count => {
    console.log('count', count);
  };

  return (
    <Counter onChange={handleChangeCounter}>
      <Counter.Decrement icon="minus" />
      <Counter.Label>Counter</Counter.Label>
      <Counter.Count max={10} />
      <Counter.Increment icon="plus" />
    </Counter>
  );
}

export {Usage};

 

💡 장점

API 복잡도가 낮다
Prop Driling 하지 않고 세부 컴포넌트에 State를 유지 시킨채로 컴포넌트 자체를 대장 컴포넌트에 합성 시켜서 복잡도를 줄였다


UI 구현 측면에서 유연성이 좋다


관심사의 분리가 잘 되어 있다
대부분 로직들은 Counter 컴포넌트에 묶여있다
CounterProvider로 Context 관리되고 있고 이를 통해서 상태를 공유하고있다
책임에 따른 분리를 잘 보여준다

 

💡 단점

UI 구현 측면에서 너무 강한 유연성은 기대하지 않은 사이드 이펙트를 만들수 있다
순서가 강제되는데도 끼어들기 하는 경우 뒤틀릴 수도 있다
어느 정도 강제 되는 컴포넌트 사용 방법이 있다면 강한 유연성은 안좋은 경우가 많다.


 

JSX 구문이 길어진다

 

 

💡 총점

Inversion of control: 1/4
Implementation complexity: 1/4

 

 

✅ 제어 Props 패턴

제어컴포넌트로 컴포넌트를 변환 하는 패턴

single source of truth인 외부 state에 유저가 custom logic 을 넣을수 있게함

💡 코드

import React, {useState} from 'react';
import {Counter} from './Counter';

function Usage() {
  const [count, setCount] = useState(0);

  const handleChangeCounter = newCount => {
    setCount(newCount);
  };
  return (
    <Counter value={count} onChange={handleChangeCounter}>
      <Counter.Decrement icon={'minus'} />
      <Counter.Label>Counter</Counter.Label>
      <Counter.Count max={10} />
      <Counter.Increment icon={'plus'} />
    </Counter>
  );
}

export {Usage};

💡 장점

외부에 제어 권한을 더 줄수 있다
메인 State가 외부에 있기 때문에 사용자의 외부에서 상태 제어가 쉽고 컴포넌트에게 영향을 바로 끼칠수 있다

💡 단점

구현 복잡도가 높다
JSX , useState, handleChange 3가지 코드를 통해서 컴포넌트가 동작한다

💡 총점

Inversion of control: 2/4
Implementation complexity: 1/4

✅ 합성 컴포넌트 패턴

제어 역전 측면에서 커스텀 훅스를 이용하면 외부로 로직을 위임 할수 있다

💡 코드

import React from 'react';
import {Counter} from './Counter';
import {useCounter} from './useCounter';

function Usage() {
  const {count, handleIncrement, handleDecrement} = useCounter(0);
  const MAX_COUNT = 10;

  const handleClickIncrement = () => {
    //Put your custom logic
    if (count < MAX_COUNT) {
      handleIncrement();
    }
  };

  return (
    <>
      <Counter value={count}>
        <Counter.Decrement icon={'minus'} onClick={handleDecrement} disabled={count === 0} />
        <Counter.Label>Counter</Counter.Label>
        <Counter.Count />
        <Counter.Increment icon={'plus'} onClick={handleClickIncrement} disabled={count === MAX_COUNT} />
      </Counter>
      <button onClick={handleClickIncrement} disabled={count === MAX_COUNT}>
        Custom increment btn 1
      </button>
    </>
  );
}

export {Usage};

💡 장점

외부에 제어권을 더 많이 줄 수 있다
custom hook과 JSX element사이에 로직을 집어 넣게 해서 제어 권한을 준다

💡 단점

구현 복잡도 올라간다

 

💡 총점

Inversion of control: 2/4
Implementation complexity: 2/4

 

 

✅ Props Getter 패턴

Custom Hook 패턴은 사용자에게 제어 권한을 많이 줬지만 동시에 많은 부분을 직접 구현하게 하고 네이티브 hook의 props를 직접 다루게 함으로써 컴포넌트 통합에는 어려움을 겪게 만들었다

Props Getter 패턴은 이런 개발 복잡도를 숨기기 위해 직접 hook의 props를 다루게 하기보다 props getter의 shortlist를 제공한다

💡 코드

import {useState} from 'react';

//Function which concat all functions together
const callFnsInSequence =
  (...fns) =>
  (...args) =>
    fns.forEach(fn => fn && fn(...args));

function useCounter({initial, max}) {
  const [count, setCount] = useState(initial);

  const handleIncrement = () => {
    setCount(prevCount => Math.min(prevCount + 1, max));
  };

  const handleDecrement = () => {
    setCount(prevCount => Math.max(0, prevCount - 1));
  };

  //props getter for 'Counter'
  const getCounterProps = ({...otherProps} = {}) => ({
    value: count,
    'aria-valuemax': max,
    'aria-valuemin': 0,
    'aria-valuenow': count,
    ...otherProps,
  });

  //props getter for 'Decrement'
  const getDecrementProps = ({onClick, ...otherProps} = {}) => ({
    onClick: callFnsInSequence(handleDecrement, onClick),
    disabled: count === 0,
    ...otherProps,
  });

  //props getter for 'Increment'
  const getIncrementProps = ({onClick, ...otherProps} = {}) => ({
    onClick: callFnsInSequence(handleIncrement, onClick),
    disabled: count === max,
    ...otherProps,
  });

  return {
    count,
    handleIncrement,
    handleDecrement,
    getCounterProps,
    getDecrementProps,
    getIncrementProps,
  };
}

export {useCounter};
import React from 'react';
import {Counter} from './Counter';
import {useCounter} from './useCounter';

const MAX_COUNT = 10;

function Usage() {
  const {count, getCounterProps, getIncrementProps, getDecrementProps} = useCounter({
    initial: 0,
    max: MAX_COUNT,
  });

  const handleBtn1Clicked = () => {
    console.log('btn 1 clicked');
  };

  return (
    <>
      <Counter {...getCounterProps()}>
        <Counter.Decrement icon={'minus'} {...getDecrementProps()} />
        <Counter.Label>Counter</Counter.Label>
        <Counter.Count />
        <Counter.Increment icon={'plus'} {...getIncrementProps()} />
      </Counter>
      <button {...getIncrementProps({onClick: handleBtn1Clicked})}>Custom increment btn 1</button>
      <button {...getIncrementProps({disabled: count > MAX_COUNT - 2})}>Custom increment btn 2</button>
    </>
  );
}

export {Usage};

 

💡 장점

쓰기 쉽다
복잡도를 숨기고 만들고자 하는 컴포넌트와 hook이 쉽게 정해진 방법에 따라서 통합이 가능한 패턴을 getter로 제공한다


유연성
원한다면 getter에 정해놓은 함수를 덮어쓸 수도 있다

 

💡 단점

코드 가독성이 떨어진다
그냥 getter로만 써놔서 뭐가 props인줄 알수가 없다

💡 총점

Inversion of control: 3/4
Integration complexity: 3/4

 

✅ State Reducer 패턴

제어역전이 가장 심화된 패턴이다

사용자에게 컴포넌트 동작을 어떻게 변화시킬지 가장 진보된 방법을 제공한다

Custom Hook 패턴과 비슷하지만 사용자는 reducer에 정의를 다할수 있다

💡 코드

import {useReducer} from 'react';

const internalReducer = ({count}, {type, payload}) => {
  switch (type) {
    case 'increment':
      return {
        count: Math.min(count + 1, payload.max),
      };
    case 'decrement':
      return {
        count: Math.max(0, count - 1),
      };
    default:
      throw new Error(`Unhandled action type: ${type}`);
  }
};

function useCounter({initial, max}, reducer = internalReducer) {
  const [{count}, dispatch] = useReducer(reducer, {count: initial});

  const handleIncrement = () => {
    dispatch({type: 'increment', payload: {max}});
  };

  const handleDecrement = () => {
    dispatch({type: 'decrement'});
  };

  return {
    count,
    handleIncrement,
    handleDecrement,
  };
}

useCounter.reducer = internalReducer;
useCounter.types = {
  increment: 'increment',
  decrement: 'decrement',
};

export {useCounter};
import React from 'react';
import {Counter} from './Counter';
import {useCounter} from './useCounter';

const MAX_COUNT = 10;
function Usage() {
  const reducer = (state, action) => {
    switch (action.type) {
      case 'decrement':
        return {
          count: Math.max(0, state.count - 2), //The decrement delta was changed for 2 (Default is 1)
        };
      default:
        return useCounter.reducer(state, action);
    }
  };

  const {count, handleDecrement, handleIncrement} = useCounter({initial: 0, max: 10}, reducer);

  return (
    <>
      <Counter value={count}>
        <Counter.Decrement icon={'minus'} onClick={handleDecrement} />
        <Counter.Label>Counter</Counter.Label>
        <Counter.Count />
        <Counter.Increment icon={'plus'} onClick={handleIncrement} />
      </Counter>
      <button onClick={handleIncrement} disabled={count === MAX_COUNT}>
        Custom increment btn 1
      </button>
    </>
  );
}

export {Usage};

 

💡 장점

제어권 많이 위임 할수 있다

 

💡 단점

구현이 복잡하다
가독성이 안좋다

💡 총점

Inversion of control: 4/4
Integration complexity: 4/4

 

 

반응형