Atomic state management – Jotai

 

안녕하세요. 화해팀 프론트엔드 플랫폼 박제훈입니다. jotai

 

최근 플랫폼 내에서 상태 관리에 대한 기술 흐름 파악 및 팀 내 표준화를 위해서 논의를 진행하려 하고 있었고 관련해서 조사하던 내용들을 글로 정리해보려 합니다.

 

 


 

매번 해왔던 고민

 

저희 프론트엔드 플랫폼은 UI 구현을 위한 라이브러리로 리액트를 메인 기술 구조로 채택하여 사용하고 있습니다. 그런데 새로운 프로젝트가 시작되면 리액트와 함께 구현할 기반 기술들을 선택할 때 자주 논의되었던 주제가 상태 관리였습니다. 과거엔 상태 관리를 위해 당연하듯 Redux를 고려했었는데 리액트 v16.8의 hook의 등장 이후로 많은 선택지가 생겨나게 되었고 이에 따라 저희 역시 새로운 시도에 대한 고민을 해왔습니다. 이럴 때마다 논의의 내용은 항상 비슷했었습니다.

  • 요즘의 상태 관리 라이브러리들은 어떠한 변화를 겪고 있고 어떤 라이브러리가 선호될까?
  • 상태 관리 라이브러리를 사용하지 않고 Context 만으로 충분히 관리될 규모일까?
  • 대부분의 상태 관리를 해야 하는 것들이 response 데이터라면 swr이나 react-query만으로 구현할 수도 있지 않을까?

위와 같은 내용으로 몇번의 논의 이후 플랫폼 내 스터디를 통해서 리액트 상태 관리에 대한 기반 지식들을 되짚고 갈 필요가 있다고 생각되었고, 플랫폼 내부에서 나눠서 여러 상태 관리 라이브러리에 대한 조사를 시작하고 각자 세미나를 진행하기로 하였습니다. 저는 자료를 조사해가던 중 Kent C. Dodds의 블로그 글을 보다가 Jotai라는 상태 관리를 언급한 문장을 보았습니다.

 

 

💡 recoil과 jotai는 매우 유사하며 같은 문제를 해결한다. 하지만 내 경험상 jotai를 선호한다. (Recoil and jotai are very similar (and solve the same types of problems). But based on my (limited) experience with them, I prefer jotai.)

 

 

20년 7월 즈음 작성된 글이어서 약 1년 정도 지난 시점에선 두 라이브러리에 변화가 많이 있었겠지만, jotai를 특별히 언급한 부분에 흥미를 느끼게 되었습니다. 이후 jotai의 철학과 컨셉, 그리고 코드를 훑어보면서 조사한 내용들을 플랫폼 인원들과 공유하면 좋겠다는 생각으로 글을 시작하게 되었습니다.

 

 

 

 

re-render, re-render

 

jotai의 컨셉 문서를 읽어보면 시작은 Context의 re-rendering 문제에서 출발한 것 같다는 생각이 들었습니다. Context를 활용해 상태 관리를 할 때 발생하는 대표적인 문제는 아래와 같은 케이스입니다.


// type, component는 생략하였습니다.

const reducer = (state: FruitState, action: Action): FruitState => {
  switch (action.type) {
    case UPDATE_TITLE:
    case UPDATE_COLOR:
      return { ...state, [action.payload.key]: action.payload.value };
    default:
      throw new Error("error");
  }
};

const Header = () => {
  const { dispatch } = useContext(FruitsContext);
  const handleClickTitle = () => {
    dispatch({
      type: UPDATE_TITLE,
      payload: { key: "title", value: getRandomFruit() },
    });
  };
  const handleClickColor = () => {
    dispatch({
      type: UPDATE_COLOR,
      payload: { key: "color", value: getRandomColor() },
    });
  };

  return (
    <HeaderWrapper>
      <Button onClick={handleClickTitle}>change title</Button>
      <Button onClick={handleClickColor}>change color</Button>
    </HeaderWrapper>
  );
};

