이전에 recoil의 기본이 되는 atom과 그런 atom에서 파생된 selector에 대해 알아보았다. 이번에는 atom과 selector를 팩토리처럼 사용할 수 있는 family에 대해 알아보고 사용해보려고 한다.
family는 쉽게 말해서 비슷한 기능을 하는 별개의 atom(혹은 selecor)가 필요한 경우 필요한 만큼 각각 선언하는 대신 하나의 형태를 선언해 놓고 팩토리처럼 언제든 새롭게 만들어 사용할 수 있는 기능을 말한다. 말로는 설명이 어려우니 직접 예시코드를 보자.
export const AState = atomFamily({
key: "AState",
default: "AState"
});
export const BState = atom({
key: "BState",
default: "BState"
});
이처럼 선언하는 파트 자체는 atom과 동일한 모습이다. 그리고 사용할 때도 마찬가지로 useRecoilState로 불러와 사용한다. 하지만 한가지 다른점이라면 atomFamily로 선언한 Astate는 하나의 parameter를 받는 function이라는 점이다.
console.log(typeof AState) // function
console.log(typeof BState) // object
따라서 useRecoilState로 불러올 때 params를 입력하지 않으면 이런 무지막지한 에러를 만날 수 있다.
const [a,setA] = useRecoilState(AState)
그러니 family를 사용할 때는 꼭 params를 넣어주도록 하자.
const [a,setA] = useRecoilState(AState("APage"))
사실 family를 사용하는 목적을 생각하면 너무나 당연한 일이다. family는 그 자체로 state로써 사용하는 것이 목적이 아니라, 유사한 목적을 가진 여러개의 state가 필요할 때 필요한 만큼 찍어내기 위한 팩토리의 목적을 가지고 있다. 따라서 각 state를 구분하기 위한 최소한의 식별자가 필요한 것이다. 이를 단적으로 보여주기 위해 간단한 화면을 구성해 봤다.
//store.js
export const sampleState = atomFamily({
key: "sampleState",
default: "sample",
});
//AComponent.js
const AComponent = () => {
const [sample, setSample] = useRecoilState(sampleState("same"));
return (
<div>
{sample}
<button onClick={() => setSample(Math.random())}>A에서 바꾸기</button>
</div>
);
};
export default AComponent;
//BComponent.js
const BComponent = () => {
const [sample, setSample] = useRecoilState(sampleState("same"));
return (
<div>
{sample}
<button onClick={() => setSample(Math.random())}>B에서 바꾸기</button>
</div>
);
};
export default BComponent;
//App.js
const App = () => {
return (
<div>
<AComponent />
<BComponent />
</div>
);
};
export default App;
A와 B component에서 atomFamily로 선언한 sampleState를 가져와 사용하고 있으며, 두 컴포넌트 모두 params로 'same'이라는 동일한 값을 넘기고 있다. 즉, A, B 컴포넌트에서 동일한 state를 사용하고 있음을 명시하고 있는 것이다. 실제로 실행해보면 A component에서 state를 변경했을 때 B component에서도 동일하게 반영되고 있다.
이번에는 서로 다른 params를 넘겨보도록 해보자
//AComponent.js
const [sample, setSample] = useRecoilState(sampleState("A Component"));
//BComponent.js
const [sample, setSample] = useRecoilState(sampleState("B Component"));
동일한 sampleState로 부터 파생된 state지만 params을 다르게 주니 별개의 state로 인식하여 각각 사용할 수 있게 되었다.
만약 주어진 params에 따라서 default value를 바꾸고 싶다면 이런식으로 default에 함수를 쓰는 것도 가능하다.
export const sampleState = atomFamily({
key: "sampleState",
default: (params) => "sample from" + params,
});
atomFamily의 활용 가능성은 무궁무진하지만 가장 먼저 떠오른 것은 false와 true 두개의 상태를 가지는 toggle state에 사용할 수 있을 것 같다. 이를테면 modal의 visible 상태를 표현하는 state가 있을 수 있다. 만약 프로젝트에 5개의 모달이 있다고 한다면 각 모달에 맞춰 5개의 visible state를 선언해 사용해야 한다. 하지만 atomFamily를 사용하면 훨씬 간편하게 사용할 수 있다.
//store.js
const toggleState = atomFamily({
key:'toggleState',
default: false
})
//someComponent.js
...
const [isVisible,setIsVisible] = useRecoilState(toggleState("enroll"))
...
//otherComponent.js
...
const [isVisbile,setIsVisible] = useRecoilState(toggleState("logout"))
...
//anotherComponent.js
...
const [isVisbile,setIsVisible] = useRecoilState(toggleState("greeting"))
...
일단 selectorFamily의 사용법은 atomFamily와 완전히 동일하다. 따라서 바로 selectorFamily를 활용한 코드를 살펴보자. [Recoil selector 만져보기]에서 만들었던 환전 계산기를 확대해 엔화까지 추가해보려고 한다. 대신 이번에는 엔화를 목적으로 별도의 selector를 만드는 대신에 selectorFamily를 이용해 구현할 예정이다. 주요 변경사항만 반영할 예정이니 전체 코드를 보고싶으면 [Recoil selector 만져보기]을 참고하면 될 것 같다.
//store.js
export const wonState = atom({
key: "wonState",
default: 0,
});
export const exchangeState = selectorFamily({
key: "exchangeState",
get:
(params) =>
({ get }) => {
const rate = params === "dollar" ? 1400 : 10;
return get(wonState) / rate;
},
set:
(params) =>
({ set }, dollar) => {
const rate = params === "dollar" ? 1400 : 10;
set(wonState, dollar * rate);
},
});
//Dollar.js
...
const [dollar, setDollar] = useRecoilState(exchangeState("dollar"));
...
//Yen.js
...
const [yen, setYen] = useRecoilState(exchangeState("yen"));
...
//App.js
const App = () => {
return (
<div>
<Won />
<Dollar />
<Yen />
</div>
);
};
export default App;
exchangeState의 params를 분석해 'dollar'라면 달러의 환율을, 아니라면 엔화의 환율을 적용하는 로직을 구현해보았다. 이렇듯 selectorFamily를 사용하면 각 통화에 대해 별도의 selector를 작성할 필요 없이 코드를 재사용할 수 있게 된다.
결과를 봐도 문제 없이 동작하는 것을 볼 수 있다.
이번에는 간단한 예시를 위해 params가 'dollar'인지만 확인 했지만, 만약 통화의 숫자가 늘어난다면 params를 인수로 받아 exchagneRate를 반환해주는 함수를 구현할 수도 있고, [휴먼에러 방지를 위한 문자열 관리 꿀팁]의 내용을 따라 통화명을 입력할 때의 오타를 방지할 수도 있다.
//store.js
export const exchangeState = selectorFamily({
key: "exchangeState",
get:
(params) =>
({ get }) => {
const rate = getExchangeRate(params)
return get(wonState) / rate;
},
set:
(params) =>
({ set }, dollar) => {
const rate = getExchangeRate(params)
set(wonState, dollar * rate);
},
});
//constants.js
export currency = {
dollar:'dollar',
yen: 'yen',
...
}
//Dollar.js
...
const [dollar, setDollar] = useRecoilState(exchangeState(currency.dollar));
...
api를 호출해 실시간 환율을 적용하고 싶다면 [Recoil selector로 비동기 작업 수행하기]의 내용을 참고하여 http 요청 주소에 문자열 포맷팅을 사용해 동적으로 필요한 데이터를 받아올 수도 있다.
family가 같은 params에 대해 같은 state로 취급하는 것은 알았다. 하지만 그렇다면 params를 object로 주었을 때는 어떻게 될까? 같은 데이터를 담고 있는 서로 다른 두개의 object는 참조값이 다르기 때문에 얕은 비교를 하면 js는 다른 값이라고 인식한다. 과연 family에서도 그럴지 궁금해져서 한번 실험해봤다.
//store.js
export const sampleState = atomFamily({
key: "sampleState",
default: (params) => "sample from " + params.component,
});
//AComponent.js
...
const [sample, setSample] = useRecoilState(
sampleState({ component: "where" })
);
...
//BComponent.js
...
const [sample, setSample] = useRecoilState(
sampleState({ component: "where" })
);
...
//App.js
const App = () => {
return (
<div>
<AComponent />
<BComponent />
</div>
);
};
export default App;
아까 구현해놨던 코드에서 params로 string이 아니라 object를 받도록 수정했다. 기본적으로 AComponent에서 params로 주는 object와 BComponent에서 params로 주는 object는 같은 내용을 갖지만 서로 다른 참조값을 갖는 다른 객체다. 따라서 이 두 state는 서로 다른 state로 취급되는 것이 예상된다. 그리고 실제로 실행을 해보면
같은 state로 인식한다. recoil의 family는 params를 깊은 비교를 통해 비교해서 같은 state인지 판단하고 있었다. 이는 굉장히 다행스러운 일을 수밖에 없는데, 만약 같은 내용을 담고 있는 object를 다른 객체라고 인식했다면 params로 넘길 수 있는 데이터에 엄청난 제약이 걸릴 뻔했고, 이를 우회하기 위해 결코 쉽지만은 않은 과정이 필요했을 것이다.
섬세한 Recoil에게 cheers...
좋은 글 감사합니다!