FC-InnerCircle / fe1-library

Inner Circle FE 1기 오픈소스 라이브러리
0 stars 0 forks source link

[이향기] 상태 관리 라이브러리 with React #16

Open 2scent opened 1 month ago

2scent commented 1 month ago

프로젝트 이름

tiny-sangtae

개요

nanostores, zustand, jotai 등을 참고해서 리액트에서 사용 가능한 상태 관리 라이브러리를 만들려고 합니다.

1주 차에는 리액트에 의존성 없는 tiny-sangtae/core를, 2주 차에는 리액트에서 쉽게 사용할 수 있게 지원하는 tiny-sangtae/react를 만들 계획입니다.

저장소 주소

https://github.com/2scent/tiny-sangtae https://github.com/2scent/tiny-sangtae-react

npm 주소

https://www.npmjs.com/package/tiny-sangtae https://www.npmjs.com/package/@tiny-sangtae/react

체크리스트

tiny-sangtae

@tiny-sangtae/react

2scent commented 1 month ago

리더님 안녕하세요!

말씀해 주신 nanostores를 비롯한 여러 라이브러리랑 관련 책들을 보면서 리액트에 의존성이 없는 core 부분만 우선 가장 간단한 방식으로 구현해 봤는데요.

get, set, subscribe 함수들을 갖는 너무 무난한 형태라서 이대로 가다간 리액트도 그냥 useSyncExternalStore 적용해서 연결하는 게 끝일 거 같은데요. 그러기에는 너무 아쉬워서 좀 더 특징이라 할만한 것을 추가하고 싶은데 중간 피드백을 주실 수 있으실까요?

taggon commented 1 month ago

추가하면 좋을만한 기능으로는...

이 정도가 떠오르는데 혹시 고려해보셨을까요?

2scent commented 1 month ago

파생 상태는 react 지원 기능 만들 때 구현하려고 생각 중이었습니다. setter에 async 함수 전달하는 건 생각 못 해봤는데 고려해보겠습니다. persistence나 fetch 기능 같은 것도 재밌어 보이네요.

피드백 감사합니다!

2scent commented 1 month ago

1주 차(8/3) 피드백

문서

구현

테스트

그 외

2scent commented 1 month ago

2주 차 목표

2scent commented 1 month ago

리더님, 안녕하세요! 지난 토요일에 피드백 주신 내용들 반영하고, react 쪽도 대략적으로 구현돼서 중간 피드백 요청드립니다.

중점적으로 피드백 받고 싶은 내용

tiny-sangtae

https://github.com/2scent/tiny-sangtae

@tiny-sangtae/react

https://github.com/2scent/tiny-sangtae-react

const $counter = sangtae(0);

function Counter() {
  const [counter, setCounter] = useSangtae($counter);
  const counterAdded5 = useSangtaeSelector($counter, (v) => v + 5);

  return (
    <div>
      <button type="button" onClick={(prev) => setCounter(prev + 1)}>{counter}</button>
      <p>{counter} + 5 = {counterAdded5}</p>
    </div>
  );
}

위와 같이 사용할 수 있도록 useSangtaeuseSangtaeSelector 두 가지 hook을 구현했는데요.

추가로, 예제 프로젝트를 어느 정도 수준에서 제공해야 하는지 궁금합니다.

그 외에도 여러 피드백 주시면 감사하겠습니다 😊

2scent commented 1 month ago

batch에 대해서 다시 생각해보니깐

란 고민을 못 했던 것 같네요.

이 부분은 좀 더 고민해보겠습니다.

taggon commented 1 month ago

상태 변경이 연속적으로 일어날 경우, batch를 적용하는 게 좋을 것 같다는 피드백에 대해 브라우저 환경에서는 requestAnimationFrame, 그 외 환경에서는 setTimeout을 이용하여 구현해봤는데요. 적절히 구현했는지 피드백 부탁드립니다.

process.nextTick이라는 것도 있습니다(참고).

2scent commented 1 month ago

상세한 피드백 감사합니다 😊

피드백 주신 내용 중에 코어 부분만 우선 반영해봤습니다.