const Title = () => {
  const { fruitData } = useContext(FruitsContext);
  return <h2>{fruitData.title}</h2>;
};

const Color = () => {
  const { fruitData } = useContext(FruitsContext);
  return <ColorBox style={{ backgroundColor: fruitData.color }} />;
};

export const ContextSample = () => {
  const [fruitData, dispatch] = useReducer(reducer, {
    title: getRandomFruit(),
    color: getRandomColor(),
  });

  return (
    <Wrapper>
      <FruitsContext.Provider value={{ fruitData, dispatch }}>
        <Header />
        <div>
          <Title />
          <Color />
        </div>
      </FruitsContext.Provider>
    </Wrapper>
  );
};

 

위 코드는 일반적으로 Context를 활용한 상태 관리의 예시가 될 수 있다고 생각되어 작성해보았습니다.

 

 

jotai_context예시

 

 

TitleColor의 state 업데이트 여부와 관계없이 Context가 업데이트되는 순간 Context 하위에 있는 컴포넌트는 전부 re-rendering이 됩니다. 예제가 극단적인 예시일 수 있지만 위와 같이 사용할 때 dispatchstate를 분리해서 별도의 Context로 관리하는 방법, 필요한 값만 memoization 하는 방법, Context를 지연 업데이트하는 방법 등으로 회피를 해볼 수 있지만 이런 부분까지 최적화하여 사용해야 하는 것인지 항상 의문이 들었습니다. 이와 관련해 리액트 팀에서도 문제를 느껴 Context Selector RFCContext 변경 지연 전파 RFC 와 같은 Context 개선 과제를 진행했습니다. 이를 기반으로 한 useContextSelectorPR로 올라온 상태였으며 개선은 꾸준히 진행 중입니다. (참고: Context 변경 지연 전파는 얼마 전 merge 되었습니다.)

 

위와 비슷하게 jotai의 maintainer는 상태 관리, 그중에서도 Context의 효율적인 관리에 대한 접근이 많았다고 생각됩니다. jotai를 만들기 전 use-context-selector라는 라이브러리로 Context의 변경점만 반영될 수 있도록 접근하였습니다.

 

use-context-selector는 아래와 같은 방식으로 사용됩니다.


// 사용할때
const count1 = useContextSelector(context, v => v[0].count1);
const setState = useContextSelector(context, v => v[1]);

// 컴포넌트
const StateProvider = ({ children }) => {
  const [state, setState] = useState({ count1: 0, count2: 0 });
  return (
    <context.Provider value={[state, setState]}>
      {children}
    </context.Provider>
  );
};

 

현재 리액트 팀의 useContextSelector RFC에 올라온 방식도 굉장히 유사한 방식으로 접근하고 있습니다.


const {a} = useContextSelector(Context, context => context.a);
const {b} = useContextSelector(Context, context => context.b);
const derived = useMemo(() => computeDerived(a, b), [a, b]);

 

위 내용을 소개 한 이유는 jotai는 리액트의 useStateuseContext만으로 충분히 상태 관리가 가능한 프로젝트 규모에서 고려해볼 만하고, Context의 re-rendering 문제를 해소하기 위한 접근으로 만들어졌다고 생각하기 때문입니다. 위 컨셉은 jotai에서 계속 활용되고 있습니다.

 

 

 

 

Jotai?

 

jotairecoil이라는 리액트 팀에서 직접 만든 상태 관리 라이브러리가 등장한 후, atomic model 기반의 상향식 접근 방법에 영감을 받아 만들어졌습니다. jotai는 일본어로 상태를 뜻합니다. 같은 개발 그룹인 Pmndrs에서 만든 Zustand라는 상태 관리 라이브러리도 있는데 이는 독일어로 상태라는 뜻이라고 합니다. jotairecoilzustandredux와 비슷하다고 비교 문서에 작성이 되어있습니다.

 

