-
[Recoil] Recoil 고수의 길 - atomFamily, selectorFamilyFront-End(Web)/React - 라이브러리들 2023. 10. 2. 18:31반응형
최근 프로젝트들을 진행하면서, 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
반응형'Front-End(Web) > React - 라이브러리들' 카테고리의 다른 글
[Recoil] Recoil 고수의 길 - snapshot과 useRecoilCallback (0) 2023.10.05 [Form 라이브러리] React Hook Form (0) 2023.03.19 [Form 라이브러리] formik (0) 2023.03.15 [React Query] (6) Suspense와 Error Boundary 적용 (0) 2022.11.03 [React Query] (5) SSR (0) 2022.08.12