batch 로직을 어떻게 가져갈까 하다가 조금 큰 변화를 줬는데요.

는 이유로 MobX에서 영감을 받아 action이란 함수를 만들어 봤습니다. 이로 인해 동작 방식이 다시 바뀌었는데요.

action 사용 X

const s = sangtae('Lee');
const callback = vi.fn();

s.subscribe(callback);
s.set('Kim');
s.set('Park');
s.set('Jung');
s.set('Choi');
s.set('Kang');

expect(callback).toBeCalledTimes(5);

action 없이 set 함수를 연속적으로 호출할 경우에는 batch 로직 도입 이전과 같이 set 함수를 호출한 만큼 등록한 콜백 함수가 호출됩니다.

action 사용 O

const s = sangtae('Lee');
const callback = vi.fn();

s.subscribe(callback);
action(() => {
  s.set('Kim');
  s.set('Park');
  s.set('Jung');
  s.set('Choi');
  s.set('Kang');
});

expect(callback).toHaveBeenCalledOnce();

action으로 set 함수들을 감쌀 경우, 마지막에 set 함수에 대해서만 콜백 함수가 호출됩니다.

이렇게 놓고 보니, 실제로 마지막 set 함수에 대해서만 콜백 함수가 호출되는지 확인하고, 사용성도 향상하기 위해서 callback 함수에 현재 상태를 전해주는 것도 필요하다는 생각이 드네요.

결론은 이렇게 하면 사용하는 입장에서 동작 예측이 더 쉬워지는 효과가 있다고 생각하는데 리더님 의견이 궁금합니다!

2scent commented 1 month ago

sangtae에 전달된 값은 sangtae 외부에서도 수정이 가능한 걸까요? subscribe 이벤트가 발생하는 것과는 별개로 수정이 되는 것처럼 보여서 문의드립니다.

외부에서도 수정이 가능하다는 게

const arr = ['one', 'two', 'three'];
const s = sangtae(arr);

arr.push('four');

// 또는
s.get().push('four');

이런 경우를 말씀하시는 게 맞을까요? 사용자가 상태는 불변값으로 관리한다는 가정 하에 만든 거라 이에 대한 고려는 안 돼 있는데 프리징 같은 것을 염두에 두고 하신 말씀이실까요?

taggon commented 1 month ago

말씀하신 경우를 염두에 둔 게 맞습니다. 프리징을 고려한 건 아닌데, 의도적으로 허용하는 부분인 것인지 궁금했었어요 🤔

taggon commented 1 month ago

action을 활용한다는 아이디어는 좋습니다. 다만 피드백 세션에서 말씀드렸듯이 비동기 액션도 고민을 해보시면 좋을 것 같습니다.

2scent commented 3 weeks ago

리더님 안녕하세요!

아마 오픈소스 프로젝트 마지막 리뷰 요청이 될 것 같네요.

이번에도 지난 리뷰 이후 꽤 변화가 있었는데요.

tiny-sangtae

action

고민 끝에 action에서 비동기는 지원하지 않기로 했습니다.

$counter.subscribe(() => console.log(`$counter: ${$counter.get()}`));
action(async () => {
  $counter.set(1);
  $counter.set(2);

  await new Promise(resolve => setTimeout(resolve, 1000));

  $counter.set(3);
  $counter.set(4);
});

이 코드를 실행할 경우, $counter: 2가 먼저 출력되고, 1초 후에 $counter: 3, $counter: 4가 연이어 출력됩니다. 저번에 예시를 들어주신 것처럼 비동기 작업으로 인해 action 외부에서 set이 호출된다면, 역시 해당 비동기 작업 후에 set을 호출한 만큼 콜백이 호출됩니다.

이렇게 결정한 가장 큰 이유는 가능한 단순한 API를 제공하고 싶었습니다. 비동기 작업에 대한 고민을 하면 할수록 제 생각도 복잡해지고, API도 복잡해지는 것 같더라고요. 그래서 그냥 API는 단순하게 유지하고, action 안에서 비동기 작업은 호출하지 않는 것을 권장하는 방식으로 푸는 게 좋다고 결정했습니다.

computed