메인테이너의 블로그에는 그동안 상태 관리에 대해 고민한 흔적들이 보이는데 이 글을 읽어보면 jotai의 전신 프로젝트였던 use-atom을 만들면서 global state management에 대해 많이 고민한 것을 알 수 있습니다. 기본 철학은 useStateuseReducer 같은 기존 리액트의 사용성을 그대로 가져오면서 core api는 minimalistic 하게 디자인되어 있습니다.


import { atom } from 'jotai';

const countAtom = atom(0);

function Counter() {
  const [count, setCount] = useAtom(countAtom);
  return (
    <h1>
      {count}
      <button onClick={() => setCount(c => c + 1)}>one up</button>
    </h1>);
}

 

기본 컨셉은 atomic 한 상태로 작은 단위로 관리하고, atomic 상태에서 파생된 “계산된” 결과를 받아봅니다.


// 위 예제에 이어서
const doubledCountAtom = atom((get) => get(countAtom) * 2)

function DoubleCounter() {
  const [doubledCount] = useAtom(doubledCountAtom)
  return <h2>{doubledCount}</h2>
}

 

위 Context로 작성한 예제를 jotai로 아래와 같이 작성할 수 있습니다.


// atom 생성
const titleAtom = atom(getRandomFruit());
const colorAtom = atom(getRandomColor());

const Header = () => {
  // setter
  const [, setTitle] = useAtom(titleAtom);
  const [, setColor] = useAtom(colorAtom);

  const handleClickTitle = () => {
    setTitle(getRandomFruit());
  };
  const handleClickColor = () => {
    setColor(getRandomColor());
  };

  return (
    <HeaderWrapper>
      <Button onClick={handleClickTitle}>change title</Button>
      <Button onClick={handleClickColor}>change color</Button>
    </HeaderWrapper>
  );
};

const Title = () => {
  const [title] = useAtom(titleAtom);
  return <h2>{title}</h2>;
};

const Color = () => {
  const [backgroundColor] = useAtom(colorAtom);
  return <ColorBox style={{ backgroundColor }} />;
};

export const JotaiSimpleSample = () => {
  return (
    <Wrapper>
      <Provider>
        <Header />
        <div>
          <Title />
          <Color />
        </div>
      </Provider>
    </Wrapper>
  );
};

 

그러면, 아래와 같이 업데이트 되는것만 렌더가 됩니다.

 

 

jotai_렌더

 

 

기본 사용법은 여기까지고, 이후부터는 조합된 사용법 혹은 확장된 사용법 그리고 다른 라이브러리와 integration 하는 응용 사례들이 있습니다.

 

 

 

 

Concurrent Mode

 

리액트에서는 동시 모드라는 실험적인 기능을 도입하고 있습니다. 최종적으로 리액트의 stable 버전에 도입이 된다면 미래 리액트를 사용할 때의 큰 변곡점이 될 것이라 기대가 되는데, 저는 이 모드에서의 가장 중요한 개념은 tearing이라 생각했습니다. tearing이란 요약하자면 동시 모드에서 컴포넌트의 렌더 트리가 외부 상태 업데이트를 통해서 갱신되는 도중에 다른 업데이트가 다시 발생하여 렌더링이 깨진 듯한 현상입니다. 스택오버플로우의 질문 글에서 redux의 메인테이너가 답변을 작성한 내용도 있고, React Next 2019의 발표 영상에서 구체적으로 설명한 내용도 있으니 참고하시기 바랍니다.

 

jotai의 메인테이너는 여러 상태 관리 라이브러리들에서 다양한 방법으로 tearing 현상 발생 테스트를 진행하였고 tearing 현상에 대한 고려를 많이 한 것 같습니다. 이 저장소에서 테스트에 대한 내용을 확인해볼 수 있습니다.

 

 

jotai_테스트

위 tearing 현상에 대한 이미지는 해당 저장소에서 가져왔습니다.

 

 

