[Recoil] Recoil 고수의 길 - atomFamily, selectorFamily
최근 프로젝트들을 진행하면서, Validator 솔루션에 Recoil을 적극 채용하면서 이런저런 기능들을 시도해봤던 것 같다.
그 과정에서 유용하게 사용한 기능들에 대해, 정리 및 부가학습 차원에서 포스팅을 정리하고자 시작하게 되었다!
🔗 Recoil 이란?
Recoil은 페이스북에서 출시한 React 전용 전역 상태관리 라이브러리이다.
이전, Redux 등 라이브러리들과 다르게, 전역상태를 atomic하게 각 컴포넌트에서 구독 및 업데이트(리렌더) 되는 것이 특징이다.
* 자세한 내용은 이전 포스팅을 참고해주길 바란다. (참고링크)
Recoil의 기본기능인 atom, selector 만으로도 동기/비동기 등의 전역상태들을 운용할 수 있다.
하지만, 이번 포스팅에선 그 이외의 기능을 적용한 사례와 이점에 대해 적어보려고 한다!
🔗 atomFamily
atomFamily는 recoil의 상태인 atom을 팩토리처럼 제작할 수 있는 기능이다. 쉽게 말하면, 비슷한 패턴의 atom을 양산하는 것이다.
- 문법
// atom 예시
// 제작
export const exampleAtom = atom<number>({
key: 'exampleAtom',
default: 0,
});
// 사용
const [example, setExample] = useRecoilState(exampleAtom);
// atomFamily 예시
// 제작
export const exampleAtom = atomFamily<number, string>({
key: 'exampleAtom',
default: 0,
});
// 사용
const [example, setExample] = useRecoilState(exampleAtom('key'));
이처럼, atomFamily는 사용시 고유한 키를 반드시 전달해야 한다. 다양한 atom 팩토리에서 어떤 상태를 사용할 지 지정하는 것이다.
또한, 타입스크립트의 경우 atom은 값에 대한 타입만 지정하나, atomFamily는 값, 키 상태 각각을 제네릭 인자로 넘긴다.
1) atomFamily 초기값에 고유키 적용
export const exampleAtom = atomFamily({
key: 'exampleAtom',
default: (key) => {
return {
id: key,
value: null,
}
},
});
atomFamily의 default는 함수형태로 작성 가능하다. 이 함수는 고유키를 인자로 받아, atom상태를 반환하는 형태이다.
이 값을 atom에 반영하고자 하는 경우 위 예시처럼 반환값에 포함시켜주면 되는 것이다.
2) atomFamily 각각에 대한 초기값 적용
const elementPositionStateFamily = atomFamily({
key: 'ElementPosition',
default: [0, 0],
});
Recoil 공식문서를 보다가 확인한 문법이다! 이처럼, atomFamily 인자는 key, default 모두 객체형태로도 전달 가능하다.
- 사용예시
interface Props {
uniqueKey: string;
}
const Item = ({ uniqueKey }: Props) => {
const [data, setData] = useRecoilState(itemAtom(uniqueKey));
return (
<>
<p>data: {data}</p>
<button
onClick={() => setData(data+1)}
>
change
</button>
<>
);
};
사용하는 방법은 여러가지 있겠지만, 보통 반복되는 컴포넌트에서 각 상태를 atomFamily로 만드는 경우가 많을 것이다.
즉, 이를 사용하는 컴포넌트의 Props로 atomFamily의 key를 받아 전달해줘도 된다.
🔗 selectorFamily
Recoil에는 selector 기능이 존재한다.
기본적으론 다른 atom에서 파생된(derived) 상태값을 반환하나, setter로 다른 atom을 수정할 수 있어 비동기 패칭 등에도 사용된다.
Recoil은 이와 동일한 컨셉으로, SelectorFamily를 제공한다. 사용법은 atomFamily + selector 와 매우 동일하다.
// recoil.ts
export const charOrder = atomFamily<number, number>({
key: 'charOrder',
default: (orderId) => orderId,
});
export const charText = selectorFamily<string, number>({
key: 'charText',
get: (id) => ({ get }) => {
const order = get(charOrder(id));
return (order + 9).toString(36).toUpperCase();
},
});
// component.tsx
const Example = () => {
const [initA, setInitA] = useRecoilState(charOrder(1));
const initAText = useRecoilValue(charText(1));
const [initB, setInitB] = useRecoilState(charOrder(2));
const initBText = useRecoilValue(charText(2));
return (
<>
<p>{initA}</p> // 1
<p>{initAText}</p> // A
<p>{initB}</p> // 2
<p>{initBText}</p> // B
<>
);
};
* family에 객체키를 준다면?
const [valA, setValA] = useRecoilState(exampleAtom({ key: 'A', lowerKey: 'a' }));
const [valB, setValB] = useRecoilState(exampleAtom({ key: 'A', lowerKey: 'a' }));
위와 같은 코드가 있다고 가정하자! atomFamily에 키로 전달한 객체는 형상은 같지만 얕은비교를 한다면 다른 객체로 판단할 수 있다.
(JS의 객체 프로토타입이 다르다면 다른 객체로 인식하는 원리)
결론적으론, 두 전역상태는 같은 atomFamily를 바라보게 된다. (키가 객체면 깊은복사를 하는 듯)
즉, atomFamily 키 값으로 string, number 뿐만 아니라 객체를 전달해도 무방한 것이다!
🔗 atomFamily & selectorFamily 적용 예시
이번에 Recoil의 Family를 리서치하면서, 보거나 혹은 직접 적용했을 때 좋았던 예시들을 같이 소개해볼까 한다!
1. To-Do List
// recoil.ts
interface TodoItemState {
id: string;
content: string;
isEditing: boolean;
};
export const todoItemState = atomFamily<TodoItemState, string>({
key: 'todoItemState',
default: (id) => ({
id,
content: '',
isEditing: false,
}),
});
// TodoItem.tsx
interface Props {
todoItem: {
id: string;
content: string;
};
}
const TodoItem = ({ todoItem }: Props) => {
const [todoItemState, setTodoItemState] = useRecoilState(todoItemState(todoItem.id));
const startEditContent = () => {
setTodoItemState({ ...todoItemState, isEditing: true });
};
const handleChangeContent = (e: ChangeEvent<HTMLInputElement>) => {
setTodoItemState({ ...todoItemState, content: e.target.value });
};
const handleSubmitContent = () => {
// TodoItem 수정(PUT) API호출
setTodoItemState({ ...todoItemState, isEditing: false });
};
if (todoItemState.isEditing) {
return (
<>
<input value={todoItemState.content} onChange={handleChangeContent} />
<button onClick={handleSubmitContent}>확인</button>
</>
);
};
return (
<>
<p>{todoItemState.content}</p>
<button onClick={startEditContent}>수정하기</button>
</>
);
};
위처럼, To-Do List의 각 아이템의 수정여부 및 수정값을 atomFamily로 동적으로 생성할 수 있다.(key는 아이템 고유id)
배열, 객체에 직접 인덱스를 비교하는 것에 비해, key에 대응하는 atomFamily 상태를 바로 바라보기에 좀 더 직관적이다.
* To-Do List의 To-Do 항목, 게시판의 게시글 등에 적용할 수 있다. 로컬상태로도 충분하나, 컴포넌트 계층이 복잡해진다면 추천!
2. Field Validation
이번 글을 쓰게 된 가장 큰 계기가 아닐까 싶다.
폼 벨리데이션을 직접 구현하면서, 동적으로 증감하는 필드들의 값, 에러 등 벨리데이션을 SelectorFamily를 통해 쉽게 구현했다.
1) recoil 설정
// recoil/validate.ts
export interface ValidateField {
fieldId: string;
value: ValidateValue;
isValid: boolean;
invalidMessage: string;
onValidate: boolean;
}
export const validateFieldAtom = atomFamily<ValidateField, string>({
key: 'validateFieldAtom',
default: (fieldId) => ({
fieldId,
value: null,
isValid: false,
invalidMessage: '',
onValidate: false,
}),
});
export const validateFieldSelector = selectorFamily<ValidateField, string>({
key: 'validateFieldSelector',
get:
(fieldId) =>
({ get }) => get(validateFieldAtom(fieldId)),
set:
(fieldId) =>
({ get, set, reset }, value) => {
if (value instanceof DefaultValue) {
reset(validateFieldAtom(fieldId));
set(validateFieldIdsAtom, (prevIds) => prevIds.filter((vid) => vid !== fieldId));
} else {
set(validateFieldAtom(fieldId), value);
const fieldIds = get(validateFieldIdsAtom);
if (!fieldIds.includes(fieldId)) set(validateFieldIdsAtom, [...fieldIds, fieldId]);
}
},
});
export const validateFieldIdsAtom = atom<string[]>({
key: 'validateFieldIdsAtom',
default: [],
});
export const validateFieldValuesSelector = selector<Record<string, ValidateValue>>({
key: 'validateFieldValuesSelector',
get: ({ get }) => {
const fieldIds = get(validateFieldIdsAtom);
const values = fieldIds.map(id => ({ [id]: get(validateFieldAtom(id)).value });
return values;
},
});
먼저, recoil 전역상태들을 위처럼 설정했다.
- validateFieldAtom : 단일필드 정보(id, 값, 에러여부 및 메세지 등). 이를 직접 사용하진 않고, selectorFamily를 통해 get한다
- validateFieldSelector : 상태는 위 atom값을, setter 함수는 atom수정 및 Ids에 아이디를 추가를 담당(resetter는 제거)
- validateFieldIdsAtom : 생성된 atomFamily들의 Id들 배열
- validateFieldValuesSelector : 위 validateFieldIdsAtom 의 id들을 통해, 각 atom의 value만 맵핑한 전체 값 객체
2) 사용부분
// hooks/useValidateField.ts
export interface ValidateFieldOptions {
fieldId: string;
validators?: ValidateFunction[];
defaultValue?: ValidateValue;
}
const useValidateField = ({
fieldId,
validators = [],
defaultValue,
}: ValidateFieldOptions) => {
const [fieldData, setFieldData] = useRecoilState<ValidateField>(validateFieldSelector(fieldId));
const resetFieldData = useResetRecoilState(validateFieldSelector(validateFieldSelector(fieldId)));
const updateFieldData = useCallback(
(value: ValidateValue, onValidate = false) => {
const invalidMessage =
validators?.map((validator) => validator(String(value))).find((is) => is) || '';
setFieldData((field) => {
const nextFieldData = {
fieldId: field.fieldId,
value,
isValid: !invalidMessage,
invalidMessage,
onValidate,
};
return nextFieldData;
});
},
[fieldId, setFieldData, validators],
);
const initValidateField = useCallback(() => {
updateFieldData(defaultValue || '', true);
}, [defaultValue, updateFieldData]);
const handleChange = (value: ValidateValue) => {
updateFieldData(value, false);
};
const resetValidateField = useCallback(() => {
resetFieldData();
}, [fieldKey, resetFieldData]);
useEffect(() => {
initValidateField();
return () => {
resetValidateField();
};
}, []);
return {
fieldId,
isError: fieldData.onValidate && !fieldData.isValid,
invalidMessage: fieldData.invalidMessage,
defaultValue,
handleChange,
};
};
export default useValidateField;
위는, 내가 각 필드에 벨리데이션을 적용하기 위해 만든 useValidateField() 커스텀 훅의 일부분을 적어둔 것이다.
우선 updateFieldData 메서드로, 각 필드에서 init(mounted), change 등 이벤트로 값을 받을 때 atomFamily를 업데이트 하도록 했다. 이 때, selector의 set을 통해 Ids도 업데이트 될 것이다.
또한, useResetRecoilState로 정의한 resetValidateField 메서드는, atomFamily를 리셋함과 동시에 selector의 reset으로 Id도 지워주는 동작을 담당하게 된다.
이처럼 selectorFamily을 활용한다면, atomFamily의 값을 조회할 뿐만 아니라, set, reset 등 우리가 커스텀한 메서드를 통해 다른 상태나 Family들의 값 조작도 가능하게 되는 것이다.
3) 전체 값 사용 - 이슈!
const entireFieldValues = useRecoilValue(validateFieldValuesSelector);
전체 값을 사용해야하는 경우, 위처럼 useRecoilValue로 selector를 가져오면 된다. (통상, 폼 혹은 제출버튼 컴포넌트 쪽)
단, 이 상태값은 모든 atomFamily들을 조회하고 있기에, 각 필드들이 업데이트 될 때 마다 같이 리렌더링을 트리거하게 될 것이다.
그렇기에, 이 컴포넌트를 최소화해서 성능이슈를 해결해야 할 것이다.
* 이 부분이 이슈가 되어, 나는 전체값을 selector가 아닌 ref(상위 컨텍스트의)로 설정했다. 이는, 추후 벨리데이션 포스팅에서 자세히 설명하도록 하겠다!
atomFamily, selectorFamily의 사용법에 대해 자세히 알아보았다.
특히, API 자체적인 사용법뿐만 아니라, 전체값을 관리할 수 있는 솔루션에 대해서도 소개해드렸다.
하지만, 결국 이 값들도 각각의 state이기 때문에, 적절하게 사용하지 않으면 오히려 무분별한 리렌더링 등 성능 이슈를 야기할 수 있다.
📌 References
- Recoil 공식문서 : https://recoiljs.org/ko/docs/api-reference/utils/atomFamily/
- 2ast님의 블로그 : https://velog.io/@2ast/React-Recoil%EC%9D%98-atomFamily%EC%99%80-selectorFamily-%EC%82%AC%EC%9A%A9%ED%95%B4%EB%B3%B4%EA%B8%B0