본 글은 Alexis Regnaud님의 5 Advanced React Patterns 를 참고한 포스팅입니다. 오역, 피드백 등은 답글을 달아주세요!
다들 리액트 개발자라면 한번쯤 다음과 같은 질문을 자신에게 던져본 적이 있을 것이다.
본 포스팅에서는 5개의 다른 패턴을 살펴본다.
비교를 용이하게 하기 위해서 각각의 패턴에 대해 동일한 구조를 통해 알아보자.
참고로 여기에서 유저는 이 컴포넌트를 사용하는 다른 개발자를 말하고, 개발자는 이 컴포넌트를 개발하는 리액트 개발자(바로 당신)을 말한다.
이 패턴은 불필요한 prop drilling 없이 Expressive하고 Declarative한 컴포넌트를 만들 수 있게 도와준다. 만약 좀 더 customizable하고 관심사를 분리하도록 하고 싶다면 이 패턴을 고려해보아야 한다.
prop drilling이란 App 컴포넌트에서 C컴포넌트에게 데이터를 주고 싶을 때, A와 B에는 필요없지만 C 컴포넌트에게 데이터를 주고 싶을 때, 상위 컴포넌트에서 프로퍼티를 아래로 내려 꽂듯이 데이터를 전달해주는 것을 말한다.
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 };
// 이렇게 쓰는 것보다
return (
<Counter
label="label"
max={10}
iconDecrement="minus"
iconIncrement="plus"
onChange={handleChangeCounter}
/>
);
// 이렇게 쓰는게 낫다!
return (
<Counter onChange={handleChangeCounter}>
<Counter.Decrement icon={"minus"} />
<Counter.Label>Counter</Counter.Label>
<Counter.Count max={10} />
<Counter.Increment icon={"plus"} />
</Counter>
);
너무 UI 자유도가 크다. 이렇게 큰 자유도는 예상치 못한 행동을 유발할 수도 있다. 유저가 어떻게 이 컴포넌트를 사용하는지에 의존한다면, 이렇게 큰 자유도를 주면 안된다.
JSX가 너무 무겁다. 이 패턴을 적용하게 되면 JSX의 열 개수를 너무 늘릴 수 있다. 특히 EsLint나 Prettier를 사용한다면 말이다.
이건 작은 컴포넌트에서는 큰 문제가 아닌 것처럼 보이지만 큰 그림을 보게 된다면 엄청난 차이를 느낄 수 있을 것이다.
이 패턴은 컴포넌트를 controlled component로 바꿔준다. 외부 상태는 single source of truth, SSOT로 사용되어 유저로 하여금 커스텀 로직을 삽입할 수 있게끔 한다.
- Controlled component란 component의 상태를 제어할 수 있는 컴포넌트를 의미한다.
- SSOT란 단일 진실 공급원이라고도 번역되는데, 이는 모든 데이터 요소를 한 곳에서만 제어, 편집하도록 하는 것이다.
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 };
좀 더 IoC에 집중해보자. 메인 로직은 이제 custom hook으로 들어간다. hook은 State, Handler와 같은 내부 로직들을 포함하며 유저에게 더 많은 통제권을 준다.
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 pattern이 엄청난 통제권을 주긴 하지만, 그만큼 컴포넌트를 이용하기 어렵게 만든다. Props Getters Pattern은 이런 복잡도를 감싸기 위해 시도한다. native props를 노출하는 대신 props getters의 목록을 제공한다. 이는 유저가 올바른 JSX요소에 접근할 수 있도록 의미있는 이름을 사용해야 한다.
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 };
IoC에 있어서는 최고의 패턴이다. 이 패턴은 유저에게 컴포넌트를 내부적으로 제어할 수 있는 더 발전된 방법을 제시한다.
코드는 Custom Hook Pattern과 비슷해보이지만, 더 나아가 유저가 Hook을 통해 전달된 reducer를 정의한다. 이 reducer는 컴포넌트 내의 모든 action들을 오버로드한다.
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>
</>
);
이 예에서는 Custom hook Pattern에 State reducer pattern을 적용했지만 우리는 이를 Compound components pattern에 적용하여 사용할 수도 있다.
이 5개의 패턴을 보면서 우리는 IoC를 조절하는 다양한 방법을 알 수 있다. 이들은 우리로 하여금 더 유연하고 융통성있는 컴포넌트들을 만드는 강력한 방법을 제공한다.
그러나 우리는 이 유명한 격언을 알고 있다.
큰 힘에는 큰 책임이 따른다
컴포넌트에게 더 많은 제어권을 주게 된다면 이는 컴포넌트가 plug and play라는 사고방식으로부터 멀어지게 만든다. 그렇기 때문에 올바른 패턴을 선택하는 것은 개발자로서의 책임이 된다.
이 문제에 도움이 되기 위해 각 패턴을 Integration complexity와 Inversion of control에 대해 정리한 그림을 첨부한다.
너무 도움되는 자료 감사합니다 :()