동시 모드는 react state를 사용하지 않는 redux와 같은 외부 상태 관리 라이브러리들에게 상태 관리 관점에서 해결해야 할 많은 과제들을 주었다고 생각됩니다. 리액트 팀에서는 동시 모드에서 안전한 상태 관리를 위한 useMutableSource RFC를 제안합니다. 아직 실험 모드에서 사용할 수 있는 상태이지만 이 PR에서 오랫동안 논의가 되었고 동시 모드에서 상태 관리를 하는 방법에 대한 고민을 많은 리뷰어들이 진행했습니다.

 

jotai의 내부 상태 관리는 useMutableSource를 활용할 계획으로 보이며 인터페이스를 맞추고 내부에서 관리하는 코드에서 아래와 같이 리액트 stable 버전이 release 될 경우 차용할 대비를 하는 것 같습니다.


// jotai/src/core/useMutableSource.ts

/*
export {
  unstable_createMutableSource as createMutableSource,
  unstable_useMutableSource as useMutableSource,
} from 'react'
*/

// useMutableSource emulation almost equivalent to useSubscription

import { useEffect, useRef, useState } from 'react'

const TARGET = Symbol()
const GET_VERSION = Symbol()

export const createMutableSource = (target: any, getVersion: any): any => ({
  [TARGET]: target,
  [GET_VERSION]: getVersion,
})

