Front-End(Web)/React - 라이브러리들

[Recoil] Recoil 고수의 길 - atomFamily, selectorFamily

ttaeng_99 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

반응형