여러 상태에 대한 파생 상태를 만들 수 있도록 개선했습니다.

const $lastName = sangtae('Lee');
const $firstName = sangtae('Hyanggi');
const $fullName = computed([$lastName, $firstName], (ln, fn) => `${ln} ${fn}`);
console.log($fullName.get()); // Lee Hyanggi

이 기능을 구현하면서 고민됐던 게 크게 두 가지가 있었는데요.

우선 단일 상태를 지원하는 computed와 여러 상태를 지원하는 computedMulti(?)로 나눌까 고민하다가 하나로 통일하는 게 사용성 측면에서 좋을 거라 생각해 computed에서 둘다 지원하기로 했습니다.

두 번째는 내부 구현을 위한 API 노출에 관한 건데요.

const s1 = sangtae('Lee');
const s2 = sangtae(123);
const s3 = sangtae(true);
const c = computed([s1, s2, s3], (v1, v2, v3) => `${v1} ${v2} ${v3}`);
const callback = vi.fn();

c.subscribe(callback);
action(() => {
  s1.set('Kim');
  s2.set(456);
  s3.set(false);
});

이 코드를 실행할 경우, 마지막 s3.set(false)에서만 콜백이 호출되게 하기 위해 subscribe 함수에서 key를 받도록 했습니다.

subscribe: (callback: SubscribeCallback<State>, key?: unknown) => () => void

내부의 기능 구현을 위해 추가한 거라 action에서 비동기 작업을 비권장하는 것처럼 key를 직접 사용하는 것은 비권장하는 방식으로 풀려고 하는데 괜찮은 방법인지 궁금합니다. key를 노출시키지 않는 건 당장 방법이 떠오르지는 않는데 추후 개선 사항으로 생각하고 있습니다.

@tiny-sangtae/react

이것도 고민 끝에 API는 useSangtae hook 하나만 제공하기로 했습니다. 세터나 파생 상태를 리액트 차원에서 제공하려니 sangtae.set, computed와 기능이 겹친다는 게 그 이유인데요.

// counter.ts
import { sangtae, computed } from 'tiny-sangtae';

export const $counter = sangtae(0);

export const $counterAdded10 = computed($counter, v => v + 10);

export const increase = () => $counter.set(v => v + 1);
export const decrease = () => $counter.set(v => v - 1);

// Counter.tsx
import { useSangtae } from '@tiny-sangtae/react';
import { $counter, $counterAdded10, increase, decrease } from './counter';

export default function Counter() {
  const counter = useSangtae($counter);
  const counterAdded10 = useSangtae($counterAdded10);

  return (
    <div>
      <h1>{counter} + 10 = {counterAdded10}</h1>
      <button type="button" onClick={increase}>+</button>
      <button type="button" onClick={decrease}>-</button>
    </div>
  );
}

sangtae.setcomputed를 이용해서 함수와 파생 상태를 만들고, 컴포넌트에서는 이를 사용하는 것을 의도했는데 괜찮은 방향일까요? (nanostores도 참고했습니다)

감사합니다 😊

taggon commented 3 weeks ago

충분한 고민을 하고 결정했다면 어느 방향이든 괜찮다고 생각합니다. 기존 라이브러리에서 검증된 방식으로 해봐야 똑같은 걸 하나 더 만드는 꼴만 되는 것이니, 자신이 작성한 라이브러리에 자신의 색을 드러내는 것도 좋습니다. 아주 명백한 문제만 없다면요. 그리고 여기서는 명백한 문제가 보이지 않습니다. :)

subscribe에 key를 전달하는 것까지는 괜찮아 보이는데, key의 타입인 unknownany 대신 사용한 듯한 인상이 있습니다. Map의 키로 사용되는 것이니만큼 타입을 조금 더 구체적으로 정해두면 좋을 것 같습니다.

React 버전에서 useSangtae가 값을 읽는 역할만 하는 것도 충분히 납득할만한 범위에 있습니다. 위에서 말한 것처럼 고유의 색을 입혀가는 것도 좋습니다. 사용법이 특별히 더 번거로워지는 것도 아니고요. 👍

2scent commented 3 weeks ago

감사합니다 😊