export const useMutableSource = (
  source: any,
  getSnapshot: any,
  subscribe: any
) => {
// ... 하략

 

useMutableSource의 동작에 대해서는 해당 RFC를 제안했던 저자의 Gist에 정리가 더 되어있으니 참고하면 좋을 것 같습니다. 동시 모드로 개발하는 시기가 오게 될 때도 jotai에서 같이 고민하고 발전할 것으로 보여서 향후 소스코드를 지켜보는 것도 즐거울 것 같습니다.

 

 

 

 

Features

사실 공식 문서가 생각보다 굉장히 잘 되어있는 편이라 공식 문서를 정독하는 것을 추천드립니다. 저는 간략하게 소개하고 넘어가려 합니다.

 

 

atom & useAtom

생성은 아래와 같이 config라 부르는 초기 값을 넣어주면 됩니다.


const priceAtom = atom(10)
const messageAtom = atom('hello')
const productAtom = atom({ id: 12, name: 'good stuff' })

// primitive 타입의 atom은 아래와 같은 형태로 생성
const primitiveAtom = atom(initialValue)

 

atom은 상태를 원자 단위로 관리하기 위한 하나의 단위로 보면 좋을 것 같습니다. atom은 상태이고 useAtom hook으로 관리됩니다. recoil과 차이점은 atom을 생성할 때는 key가 필요 없습니다.

 

atom은 아래 3가지 형태로 파생될 수 있습니다.


// Read only atom
const priceAtom = atom(1);
const readOnlyAtom = atom((get) => get(priceAtom) * 2)

function ReadComponent() {
  const [double] = useAtom(readOnlyAtom)
  return <span>{double}</span>
}

// Write only atom
const countAtom = atom(1);
const writeOnlyAtom = const multiplyCountAtom = atom(
  null,// it's a convention to pass `null` for the first argument
  (get, set, by) => set(countAtom, get(countAtom) * by)
)

function WriteComponent() {
  const [, multiply] = useAtom(multiplyCountAtom)
  return <button onClick={() => multiply(3)}>triple</button>
}

// Read and Write atom
const countAtom = atom(1);
const readWriteAtom = atom(
  (get) => get(countAtom),
  (get, set, _arg) => set(countAtom, get(countAtom) - 1),
)

function Counter() {
  const [count, decrement] = useAtom(readWriteAtom)
  return (
    <h1>
      {count}
      <button onClick={decrement}>Decrease</button>
    </h1>)
}

// 파생된 atom은 아래와 같은 형태로 생성
const derivedAtomWithRead = atom(read)
const derivedAtomWithReadWrite = atom(read, write)
const derivedAtomWithWriteOnly = atom(null, write)
  • 예제의 readOnlyAtom과 같이 기반이 되는 atom 값이 변경될 때 반응하는 값을 읽을 수만 있는 atom 타입
  • 예제의 writeOnlyAtom과 같이 atom이 갖는 값은 없으며, 파생된 atom의 값에 쓰기가 가능한 타입
  • 예제의 readWriteAtom과 같이 기반이 되는 atom 값이 변경될 때 반응하는 값도 읽을 수 있고 쓰기도 가능한 타입

useAtom은 리액트의 useState와 비슷하게 사용 가능하고 값과 update로 구성된 Tuple로 전달됩니다. useAtom에서 사용되는 atom의 값이 더 이상 사용되지 않으면 GC 된다는 가이드가 있는데 내부에 Context에서 관리되는 Global State를 확인해보면 WeakMap으로 관리되는 것을 확인할 수 있습니다.


// atom type
type Atom<Value> = {
  toString: () => string
  debugLabel?: string
  scope?: Scope
  read: Read<Value>
}

// map으로 관리되는 atom state
type AnyAtom = Atom<unknown>
type AtomStateMap = WeakMap<AnyAtom, AtomState>

// context에서 관리되는 jotai의 global state
export type State = {
  l?: StateListener
  v: StateVersion
  a: AtomStateMap // <- atom map으로 관리되고 있음
  m: MountedMap
  p: PendingMap
}

 

아마도 jotai의 상태가 원자 단위로 확장되어서 모든 상태가 atom으로 관리가 되어야 저자의 의도대로 활용될 것으로 보이고 그 예제는 글 아래 atom in atom 파트에 추가 내용이 있습니다. 규모가 커지거나 효율적인 상태 관리를 고민하다 보면 많은 atom으로 구성이 될 수 있다 보니 메모리 관리에 신경 쓴 것 같습니다.

 

atom.onMount

atom이 provider에 처음으로 사용되는 시점을 활용하고 싶다면 onMount 프로퍼티를 사용하면 됩니다. onMount 함수의 return 값은 onUnmount 시점에 트리거 됩니다.


// mount, unmount 기본 형태
const anAtom = atom(1)
anAtom.onMount = (setAtom) => {
  console.log('atom is mounted in provider')
  setAtom(c => c + 1) // increment count on mount
  return () => { ... } // return optional onUnmount function
}

// mount를 활용한 초기값 설정 예제
const countAtom = atom(1)
const derivedAtom = atom(
  (get) => get(countAtom),
  (get, set, action) => {
    if (action.type === 'init') {
      set(countAtom, 10)
    } else if (action.type === 'inc') {
      set(countAtom, (c) => c + 1)
    }
  }
)
derivedAtom.onMount = (setAtom) => {
  setAtom({ type: 'init' })
}

 

Async

간단한 비동기 액션은 아래와 같이 사용합니다.


const urlAtom = atom('<https://json.host.com>')
const fetchUrlAtom = atom(async (get) => {
  const response = await fetch(get(urlAtom))
  return await response.json()
})

 

그리고 <Suspense />와 함께 사용이 가능합니다.


const countAtom = atom('')

const Layout = () => {
  // 아래의 atom은 fallback을 트리거링 합니다.
	const writeAtom = atom(null, async (get, set) => {
	  const response = await new Promise<string>((resolve, _reject) => {
	    setTimeout(() => {
	      resolve('some returned value')
	    }, 2000)
	  })
	  set(countAtom, 'The returned value is: ' + response)
	})

	return (<>
		<button onClick={writeAtom}>increase</button>
	</>)
}

const Component = () => {
  const [, increment] = useAtom(asyncIncrementAtom)
}
const App = () => (
  <Provider>
    <Suspense fallback="Loading...">
      <Layout />
    </Suspense>
  </Provider>
)

 

<Suspense />를 사용하지 않고 아래와 같이 Promise를 반환하지 않는 형태로 write 속성을 활용하여 fetching 상태를 직접 관리할 수도 있습니다.


const fetchResultAtom = atom({ loading: true, error: null, data: null })
const runFetchAtom = atom(
  (get) => get(fetchResultAtom),
  (_get, set, url) => {
    const fetchData = async () => {
      set(fetchResultAtom, (prev) => ({ ...prev, loading: true }))
      try {
        const response = await fetch(url)
        const data = await response.json()
        set(fetchResultAtom, { loading: false, error: null, data })
      } catch (error) {
        set(fetchResultAtom, { loading: false, error, data: null })
      }
    }
    fetchData()
  }
)
runFetchAtom.onMount = (runFetch) => {
  runFetch('<https://json.host.com>')
}

 

 

Provider

jotaiatom을 위한 Provider를 제공합니다. 내부 구현은 scope를 키로 갖는 Map으로 관리하고 있고, 별도로 선언을 해주지 않아도 됩니다. 다만 초기값을 선언해야 하거나, scope 별로 업데이트 영역을 나누고 싶다거나, 개발 중 debug 정보를 보고 싶다면 Provider를 활용하여 이점을 챙길 수 있으니 선택적으로 사용하라고 가이드가 되어있습니다.


// Provider를 한개만 활용해도 되는 케이스에선 아무런 값을 넣지 않아도 됨.
const SubTree = () => (
  <Provider>
    <Child />
  </Provider>
)

// initail value로 값을 초기화 해서 사용가능
const TestRoot = () => (
  <Provider initialValues=[[atom1, 1], [atom2, 'b']]>
    <Component />
  </Provider>
)

// scope 활용 예제
const myScope = Symbol()
const anAtom = atom('')
anAtom.scope = myScope

const SubTree2 = () => (
  <Provider scope={myScope}>
    <Child />
  </Provider>
)

 

내부 구현 코드를 보면 Provider는 scope를 키값으로 갖는 Map으로 관리되고 있습니다. scope는 문서에는 Symbol을 권장하고 있습니다. 라이브러리를 jotai를 이용하여 만드는 방법같이 scope를 분리하고 싶을 때 사용하면 좋을 것 같습니다.


// jotai/src/core/Provider.ts

// ..생략

const StoreContextMap = new Map<Scope | undefined, StoreContext>()
export const getStoreContext = (scope?: Scope) => {
  if (!StoreContextMap.has(scope)) {
    StoreContextMap.set(scope, createContext(createStore()))
  }
  return StoreContextMap.get(scope) as StoreContext
}

 

특이한 점은 Provider 없이 Context를 활용하는 것 같은 사용성을 가진 Provider-less 모드를 제공합니다. 아래와 같이 Provider가 없어도 동작하도록 설계되어 있습니다.


 const SubTree = () => (
  {/* <Provider> 없어도 됨 */}
    <Child />
  {/* </Provider> */}
)

 

 

이와 관련해서는jotai 개발에 참여하고 있는 컨트리뷰터의 글에 소개되어있습니다. 블로그의 내용은 useContextuseMutableSource의 리턴 값으로 대체하고 global state를 mutable하게 관리하면서 실제 변화 사항은 listener를 통해 구독하는 흐름으로 설명되어있습니다. jotai 전체 설계에서 중요한 부분이라 생각되고 이 설계는 useAtom까지 이어집니다. 다만 jotai에서는 Context를 잘 활용하고 있습니다.


// jotai/src/core/context.ts

const createStoreForProduction = (
  initialValues?: Iterable<readonly [Atom<unknown>, unknown]>
): StoreForProduction => {
  const state = createState(initialValues)
  const stateMutableSource = createMutableSource(state, () => state.v)
  const commitCallback = () => flushPending(state)
  const updateAtom = <Value, Update>(
    atom: WritableAtom<Value, Update>,
    update: Update
  ) => writeAtom(state, atom, update)
  return [stateMutableSource, updateAtom, commitCallback]
}

export const createStore: CreateStore =
  typeof process === 'object' && process.env.NODE_ENV !== 'production'
    ? createStoreForDevelopment
    : createStoreForProduction

export const getStoreContext = (scope?: Scope) => {
  if (!StoreContextMap.has(scope)) {
    StoreContextMap.set(scope, createContext(createStore()))
  }
  return StoreContextMap.get(scope) as StoreContext
}

// jotai/src/core/useAtom.ts

export function useAtom<Value, Update>(
  atom: Atom<Value> | WritableAtom<Value, Update>
) {
  // ..중략
  const StoreContext = getStoreContext(atom.scope)
  const [mutableSource, updateAtom, commitCallback] = useContext(StoreContext)
  const value: Value = useMutableSource(mutableSource, getAtomValue, subscribe)
	// .. 중략
}

 

 

Atom in atom

jotai의 maintainer가 렌더링 요소에서 이점을 많이 볼 수 있도록 설계한 특징을 제일 많이 반영한 가이드가 이 파트라고 생각합니다. atomatom을 포함할 수 있고 atom들의 array, object로 관리될 수 있어서 atom으로 관리되는 상태들은 상향식 접근으로 확장할 수 있습니다. 이 문서에 가이드가 잘 되어 있으니 살펴보시고, 여기서는 한 가지 예제를 소개합니다.


const countsAtom = atom([atom(1), atom(2), atom(3)])

const Counter = ({ countAtom }) => {
  const [count, setCount] = useAtom(countAtom)
  return (
    <div>
      {count} <button onClick={() => setCount((c) => c + 1)}>+1</button>
    </div>
  )
}

const Parent = () => {
  const [counts, setCounts] = useAtom(countsAtom)
  const addNewCount = () => {
    const newAtom = atom(0)
    setCounts((prev) => [...prev, newAtom])
  }
  return (
    <div>
      {counts.map((countAtom) => (
        <Counter countAtom={countAtom} key={countAtom} />
      ))}
      <button onClick={addNewCount}>Add</button>
    </div>
  )
}

 

이 예제는 여러 개의 Counter 중 업데이트가 발생하는 Counter만 re-rendering 되게 하고 싶은 케이스에서 이점을 볼 수 있습니다.

 

 

 

 

Appendix

 

Utils

jotai를 import 하여 사용하는 경우 외에 추가적인 기능이 필요한 경우 jotai/utils에 확장된 헬퍼들이 포함되어 있습니다. 이 문서를 통해서 확인 가능하고, 재미있는 것은 util이 생성된 이유들을 히스토리처럼 PR 링크로 대부분 달아놓았습니다. 또한 recoil에 영감을 받아 작성해서인지 recoil에 포함되어 있는 기능 중에 jotai를 확장하여 구현한 듯한 PR들이 많이 보였습니다. 대표적으로 atomFamily PR 이 인상적이었습니다. Maintainer가 recoil에 있는 기능들의 필요성을 이해하고 jotai에 녹여내는 과정 또한 인상적이었습니다.

 

그리고 다른 유틸들의 PR을 읽어보는 것도 jotai를 이해하는 데 도움이 많이 되었습니다.

 

Large object – focus or split

대용량 데이터를 관리할 때 단일 원자로 저장하고 이를 나눠서 관리하는 레시피도 인상적이었습니다. 대용량 데이터의 특정 부분만 업데이트를 하면서 렌더링 최적화를 하는 등의 응용이 가능해 보입니다. optics라는 외부 라이브러리를 integration 해서 사용하는 방법이 아래와 같이 있습니다.


import { atom } from 'jotai'
import { focusAtom } from 'jotai/optics'

const initialData = {
  people: [
    {
      name: 'Luke Skywalker',
      information: { height: 172 },
      siblings: ['John Skywalker', 'Doe Skywalker'],
    },
    {
      name: 'C-3PO',
      information: { height: 167 },
      siblings: ['John Doe', 'Doe John'],
    },
  ],
  films: [
    {
      title: 'A New Hope',
      planets: ['Tatooine', 'Alderaan'],
    },
    {
      title: 'The Empire Strikes Back',
      planets: ['Hoth'],
    },
  ],
  info: {
    tags: ['People', 'Films', 'Planets', 'Titles'],
  },
}

const dataAtom = atom(initialData)
const peopleAtom = focusAtom(dataAtom, (optic) => optic.prop('people'))

 

array를 config 값으로 사용하는 atom을 split 해서 사용할 수도 있습니다. 마찬가지로 변화된 상태가 있는 컴포넌트만 렌더링 하도록 사용합니다.


const initialState = [
  {
    task: 'help the town',
    done: false,
  },
  {
    task: 'feed the dragon',
    done: false,
  },
]

const todosAtom = atom(initialState)
const todoAtomsAtom = splitAtom(todosAtom)

const TodoList = () => {
  const [todoAtoms, removeTodoAtom] = useAtom(todoAtomsAtom)
  return (
    <ul>
      {todoAtoms.map((todoAtom) => (
        <TodoItem todoAtom={todoAtom} remove={() => removeTodoAtom(todoAtom)} />
      ))}
    </ul>
  )
}

 

atomFailmy 그리고 splitAtom과 유사하게 atom을 배열로 관리하는 TODO 예제에서 같은 UI를 다르게 구현하는 방법으로 비교해볼 수 있도록 공식 문서에서 제공합니다.

 

 

Integration

jotai는 다른 상태 관리 라이브러리들과의 Integration을 공식적으로 제공합니다. 모든 상태 관리의 기본 사용성을 유지한 채로 다른 라이브러리와 연결하여 쓰는 이 부분이 정말 큰 강점이라고 생각합니다. 아래는 react-query와 integration 한 예제입니다.


import { useAtom } from 'jotai'
import { atomWithQuery } from 'jotai/query'

const idAtom = atom(1)
const userAtom = atomWithQuery((get) => ({
  queryKey: ['users', get(idAtom)],
  queryFn: async ({ queryKey: [, id] }) => {
    const res = await fetch(`https://jsonplaceholder.typicode.com/users/${id}`)
    return res.json()
  },
}))

const UserData = () => {
  const [data] = useAtom(userAtom)
  return <div>{JSON.stringify(data)}</div>
}

 

공식적으로 아래의 라이브러리 들을 지원합니다.

  • immer
  • optics
  • react-query
  • xstate
  • valtio
  • zustand
  • redux

 

 

 

 

마무리

 

jotai라는 상태 관리 라이브러리를 찾아보면서 이 오픈소스는 왜 만들어졌는지, 무엇을 신경 썼는지를 여행 다녀온 느낌으로 즐겁게 찾아보았습니다. 상태 관리는 요즘 많이 관심을 갖고 있던 주제였는데 본격적으로 찾아보면서 다시 한번 미진하게 공부했던 자신을 발견하며 반성했고 이 글도 즐겁게 작성했습니다. 덤으로 jotai maintainer의 팬이 되어버렸습니다.

 

react-query, Redux, XState 등과 같이 확장하여 사용 가능해서 큰 규모의 프로젝트에서도 충분히 활용할 수 있다는 생각이 들었습니다. 첫 메이저 버전 release가 지난 6월 15일 배포되었는데, 앞으로 관심 있게 지켜볼 예정이고 후속 포스트가 jotai를 사용해본 경험이나 다른 상태 관리 라이브러리의 비교 글이었으면 좋겠다는 생각을 하며 마무리합니다.

 

 

 


 

제훈님과 함께한 개발팀 프론트엔드 플랫폼의 인터뷰도 확인해보세요!

 

 

 

채용정보 확인하기

 

 

18
by nc nd