External Store
리액트 외부에 Store(저장소)를 두고 상태를 관리함으로써, 상태를 제어하는 관심사와 UI(컴포넌트)의 관심사를 분리
React 컴포넌트 입장에서는 “전역”처럼 여겨진다.
“Prop Drilling” 문제를 우아하게 해결 할 수 있는 방법 중 하나(React로 한정하면 Context도 쓸 수 있다 ).
External → (React) 외부를 의미한다.
일반적인 아키텍처에서는 UI(React)가 가장 바깥쪽이지만 여기서 말하는 Extenral은 안이냐 밖이냐라는 관점이 아니라 React 입장에서 외부를 의미
forceUpdate
React를 사용하는 것이 아닌 React 외부를 통해 상태관리를 하는 것이므로 useState를 사용하여 상태관리를 하는것이 아니다.
그래서 화면을 리렌더링 시킬 방법이 필요한데 아래와 같이 강제적으로 리렌더링하는 방식을 사용한다.
Copy function useForceUpdate () {
const [ , setState ] = useState ({});
return useCallback (() => setState ({}) , []);
}
React 공식문서에 나와있는 forceUpdate 코드
가능하면 해당 패턴을 피하라고 주의를 주고 있다.
Copy const [ ignored , forceUpdate ] = useReducer (x => x + 1 , 0 );
function handleClick () {
forceUpdate ();
}
useReducer
React에서 제공하는 상태 관리 훅이며, useState와 유사하지만 상태 업데이트 로직을 컴포넌트 외부로 분리할 수 있다.
useReducer가 기본형이고 useState는 내부적으로 useReudcer를 사용하는 방식이다.
여러 컴포넌트에서 공유되는 상태를 관리할 때 도움이 된다.
우리가 흔히 알고있는 Redux의 그것과 비슷함
Copy const [ state , dispatch ] = useReducer (reducer , initialState);
reducer : 상태 업데이트 로직을 처리하는 함수
리엑트 내부적으로 사용하는 상태관리(useState, useReduer, props, context)를 Internal Store 라고 한다.
useCallback
React에서 제공하는 성능 최적화를 위한 훅이며, 성능 최적화를 위해 함수를 캐시하고 재사용한다.
매 렌더링 마다 새로운 함수를 생성하지 않고 이전에 생성된 함수를 재사용하도록 보장함
자식컴포넌트에 props로 콜백함수를 전달할 때 props의 변화로 불필요하게 리렌더링 되는 것을 방지하기 위해 사용된다.
useCallback만 사용한다고 해서 최적화 되는 것이 아니라 또 다른 성능 최적화 훅인 React.memo()
와 같이 사용해야 최적화 된다는 것에 유의
React.memo Hooks
이전에 렌더링된 결과를 캐시하고, 다음 렌더링 시 현재 props와 얕게 비교(변경가능)해서 같다면 리렌더링을 하지 않고 캐시된 결과를 사용하는 식으로 리렌더링을 방지한다.
Copy const callbackFn = useCallback (() => {
doSomething
} , [dependency])
useEffect의 의존성 배열과 비슷한데 함수 내부에서 참조하는 상태나 props 중에서 변경되는 것이 있을때 새로운 함수를 생성하도록 지정한다.
Props Drilling
부모에서 React 트리의 모든 중첩된 자식에게 데이터를 전달하는 것을 의미한다.
아래 코드와 같이 중간 단계의 컴포넌트가 props를 단순히 자식에게 전달하기만 하는 형태를 props drilling 이라 함
Copy function Parent () {
const [ count , setCount ] = useState ( 0 );
return (
< div >
< Children count = {count} setCount = {setCount} />
</ div >
)
}
function Children ({count , setCount}) {
return (
< div >
< ChildrenChildren count = {count} setCount = {setCount} />
</ div >
)
}
function ChildrenChildren ({count , setCount}) {
return (
< div >
< p >{ `count: ${ count } ` }</ p >
< button type = "button" onClick = {() => setCount ((prev) => prev + 1 )}>Increase</ button >
</ div >
)
}
Props Drilling의 문제점
컴포넌트 구조가 변경되면 props를 전달하는 모든 컴포넌트를 수정해야 하므로 번거롭다.
Context API
React에서 제공해주는 Context API Hooks로 Props Drilling을 최소화할 수 있다.
Context의 사용 목적은 복잡하게 "중첩 된" 하위 컴포넌트들에 데이터를 공유하는 것
Context를 사용하면 컴포넌트 재사용이 어려우므로 꼭 필요한 경우에만 사용해야 한다.
Copy
import {createContext , useContext , useState , useMemo} from 'react' ;
interface CounterContextValue {
count : number ;
setCount : () => void ;
}
export const CounterContext = createContext ({} as CounterContextValue );
export function useCounter () {
const contextValue = useContext (CounterContext);
if ( ! contextValue) {
throw new Error ( 'useCounter must be used within CounterProvider' );
}
return contextValue;
}
export default CounterProvider ({children}: React .PropsWithChildren) {
const [count , setCount] = useState(0);
const value = useMemo(() => ({count, setCount}), [count , setCount])
return (
<CounterContext.Provider value={value}>
{children}
</CounterContxt.Provider>
)
}
Context 적용 코드
넘겨주는 Props들을 모두 제거하니 깔끔해졌다.
Copy function Parent () {
return (
< CounterProvider >
< div >
< Children />
</ div >
</ CounterProvider >
)
}
function Children () {
return (
< div >
< ChildrenChildren />
</ div >
)
}
function ChildrenChildren () {
const { count , setCount } = useCounter ();
return (
< div >
< p >{ `count: ${ count } ` }</ p >
< button type = "button" onClick = {() => setCount ((prev) => prev + 1 )}>Increase</ button >
</ div >
)
}
Context API + useReduecr 조합으로 상태관리를 흉내낼수 있지만 관리되는 컨텍스트 값 중 일부가 변경이 되었을 때 변경되지 않은 다른 상태값에 연결된 컴포넌트 또한 리렌더링이 발생하는 Side Effect가 있다.
External Store 직접 구현해보기 with Tsyringe
여러 스토어를 통해 관리되는 extenral store를 직접 구현 해보자.
ObjectStore.
여러 스토어의 베이스가 되는 부모 클래스
Copy type Listener = () => void ;
export default abstract class ObjectStore {
private listeners = new Set < Listener >();
protected publish () {
this . listeners .forEach ((listener) => listener ());
}
addListener (listener : Listener ) {
this . listeners .add (listener);
}
removeListener (listener : Listener ) {
this . listeners .delete (listener);
}
}
CounterStore
Copy import { singleton } from 'tsyringe' ;
import ObjectStore from './objectStore' ;
@ singleton ()
export default class CounterStore extends ObjectStore {
counter = 0 ;
increase (step = 1 ) {
this .counter += step;
this .publish ();
}
decrease (step = 1 ) {
this .counter -= step;
this .publish ();
}
}
useObjectStore
Copy import { useEffect } from 'react' ;
import ObjectStore from '../stores/objectStore' ;
import useForceUpdate from './useForceUpdate' ;
export default function useObjectStore < T extends ObjectStore >(store : T ) : T {
const forceUpdate = useForceUpdate ();
useEffect (() => {
store .addListener (forceUpdate);
return () => {
store .removeListener (forceUpdate);
};
} , [store , forceUpdate]);
return store;
}
useCounterStore
Copy import { container } from 'tsyringe' ;
import CounterStore from '../stores/counterStore' ;
import useObjectStore from './useObjectStore' ;
export default function useCounterStore () {
const store = container .resolve (CounterStore);
return useObjectStore (store);
}
Usage
CounterController.tsx
Copy import useCounterStore from '../hooks/useCounterStore' ;
export default function CounterController () {
const store = useCounterStore ();
const handleClickIncrease = (step ?: number ) => () => {
store .increase (step);
};
const handleClickDecrease = (step ?: number ) => () => {
store .decrease (step);
};
return (
< div >
< button type = "button" onClick = { handleClickIncrease ( 10 )}>
Increase 10
</ button >
< button type = "button" onClick = { handleClickIncrease ()}>
Increase
</ button >
< button type = "button" onClick = { handleClickDecrease ( 10 )}>
Decrease 10
</ button >
< button type = "button" onClick = { handleClickDecrease ()}>
Decrease
</ button >
</ div >
);
}
Counter.tsx
Copy import useCounterStore from '../hooks/useCounterStore' ;
export default function Counter () {
const { counter } = useCounterStore ();
return (
< div >
< p >{ `counter: ${ counter } ` }</ p >
</ div >
);
}
이런 접근을 잘 하면, React가 UI를 담당하고, 순수한 TypeScript(또는 JavaScript)가 비즈니스 로직을 담당하는, 관심사의 분리(Separation of Concerns)를 명확히 할 수 있다. 자주 바뀌는 UI 요소에 대한 테스트 대신, 오래 유지되는(바뀌면 치명적인) 비즈니스 로직에 대한 테스트 코드를 작성해 유지보수에 도움이 되는 테스트 코드를 치밀하게 작성할 수 있다.