지난 글에 이은 Fluent React의 5장, Powerful Patterns 챕터의 마지막 내용이다. Props와 연관된 설계 패턴을 모아 작성하였다.
Render Prop
const WindowSize = (props) => {
const [size, setSize] = useState({ width: -1, height: -1 });
return props.render(size);
};
...
<WindowSize
render={({ width, height }) => (
<div>
Your window is {width}x{height}px
</div>
)}
/>
render prop의 기본형은 컴포넌트의 prop으로 함수 형태의 컴포넌트를 넘겨주는 것이다. 이를 받는 컴포넌트는 prop을 렌더링한다. 위의 예시에서 WindowSize는 render에 div 컴포넌트를 리턴하며, 내부의 size에 접근하고 있다. 라이브러리에서 보통 접할 수 있다. 예시를 들어 react-error-boundary의 ErrorBoundary 컴포넌트의 fallback ui 패턴은 render props를 사용한다.
다음 예시를 통해 render props의 장단점, 사용처를 알아보자.
// Before
const SnippetInput = () => {
const [text, setText] = useState("");
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setText(e.target.value);
};
return (
<input
type="text"
placeholder="Type Here"
value={text}
onInput={handleInputChange}
/>
);
};
const SnippetToLower = ({ text }: { text: string }) => {
return <p>{text.toLowerCase()}</p>;
};
const SnippetToUpper = ({ text }: { text: string }) => {
return <p>{text.toUpperCase()}</p>;
};
const SnippetSample = () => (
<>
<SnippetInput />
<SnippetToLower text="Hello World" />
<SnippetToUpper text="Hello World" />
<ExpensiveComponentA />
<ExpensiveComponentB />
<ExpensiveComponentC />
</>
);
// Refactor-1
const SnippetInput = () => {
const [text, setText] = useState("");
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setText(e.target.value);
};
return (
<>
<input
type="text"
placeholder="Type Here"
value={text}
onInput={handleInputChange}
/>
<SnippetToLower text={text} />
<SnippetToUpper text={text} />
</>
);
};
Before 코드에서 input의 로컬 상태(text)에 접근할 수 없어 SnippetInput 컴포넌트 내부로 SnippetToLower, SnippetToUpper가 이동한 모습이다. Refactor-1에서 부모(SnippetSample)로 상태를 올리고, Input, ToLower, ToUpper 세 컴포넌트가 상태를 공유하지 않은 이유를, 키보드 입력마다 ExpensiveComponentA~C가 리렌더링 되기 때문이라고 가정하자. 일반적인 컴포넌트의 분리이지만, 컴포넌트의 deps가 생기는 점이 아쉽다.
// Render Props - 1
const SnippetInput = (props: {
render: (text: string) => React.ReactNode;
}) => {
const [text, setText] = useState("");
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setText(e.target.value);
};
return (
<>
<input
type="text"
placeholder="Type Here"
value={text}
onInput={handleInputChange}
/>
{props.render(text)}
</>
);
};
const SnippetSample = () => {
console.log("rerender SnippetSample");
return (
<>
<SnippetInput
render= {(text: string) => (
<div>
<SnippetToLower text={text} />
<SnippetToUpper text={text} />
</div>
)}
/>
<ExpensiveComponentA />
<ExpensiveComponentB />
<ExpensiveComponentC />
</>
);
};
Render Prop 사용 시 다음과 같은 장점이 생긴다.
- 부모 컴포넌트에서 자식의 상태를 접근하면서 부모 전체의 리렌더링을 방지
- render prop으로 자식에서 생성될 JSX를 작성할 수 있다.
상태의 관리는 자식에서 관리하지만, 부모에서 자식 컴포넌트의 상태에 접근하면서 렌더링될 JSX를 작성할 수 있는 점이 장점으로 다가온다. 만약 render={() ⇒ …} 형태가 가독성이 좋지 않다면 children을 이용해 더 명시적으로 표현할 수 있다.
// Render Props - children
const SnippetInput = (props: {
children: (text: string) => React.ReactNode;
}) => {
... (생략)
return (
<>
<input
type="text"
placeholder="Type Here"
value={text}
onInput={handleInputChange}
/>
{props.children(text)}
</>
);
};
const SnippetSample = () => {
console.log("rerender SnippetSample");
return (
<>
<SnippetInput>
{(text: string) => (
<div>
<SnippetToLower text={text} />
<SnippetToUpper text={text} />
</div>
)}
</SnippetInput>
....
</>
);
};
키보드 입력이 일어나도 모든 컴포넌트가 리렌더링되지 않는 것을 확인할 수 있다. 물론 부모 컴포넌트(SnippetSample)에 로컬 상태가 추가된다면, 이 상태가 업데이트 될 때마다 SnippetInput 또한 리렌더링될 것이다. 이 경우엔 상태를 효과적으로 분리했다고 보기 힘들 수 있다.
결론적으로 render prop의 사용목적은 ‘상태 공유’에 있겠다. children 패턴을 이용하면 코드의 가독성 또한 나쁘지 않아진다. 특히 SnippetInput 예시에서 살펴본 바와 같이 다음 상황에서 이점이 있겠다.
- 부모에서 상태를 공유하는 것이 비용이 높은 다른 컴포넌트의 리렌더링을 불러 올 때,
- 지역적으로 사용되는 컴포넌트의 상태를 캡슐화하여 관리하게 해주고,
- 부모 컴포넌트에서 명시적(선언적)으로 렌더링될 컴포넌트를 작성할 수 있게 해주는 점
현재는 커스텀 Hooks나 전역 상태 등으로 유즈 케이스가 많은 부분 대체되었다고 한다. 따라서 render prop이 1번 이상 네스팅된다면, 커스텀 훅을 고려해보는 것도 나쁘지 않겠다.
<SnippetInput>
{(text) ⇒ (
<>
<NestedProp>
{(localState) ⇒
<>{localState ? “YES” : “NO” </>
}
</NestedProp>
<SnippetToLower text={text} />
<SnippetToUpper text={text} />
</>
)}
</SnippetInput>
Conclusion
- Pros: 높은 유연성과 재사용성
- Cons:
- props로 render 함수를 내리게 될 때 복잡한 컴포넌트 구조가 발생할 수 있음. (nesting 시 심해짐)
- 잘못 사용시 render prop의 render가 여러번 실행되어 성능 상 좋지 않을 수 있음.
- Use case: 작은 단위에서 공유되는 상태를 캡슐화할 때
Control Prop
컨트롤된 컴포넌트는 자체적인 내부 상태를 유지하지 않는 컴포넌트입니다. 대신, 부모 컴포넌트로부터 현재 값이 prop으로 전달되며, 이는 그 상태의 유일한 원천이 됩니다. 상태가 변경되어야 할 때, 컨트롤된 컴포넌트는 일반적으로 onChange와 같은 콜백 함수를 사용하여 부모에게 알립니다. 따라서 부모 컴포넌트가 상태를 관리하고 컨트롤된 컴포넌트의 값을 업데이트하는 책임을 집니다.
jsxCopy code
function ControlledInput({ value, onChange }) {
return <input type="text" value={value} onChange={onChange} />;
}
function ParentComponent() {
const [inputValue, setInputValue] = useState("");
return <ControlledInput value={inputValue} onChange={e => setInputValue(e.target.value)} />;
}
부모에서 상태를 관리하고, 자식 컴포넌트에 상태와 setter를 위한 콜백 함수를 전부 prop으로 받는다. 일견 앞선 패턴(render props, compound)들과 자식 컴포넌트의 상태를 분리하지 않고 상속하는 점에서 반대되며, presentational 패턴과 비슷해 보인다. 하지만 이 패턴은 자식 컴포넌트를 제어 컴포넌트로 만들어 상태 관리를 추상화하는 것에 초점을 맞춘다. 또한 제어된 props 패턴은 필요에 따라 자체적인 내부 상태도 유지할 수도 있다. 다음 예시에서 처럼 자식 컴포넌트 내부에서 새로운 state와 setter를 선언하고, 이를 controlled prop과 결합하여 사용한다.
const Toggle = ({
on,
onToggle,
}: {
on?: boolean;
onToggle?: Dispatch<SetStateAction<boolean>>;
}) => {
const [isOn, setIsOn] = React.useState(false);
const handleToggle = () => {
const nextState = on === undefined ? !isOn : !on;
if (on === undefined) {
setIsOn(nextState);
}
if (onToggle) {
onToggle(nextState);
}
};
const displayText = useMemo(() => {
if (on !== undefined) {
return on ? "On" : "Off"
}
return isOn ? "On" : "Off"
}, [on])
return <button onClick={handleToggle}>{displayText}</button>;
};
const ControlPropsPattern = () => {
const [toggle, setToggle] = React.useState(false);
return (
<div>
<h1>Controlled Props Pattern</h1>
<Toggle on={toggle} onToggle={setToggle} />
<p>{toggle ? "The button is on" : "The button is off"}</p>
</div>
);
};
다음의 예시는 Toggle의 상태는 부모 컴포넌트의 toggle에 따라 결정된다. 굳이 이 방식을 사용할 이유가 있을까? 책에서는 다음과 같은 패턴의 이점을 설명한다.
Control Props 패턴은 컴포넌트가 외부의 props에 의해 제어되거나 내부적으로 자체 상태를 관리할 수 있도록 함으로써 컨트롤할 수 있습니다… 이러한 이중 능력은 부모가 선택적으로 자식 컴포넌트의 상태를 제어할 수 있게 해주며, 동시에 자식 컴포넌트가 제어되지 않을 경우 독립적으로 작동할 수 있도록 합니다.
... Toggle 컴포넌트에서, isOn은 내부 상태를 나타내고, on은 외부 제어 prop입니다. 부모로부터 on prop이 제공될 경우, 컴포넌트는 제어 모드에서 작동할 수 있습니다. 제공되지 않으면 내부 상태인 isOn으로 돌아갑니다. onToggle prop은 부모 컴포넌트가 상태 변경에 반응할 수 있게 하는 콜백으로, Toggle의 상태와 부모의 자체 상태를 동기화할 수 있는 기회를 부모에게 제공합니다. 이 패턴은 컴포넌트의 유연성을 향상시키며, 제어된 모드와 비제어 모드의 운영을 모두 제공합니다. 필요할 때 부모가 제어할 수 있도록 하면서도, 명시적으로 제어되지 않을 때는 컴포넌트가 자신의 상태에 대한 자율성을 유지할 수 있도록 합니다.
Toggle 컴포넌트 예시와 달리 props가 undefined일 수 있다면, 제어 컴포넌트에 로컬 상태를 추가하여 자식 컴포넌트가 제어되지 않을 경우 독립적으로 작동하는 것이 좀 더 방어적일 수 있다.
- props로 넘어오는 상태가 api 데이터인 경우
- 컴포넌트가 공용으로 사용되지만 사용처에 따라 props로 넘겨줄 상태가 없을 수 있는 경우
첫번째 경우엔 api 데이터를 리액트의 상태나 react-query, swr 등 라이브러리가 제공하는 상태를 props로 넘겨준다면 흔하게 일어날 수 있는 상황이다. pending 일 때 자식 컴포넌트의 default state를 보여주도록 할 수 있다. 두번째 경우, Toggle 컴포넌트를 여러 곳에서 사용하나 부모 컴포넌트에서 상태를 내려주지 않을 때를 가정할 수 있다.
결론적으로 Controlled props를 받는 제어된 컴포넌트 내부에서 로컬 상태를 포함하든, 하지 않든 Controlled Props 패턴의 효과는 동일하다. 상태를 prop으로 전달하고, 상태를 부모에서 관리해 로직을 중앙화하는 것이다. 이를 통해 상태 관리 로직이 파편화되는 것을 막을 수 있다. 상태 관리 로직을 분리하는 패턴은 prop gettter, state reducer 패턴과 함께 더 효율적으로 추상화할 수 있다.
Use Case
- 조건에 따른 상태 변화 제어: 외부 조건, 부모 컴포넌트의 상태에 따라 특정 컴포넌트의 상태를 동적으로 변경해야 할 때 사용. ex) 입력 폼의 값에 따른 데이터 유효성 검사, 실패 시 에러메시지 문구 노출과 같은 사용자의 입력에 따라 다른 ui를 보여주는 경우.
Pros & Cons
- Pros:
- 상태 로직의 재사용성: 상태 관리 로직을 부모 컴포넌트에 중앙화하여 다양한 자식 컴포넌트 간에 상태 관리 로직을 재사용 가능. 코드의 중복을 줄이고, 유지보수성을 높일 수 있음.
- 상태 공유 유연성: 여러 제어 컴포넌트에서 동일한 상태를 공유하고 싶을 때 유용합니다. 부모 컴포넌트가 중앙 집중식으로 상태를 관리하여, 제어된 자식 컴포넌트들이 상태를 쉽게 공유할 수 있음.
- Cons:
- 부모-자식 컴포넌트 간 결합도 증가: 상태를 부모 컴포넌트에서 관리하게 되면, 자식 컴포넌트가 해당 상태에 대해 부모 컴포넌트에 의존하게 됨. 부모 컴포넌트가 여러 자식 컴포넌트를 갖고 있다면, 모두 리렌더링되어 성능 상의 비용이 될 수 있음.
Prop Collection & Prop Getter
Controlled Props를 통해 부모 컴포넌트로 상태 관리를 분리하여 로직을 중앙화하였다. 하지만 state와 여러 종류의 이벤트 핸들러 등 제어된 컴포넌트로 넘길 props가 늘어가면, 점차 코드가 방대해지고 유지보수성이 떨어질 수 있다. 이 때 Prop collection과 prop getter를 사용하여 prop을 조금 더 효율적으로 관리할 수 있다.
Prop Collection :
Prop Collection 패턴은 컴포넌트에 자주 전달되는 prop들을 하나의 객체로 묶어서 관리하는 방법이다.
const droppableProps = {
onDragOver: (event) => {
event.preventDefault();
},
onDrop: (event) => {},
};
const mouseProps = {
onMouseEnter: (e) => {console.log("mouse enter")}
onMouseLeave: (e) => {console.log("mouse leave")}
}
<DropzoneA {...droppableProps}/>
<DropzoneB {{ onDragOver: (e) => {
event.preventDefault();
console.log(e.target.id)}, ...droppableProps}}
{...mouseProps}
/>
객체로 prop을 작성하고, 이를 스프레드 문법으로 전달할 수 있다. 하지만 넘겨줘야할 props가 점점 증가하고, 컴포넌트 별로 구분하여 전달해야한다면 관리가 어려워질 수 있다. 이때 props를 반환하는 콜백 함수를 활용하는 Prop Getter를 활용할 수 있다.
Prop Getter:
Props Getter는 여러 프롭을 반환하는 함수이며, 어떤 겟터가 어떤 컴포넌트에 사용되는지 명확하게 할 수 있습니다.
Prop Getter는 ****Prop Collection과 함께 사용할 수 있다. Prop getter 콜백함수를 이용해 기본적으로 정의한 Prop collection과 사용자가 정의한 커스텀 prop을 결합할 수 있다.
<DropzoneA {...droppableProps} />
<DropzoneB {...droppableProps, onDragOver: (e) => {
e.preventDefault();
alert('added')
}} />
....
위의 코드에서 onDragOver의 핸들러 함수 내부에 event.preventDefault() 이후 새로운 로직을 추가하려고 한다. spread 문법을 사용하여 덮어쓸 수 있지만, prop의 기존 로직을 반복해서 작성해야하며, prop collection을 개별 컴포넌트에서 오버라이딩 하는 방법이 적절한지 의문이 든다. Props Getter 패턴은 콜백함수로 이를 접근한다.
const compose =
(...functions) =>
(...args) =>
functions.forEach((fn) => fn?.(...args));
const getDroppableProps = ({
onDragOver: replacementOnDragOver,
...replacementProps
}) => {
const defaultOnDragOver = (event) => {
event.preventDefault();
};
return {
onDragOver: compose(replacementOnDragOver, defaultOnDragOver),
onDrop: (event) => {...},
onDragEnd: (event) => {...}
...replacementProps,
};
};
<Dropzone
{...getDroppableProps({
onDragOver: () => {
alert("Dragged!");
},
})}
/>
getDroppableProps 함수를 통해 onDragOver 핸들러에서 preventDefault의 기본동작에 alert 로직을 추가하게 하였다. Prop Collection의 객체에서 getter 함수로 전환하였다. 함수를 통해 prop 전달의 확장성을 고려할 수 있게 되었다. Props Getter는 함수 컴포넌트에서 Hooks와 함께 사용된다. 커스텀 훅을 만들어 prop collections을 제공하면, 컴포넌트의 재사용성과 가독성을 높일 수 있다.
const useDroppablePropsGetter = () => {
return {
onDragOver: (event) => { e.preventDefault(); }
onDrop: (event) => {...},
onDragEnd: (event) => {...}
}
};
}
const {onDragOver, ...callbacks} = useDroppablePropsGetter()
const onDragOverHandler = (e) => {
onDragOver(e)
alert("dragged!!")
}
<Dropzone onDragOver={onDragOverHandler} {...callbacks} />
훅을 사용하여 prop으로 넘겨줄 함수를 더 간단히 만들었다. Props getter 패턴은 상태와 다양한 핸드러를 관리할 때 유용할 수 있다. 훅 내부에서 핸들러의 default 로직을 작성하고, 훅을 사용하는 컴포넌트에서 추가 로직을 작성할 수 있다.
interface IUseInputArgs {
valueProp?: string;
defaultValue?: string;
onChange?: (next: string) => void;
}
export const useInput = function ({ valueProp, defaultValue = 'placeholder', ...callbackProps }: IUseCounterArgs) {
const [input, setInput] = useState(valueProp ?? defaultValue);
const onChange = (inputVal: string) => {
setInput(string);
callbackProps?.onChange?.(inputVal);
};
return {
input,
onChange,
};
};
/** App.tsx */
function App() {
const { input, ...callbacks } = useInput({ defaultValue: 10 });
// onchange 콜백에 커스텀 로직을 추가하여 사용
const onChange = () => {
if(100 < input.length) {
return
callbacks?.onChange((inpiut ?? 0) + 2);
};
return (
<div>
<TextInput input={input} onChange={onChange} {...callbacks} />
</div>
);
}
상태와 핸들러를 콜백함수로 관리하게 되었다. 하지만 여전히 콜백함수가 늘어날 시 prop으로 전달할 콜백함수가 늘어 관리가 어렵다는 점은 문제로 다가온다. 추상화 레벨이 높아질 수록 콜백 체인이 일어나기 때문에, 디버깅 시 어려움이 있을 수 있다. 또한 재사용될 로직이 없는데도 섣불리 패턴을 도입할 시 코드의 복잡성만 증가할 수 있다.
Use Case
- 컴포넌트 커스터마이징: 기본 컴포넌트에 추가적인 props를 적용하거나 기존의 props를 수정하여 컴포넌트의 기능이나 상태 등의 props를 커스터마이징 할 때 유용
- 재사용 가능한 로직 공유: 비슷한 동작을 하는 여러 컴포넌트 간에 공통된 props 로직을 공유할 때.
- Props Getter 함수를 통해 이 로직을 재사용하고 각 컴포넌트에 쉽게 적용할 수 있음.
Pros & Cons
- Pros:
- 유연성과 재사용성: Props Getter는 컴포넌트에 동적으로 props를 제공하고 수정할 수 있게 해주어 유연성과 재사용성을 높임
- 커스터마이징 용이: props를 쉽게 수정하거나 확장할 수 있게 해줌.
- 명시적인 코드: 컴포넌트가 어떤 props를 받게 될지 명시적으로 표현할 수 있어 가독성 개선 가능.
- Cons:
- 오버헤드 증가: 간단한 컴포넌트에 Props Getter 패턴을 적용하면, 필요 이상의 복잡성과 오버헤드를 초래할 수 있음.
props로 넘기는 콜백함수의 관리가 어려워지는 경우를 위해, 리액트는 useReducer라는 훅을 제공한다. 만약 prop으로 전달되는 로직이 계속하여 증가할 가능성이 있다면, 리듀서를 고려해보는게 좋다.
State Reducer
State Reducer 패턴은 컴포넌트의 상태 로직을 외부로 분리하여, 상태 변경 로직을 사용하는 측에서 직접 제어할 수 있도록 하는 디자인 패턴입니다.
const [state, dispatch] = useReducer(reducer, initialArg, init)
useReducer 훅의 기본적인 형태는 다음과 같다.
Control Prop → Prop Collection → Props Getter로 갈수록 상태 관리 로직을 함수로 분리하며 점차 추상화를 높였다. 하지만 Props Getter가 너무 많은 콜백함수를 포함해 복잡해진다면, reducer를 사용하는 것이 적절하다. 이 패턴을 이용해 상태와 상태 업데이트 로직을 완전히 함수로 분리할 수 있다. 또한 Props Getter 패턴의 장점인 상태 변경 로직의 커스터마이징 또한 유연하게 처리할 수 있다.
function init(initialCount){
// 복잡한 계산을 통한 초기 상태 가공 함수
return {count: initialCount * 2};
}
function initReducer(state, action) {
switch (action.type) {
case 'increment':
return {count: state.count + 1};
case 'decrement':
return {count: state.count - 1};
default: throw new Error();
}
}
function Counter({initialCount}) {
const [state, dispatch] = useReducer(initReducer, initialCount);
return (
<div>
<button onClick={() => dispatch({ type: "increment" })}>+</button>
<button onClick={() => dispatch({ type: "decrement" })}>-</button>
</div>);
}
- reducer: 상태 업데이트 방식을 정의하는 함수. 이 함수는 현재 상태(state)와 액션(action)을 매개변수로 받고, 새로운 상태를 반환해야 한다. reducer 함수는 순수 함수여야 하며, 동일한 입력에 대해 항상 동일한 출력을 반환해야 한다.
- initialArg: 초기 상태를 계산하기 위한 값. 초기 상태는 init 함수로 계산되며, 세번째 인자 init 함수가 없을 시 initialArg가 초기 값이 된다.
- optional init: 초기 상태를 반환하는 선택적인 초기화 함수. 이 함수는 initialArg를 매개변수로 받아 초기 상태를 계산하여 반환한다. 만약 init 함수가 지정되지 않으면, 초기 상태는 initialArg로 설정된다. 복잡한 초기 상태를 설정할 때 유용하다. init 함수를 사용하면, useReducer 훅을 호출할 때마다 복잡한 초기 상태 계산 로직을 반복하지 않고, 한 번만 실행하여 성능을 최적화할 수 있다.
Reducer 확장하기
앞선 예시(Props Getter)에서 compose 함수 혹은 커스텀 훅과 함께 props getter를 사용하여 props의 상태 제어 로직을 커스터마이징하였다. 리듀서 또한 마찬가지로 커스터마이징을 위한 추가 함수를 작성할 수 있다.
// props로 커스터마이징된 리듀서를 매개변수로 받아 기존 리듀서를 확장하는 함수
function enhancedReducer(reducer, propReducer) {
return (state, action) => {
// 1 -기존의 reducer 함수를 호출하여 다음 상태(nextState)를 계산하고
const nextState = reducer(state, action);
// 2 - action에 changes 프로퍼티를 추가해 reducer에 다음 state를 할당한다.
/* 3 - state와 changes가 추가된 action을 매개변수로 넣어
* prop으로 내려올 커스터마이징 리듀서를 실행 */
if(!propReducer) { // defaultProp을 추가하지 않는 대신 if로 분기처리할 수 있다.
return nextState
}
return propReducer(state, { ...action, changes: nextState });
};
}
// Counter 컴포넌트는 propReducer(커스텀 리듀서)를 prop으로 받아 위의 enhancedReduce함수를
// useReducer의 reducer 매개변수로 넣는다
function Counter({initialCount, propReducer}) {
const [state, dispatch] = useReducer(
enhancedReducer(initReducer, propReducer),
initialCount);
return (
<div>
<button onClick={() => dispatch({ type: "increment" })}>+</button>
<button onClick={() => dispatch({ type: "decrement" })}>-</button>
Count: {state.count}
</div>
);
}
Counter.defaultProps = {
initialCount: 0,
// propReducer가 undefined일 시 default 함수로 기존 reducer의 반환값(nextState)을 사용
propReducer: (state, action) => action.changes,
};
function App() {
const customReducer = (state, action) => {
// 기존 reducer에서'decrement' 액션을 수정
// 카운터 값이 0이고 상태 변경 무시
if (state.count === 0 && action.type === 'decrement') {
return state;
}
// action.type을 추가
if(action.type === 'onChange') {
return action.value ?? 0
}
// 아니라면 기존 reducer가 반환하는 값을 반환
// nextState = reducer(state, action)가 할당된 action.changes를 할당
return action.changes;
};
return <Counter initialCount={0} propReducer={customReducer} />;
}
- enhancedReducer : 기존 reducer 함수를 확장하기 위한 함수이다. 기존의 reducer가 계산한 값을 action.changes에 할당한다.
- propReducer : 기존 reducer에서 수정하고자 하는 로직을 작성한다. props가 undefined일 시 기존의 reducer가 반환하는 새 상태를 반환한다.
- customReducer : Counter의 부모 컴포넌트에서 prop으로 내려줄 커스텀 리듀서이다. 기존의 action.type의 동작을 수정하고, action.type을 추가할 수 있다.
useReducer 훅을 사용하여 위에서 다룬 패턴들과 동일한 효과를 갖는 상태 관리 로직의 분리를 이루었다. enhancedReducer을 통해 prop으로 수정된 리듀서의 추가여부에 따라 동작하게 하였다. 상태 업데이트에 대한 경우의 수가 많아질 수록, 공통된 로직은 initReducer에 정리될 것이고, 수정된 로직은 컴포넌트의 상위에서 함수로 작성될 것이다. 수정된 리듀서를 prop으로 받아, 수정된 상태만을 바라보게되어 이 패턴이 적용된 자식 컴포넌트들을 모두 제어할 수 있게 된다. enhancedReducer의 로직이 복잡하다면 다음의 예시로도 리듀서를 커스터마이즈 할 수 있다.
// composeReducer 함수는 외부 리듀서를 조합하여 반환
function enhancedReducer(reducer, propReducer) {
return function (state, action) {
const nextState = reducer(state, action);
if (!propReducer) {
return nextState;
}
return propReducer(state, { ...action, changes: nextState }, reducer);
};
}
function Counter({ initialCount, propReducer }) {
const [state, dispatch] = useReducer(
composeReducer(initReducer, propReducer),
{ count: initialCount }
);
...
Use Case
- 고도의 커스터마이징 필요: 컴포넌트의 상태 관리 로직을 사용하는 측에서 완전히 제어하고 싶은 경우
- ex) 예를 들어 다양한 사용 사례에 맞게 상태 업데이트 로직을 변경해야 할 때
- 로직 재사용 및 분리: 상태 관리 로직을 컴포넌트에서 분리, 재사용할 수 있고, 컴포넌트를 더 순수한 UI로 유지할 수 있음
Pros & Cons
- Pros:
- 유연성 증가: 사용자가 컴포넌트의 상태 변경 로직을 완전히 제어할 수 있어, 다양한 요구 사항에 맞게 컴포넌트를 쉽게 조정 가능
- 재사용성 및 분리: 상태 관리 로직을 컴포넌트로부터 완전히 분리. 로직의 재사용성, 관심사의 분리
- 테스트 용이성: 로직 자체를 독립적으로 테스트하기 용이함
- Cons:
- 복잡성 증가: 상태 업데이트 로직을 외부에서 제공해야 하므로, 구현의 복잡성이 증가할 수 있습니다. 특히, 상태 관리 로직이 복잡한 경우, 이를 제어하는 것이 어려울 수 있습니다.
- 학습 곡선: State Reducer 패턴을 효과적으로 사용하기 위해서는 리듀서 함수가 지나치게 복잡해질 수 있다. 예시에서 기존의 reducer를 확장하기 위해 propReducer, enhancedReducer 함수가 추가되었는데, 이 부분이 이해하기 까다로울 수 있다.