ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Recoil] Recoil 고수의 길 - atomFamily, selectorFamily
    Front-End(Web)/React - 라이브러리들 2023. 10. 2. 18:31
    반응형

    최근 프로젝트들을 진행하면서, Validator 솔루션Recoil을 적극 채용하면서 이런저런 기능들을 시도해봤던 것 같다.

    그 과정에서 유용하게 사용한 기능들에 대해, 정리 및 부가학습 차원에서 포스팅을 정리하고자 시작하게 되었다!


    🔗 Recoil 이란?

    Recoil은 페이스북에서 출시한 React 전용 전역 상태관리 라이브러리이다.

    이전, Redux 등 라이브러리들과 다르게, 전역상태를 atomic하게 각 컴포넌트에서 구독 및 업데이트(리렌더) 되는 것이 특징이다.

    * 자세한 내용은 이전 포스팅을 참고해주길 바란다. (참고링크)

     

    [Recoil] 전역 상태관리 라이브러리 - Recoil 정복기

    🧐 서론 굉장히 오랜만에 쓰는 서론인 것 같다!! 그만큼 이 글의 길이가 짧진 않을거라는 마음의 준비 차원일지도? 오랜만에 React를 복기하고 Typescript를 숙달할 겸 예전에 면접과제로 받았던 메

    abangpa1ace.tistory.com

     

    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

    반응형
Designed by Tistory.