본문 바로가기

FrontEnd

Recoil 실전 패턴 : 상태의 계층화와 분리

반응형

원문 보기

 

Recoil Patterns: Hierarchic & Separation

This article will discuss practical patterns in Recoil. It’s an advance topic that goes beyond Recoil basics, so we won’t spend time…

medium.com

Recoil
이 아티클에서는 Recoil의 실전 패턴에 대해 설명합니다.

* 공식 Recoil YouTube
* Recoil documentation

 

이 기사는 WeKnow에서 제공했으며 REDUX에서 Recoil로 이동하는 동안 아키텍처 작업에서 얻은 통찰들을 보여줍니다.

글로벌 상태를 분리된 자율적인 상태로 유지하며, 높은 레벨의 추상화, 합성을 통해 그 상태들을 조합하는 방법에 대해 논의합니다.

 

각 컴포넌트는는 상태 데이터의 다른 부분과 상호 작용할 수 있습니다.
데이터의 원자적(atomic unit) 단위(예: 날짜, 순위, 이름 등) 또는 상태의 더 넓은 부분(요약, 데이터 그리드, 등.)입니다.
이 기사에서는 구조화된 패턴을 사용하여 두 경우 모두의 요구 사항을 충족하는 방법에 대해 설명합니다.
이 문서의 코드 샘플은 GitHub에서 확인할 수 있습니다.

Typescript 및 Styled-Components와 함께 Next.js(SSR)를 사용합니다.
Styled-Component에서는 Styled-Component(Component.ts)로 래핑된 실제 JSX에 대해 접미사 "Raw"(<Component>Raw.tsx)가 있는 컴포넌트명을 사용하는 WeKnow의 규칙을 사용합니다.

Next.js에 익숙하지 않다면 npm run dev를 통해 코드를 실행합니다.
코드 샘플은 다음 다이어그램의 시나리오를 구현합니다.
실제 시나리오를 표현할 수 있을 정도로 복잡하지만 너무 복잡하지 않게 만들려고 노력하겠습니다.(희망 ☺)

이 패턴으로 달성하려는 목표는 다음과 같습니다.
  • 관심사 분리: 각 구성 요소는 필요한 데이터 부분에만 익숙해야 합니다. 데이터의 다른 부분에 대해서는 알 필요가 없습니다.
  • DRY: 각 데이터 단위는 한 번만 선언되어야 합니다.
  • 합성(composition) : 복잡한 상태는 원자 상태의 조합을 나타내야 합니다.
  • 변경 추적(Change Trackin): 개별 상태의 중앙 추적은 현재 상태 저장소에 존재합니다(특정 세분화 아래).
  • 렌더링 최적화: 한 컴포넌트의 변경이 관련 없는 컴포넌트의 렌더링으로 이어지지 않아야 합니다.
 
우리의 상태부터 시작합시다.

Atomic State:

원자 상태는 다음을 나타냅니다.
  • 주문 속성: 제품 ID, 색상, 크기, 개수.
  • 리뷰 속성: 제품 ID, 리뷰어, 별점.

여러 주문/검토 인스턴스를 처리하기 위해 Atom 대신 Recoil의 atomFamily를 사용합니다.

다음 코드 스니펫은 상태의 product-id 단위를 나타냅니다.
import { atomFamily } from ‘recoil’;
import { IRecoilId } from ‘../../interfaces’;
// string이 리턴값, 파라미터가 IRecoilId
export const stateProductId = atomFamily<string, IRecoilId>({
  key: 'state-product-id',
  default: '',
});
atomFamily는 원자 패밀리를 나타냅니다 (같은 클래스의 Entity 데이터와 유사).
Family의 각 member는 키를 통해 액세스할 수 있습니다. 키는 단순(문자열, 숫자 등) 또는 복합(객체)일 수 있습니다.
 
위의 경우 JourneyType 및 ID(주문 혹은 리뷰)를 나타내는 사용자 지정 복합 키를 사용했습니다.
이 복합 키 패턴을 통해 다른 컨텍스트에서 동일한 아톰 구조를 재사용할 수 있습니다.
예를 들어 제품 ID를 review / order에서 사용할 수 있으며, 두 데이터를 완벽하게 분리할 수 있습니다.
(아톰 구조는 재사용 하지만, 두 인스턴스의 데이터는 다르다는 의미)
 
다음 코드 조각은 IRecoilId 구조를 보여줍니다.
import { SerializableParam } from 'recoil';
export interface IRecoilId 
          extends Readonly<Record<string, SerializableParam>> {
  id: string;
  journey: JourneyType;
}
export enum JourneyType {
  order = 'order',
  review = 'review',
}
* extends Readonly<Record<string, string>>은 Recoil의 패밀리 키에 필요합니다. (복합 타입 사용 시)

Tracking:

다음 패턴은 Recoil Family를 사용할 때 거의 필수입니다.
이 기사를 작성하는 시점에서 Recoil에는 아직 Family의 모든 구성원을 가져올 수 있는 연산자가 없습니다.
이는 나중에 모든 구성원을 확보할 수 있으려면 모든 가족 구성원의 상태를 유지해야 함을 의미합니다.
 
다음 코드 조각은 이 개념을 나타냅니다.
export const stateTracking = atomFamily<string[], JourneyType>({
  key: 'state-product-tracking',
  default: [],
});
이 패턴의 이면에 있는 아이디어는 새 Family의 인스턴스를 추가할 때마다(특정 여정에서) 해당 ID를 리스트에 추가해야 한다는 것입니다.
추적 목록의 상호 작용은 목록 원자를 대상으로 하는 직접적이거나, 예를 들어 더 높은 수준의 추상화를 재설정할 때 목록에서 항목을 제거하는 간접적인 것일 수 있습니다(자세한 내용은 나중에 설명).
이제 우리는 원자 데이터 단위를 정의하는 방법을 알고 추적 목록의 개념에 익숙합니다.
지금까지는 다른 튜토리얼에서 볼 수 있는 것과 크게 다르지 않습니다.

Composition and Encapsulation : 

아무도 다른 컴포넌트들의 계층적 데이터를 처리하기 위해 동일한 상태를 계속 사용하는 것을 좋아하지 않습니다.
atom 그룹에 대해 더 높은 수준의 추상화를 갖는 것은 여러 가지 방법으로 우리에게 도움이 될 것입니다.
  • 더 간단하고 사용하기 쉽습니다. 어떤 아톰이 타입과 관련되어 있는지 파악할 필요가 없습니다.
  • 유지보수가 쉽습니다. 주문 또는 리뷰에 새로운 optional 필드를 추가하는 것을 생각해 봅시다.
    • 추상화를 사용하면 다른 위치에서 처리하는 모든 코드 부분을 추적하는 대신 한 곳에서 처리할 수 있습니다.
  • composition — 단일 명령(예: 재설정)을 호출하여 여러 원자에 영향을 주는 집계 작업을 수행할 수 있습니다.
 
 
다음 코드 스니펫은 이 아이디어를 보여줍니다.
export const stateOrder = selectorFamily<IOrder, IRecoilId>({
  key: 'state-order',
  get: (familyKey) => ({ get }) => {
    const { color, size, productId, count } = get(
      waitForAll({
        productId: stateProductId(familyKey),
        color: stateColor(familyKey),
        size: stateSize(familyKey),
        count: stateCount(familyKey),
      })
    );
    return {id: familyKey.id, count, size, color, productId};
  },
  set: (familyKey) => ({ set, reset }, value) => {
    const { journey, id } = familyKey;
    // reset (when recoil's value is empty, will discuss later)
    if (guardRecoilDefaultValue(value)) {
      reset(stateProductId(familyKey));
      reset(stateColor(familyKey));
      reset(stateSize(familyKey));
      reset(stateCount(familyKey));
      // remove from tracking
      set(stateTracking(journey),
               (prv) => [...prv.filter((m) => m !== id)]);
      return;
    }
    // set
    set(stateProductId(familyKey), value.productId);
    set(stateColor(familyKey), value.color);
    set(stateSize(familyKey), value.size);
    set(stateCount(familyKey), value.count);
    // track
    set(stateTracking(journey), (prv) => {
      return [...prv, id];
    });
  }
});
이 selector는 journey type의 특정 주문 ID를 나타내는 매개변수를 가져옵니다.
get 접근자는 주문 세부 정보를 함께 표시하는 관련 atom에서 IOrder의 집계 결과를 반환합니다.
실제 집계는 여러 상태의 결과를 기다리는 waitForAll에 의해 수행됩니다(잠재적인 비동기 작업에 매우 유용함).
다음 코드 스니펫은 이를 사용하는 방법을 보여줍니다. 각 atom 개별적 사용 대신 한 줄로 사용이 가능합니다.
const key: IRecoilId = {
    id,
    journey,
  };
const order = useRecoilValue(stateOrder(key));
 
Set 접근자는 페이로드를 보고 비어 있는지 확인한 다음 재설정 요청을 발행합니다.
(리코일 값이 비어있으면 초기값)
typescript의 타입 가드 개념을 사용합니다.
import { DefaultValue } from 'recoil';
export const guardRecoilDefaultValue = (
  candidate: any
): candidate is DefaultValue => {
  if (candidate instanceof DefaultValue) return true;
  return false;
};
 
재설정을 다루는 스니펫 부분입니다.
if (guardRecoilDefaultValue(value)) {
      reset(stateProductId(familyKey));
      reset(stateColor(familyKey));
      reset(stateSize(familyKey));
      reset(stateCount(familyKey));
      // remove from tracking
      set(stateTracking(journey),
               (prv) => [...prv.filter((m) => m !== id)]);
      return; // no need for further handling
}
 
비어 있지 않은 페이로드는 모든 주문 관련 atom을 설정합니다.
set(stateProductId(familyKey), value.productId);
    set(stateColor(familyKey), value.color);
    set(stateSize(familyKey), value.size);
    set(stateCount(familyKey), value.count);
    // track
    set(stateTracking(journey), (prv) => {
      return [...prv, id];
    });
두 페이로드 모두 추적에 영향을 줍니다.
비어 있지 않은 페이로드는 추적에 새 주문 ID를 추가하고 빈 페이로드는 이를 제거합니다.
이 부작용 처리(side-effect handling )는 추상화 사용의 이점 중 하나입니다.
 

Usage Patterns

불필요한 렌더링을 피하기 위해 코드를 하위 컴포넌트로 분할하는 것이 좋습니다.
각 컴포넌트는 특정 상태 부분을 처리합니다.
컴포넌트 스스로(부모가 아님)가 실제 useRecoil… 문을 수행하는 것이 중요합니다.
(파라미터로 불변 아톰을 전달)

서브 컴포넌트

import { RecoilState } from 'recoil';
export interface IStarsPickerProps {
  state: RecoilState<number>;
}
export const StarsPicker =({ state }: IStarsPickerProps) => {
  const [value, setValue] = useRecoilState(state);
  return (
      <ReactStars
        name="raring"
        value={value}
        onStarClick={(r: number) => setValue(r)}
      />
  );
};

부모 컴포넌트

import React from 'react';
import { useRecoilState } from 'recoil';
import { stateComment, stateStars } from '../../../states';
import { StarsPicker } from '../../ui-units';
export const Details = () => {
  const { id, journey } = useRoutingInfo(); // parse query string
  const key: IRecoilId = { id, journey };
  // best practice (won't cause rendering of the entire component)
  const starState = stateStars(key);
  // bad practice (will cause rendering of the entire component)
  const [comment, setComment] = useRecoilState(stateComment(key));
  return (
    <div>
      <StarsPicker className="raring" state={starState} />
      <input
          className="comment-input"
          type="text"
          value={comment}
          onChange={(e) => setComment(e.target.value)}
        />
    </div>
  );
};

위의 bestPractice는 atom을 파라미터로 넘기라는 뜻

export interface IStarsPickerProps extends IWithClassName {
  state: RecoilState<number>;
}

요약

 
Recoil은 유망한 기술이지만,
기술이 좋다고 비즈니스 및 기술 목표 고려 없이 무작정 도입하면, 문제의 원인이 될수 있습니다.
 
다른 패턴, 기술과 마찬가지로 목표를 달성하는 한 좋은 사용 방법입니다.
GitHub에서 이 기사에 첨부된 소스 코드를 보고 실제 구현을 더 자세히 볼 수 있습니다. 
반응형