Open 2scent opened 1 month ago
리더님 안녕하세요!
말씀해 주신 nanostores
를 비롯한 여러 라이브러리랑 관련 책들을 보면서 리액트에 의존성이 없는 core
부분만 우선 가장 간단한 방식으로 구현해 봤는데요.
get
, set
, subscribe
함수들을 갖는 너무 무난한 형태라서 이대로 가다간 리액트도 그냥 useSyncExternalStore
적용해서 연결하는 게 끝일 거 같은데요. 그러기에는 너무 아쉬워서 좀 더 특징이라 할만한 것을 추가하고 싶은데 중간 피드백을 주실 수 있으실까요?
파생 상태는 react 지원 기능 만들 때 구현하려고 생각 중이었습니다. setter에 async 함수 전달하는 건 생각 못 해봤는데 고려해보겠습니다. persistence나 fetch 기능 같은 것도 재밌어 보이네요.
피드백 감사합니다!
문서
구현
테스트
그 외
리더님, 안녕하세요! 지난 토요일에 피드백 주신 내용들 반영하고, react 쪽도 대략적으로 구현돼서 중간 피드백 요청드립니다.
https://github.com/2scent/tiny-sangtae
requestAnimationFrame
, 그 외 환경에서는 setTimeout
을 이용하여 구현해봤는데요. 적절히 구현했는지 피드백 부탁드립니다.기존 배열을 직접 수정해서 set을 호출한 경우, 콜백 함수를 호출하지 않는다.
, 기존 객체를 직접 수정해서 set을 호출한 경우, 콜백 함수를 호출하지 않는다.
두 가지 테스트 케이스를 추가했는데 의도가 잘 표현됐는지 궁금합니다.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>
);
}
위와 같이 사용할 수 있도록 useSangtae
와 useSangtaeSelector
두 가지 hook을 구현했는데요.
추가로, 예제 프로젝트를 어느 정도 수준에서 제공해야 하는지 궁금합니다.
그 외에도 여러 피드백 주시면 감사하겠습니다 😊
batch에 대해서 다시 생각해보니깐
란 고민을 못 했던 것 같네요.
이 부분은 좀 더 고민해보겠습니다.
상태 변경이 연속적으로 일어날 경우, batch를 적용하는 게 좋을 것 같다는 피드백에 대해 브라우저 환경에서는 requestAnimationFrame, 그 외 환경에서는 setTimeout을 이용하여 구현해봤는데요. 적절히 구현했는지 피드백 부탁드립니다.
process.nextTick
이라는 것도 있습니다(참고).
selector로 () => 10이 주어지면, 상태에 상관 없이 10을 리턴한다.
이 테스트에서는 상태의 값이 변경되었을 때도 동일한 상수를 반환하는지 확인하는 것도 필요하지 않을까요?sangtae
에 전달된 값은 sangtae
외부에서도 수정이 가능한 걸까요? subscribe 이벤트가 발생하는 것과는 별개로 수정이 되는 것처럼 보여서 문의드립니다.초기값으로 배열이 주어지면, 동일한 배열을 리턴한다.
정도로 작성하셔도 됩니다.리턴한 함수 "unsubscribe"를 호출하면 더 이상 콜백을 호출하지 않는다.
테스트의 경우 unsubscribe의 동작을 제대로 테스트하려면 unsubscribe를 호출하는 라인의 존재 여부만 다른 테스트 두 개를 작성하시는 게 좋겠습니다. 예를 들어, 다음과 같이 작성할 수 있습니다.
it('set() 실행 사이에 충분한 시간을 두면 set 할 때마다 콜백을 호출한다.', async () => {
const s = sangtae('start');
const callback = vi.fn();
s.subscribe(callback);
s.set('first');
await sleep(100);
s.set('second');
await sleep(100);
expect(callback).toHaveBeenCalledTimes(2);
expect(callback).toHaveBeenNthCalledWith(1, 'first');
expect(callback).toHaveBeenNthCalledWith(2, 'second');
});
it('리턴한 함수 "unsubscribe"를 호출하면 더 이상 콜백을 호출하지 않는다.', async () => {
const s = sangtae('start');
const callback = vi.fn();
const unsubscribe = s.subscribe(callback);
s.set('first');
await sleep(100);
unsubscribe();
s.set('second');
await sleep(100);
expect(callback).toHaveBeenCalledOnce();
expect(callback, '첫 번째 set에서만 호출됨').toHaveBeenCalledWith('first');
});
useSangtaeSelector
를 변경한다면 useEffect처럼 배열로 deps를 설정하도록 만들 수도 있겠습니다.상세한 피드백 감사합니다 😊
피드백 주신 내용 중에 코어 부분만 우선 반영해봤습니다.
batch 로직을 어떻게 가져갈까 하다가 조금 큰 변화를 줬는데요.
는 이유로 MobX에서 영감을 받아 action
이란 함수를 만들어 봤습니다. 이로 인해 동작 방식이 다시 바뀌었는데요.
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
함수를 호출한 만큼 등록한 콜백 함수가 호출됩니다.
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
함수에 현재 상태를 전해주는 것도 필요하다는 생각이 드네요.
결론은 이렇게 하면 사용하는 입장에서 동작 예측이 더 쉬워지는 효과가 있다고 생각하는데 리더님 의견이 궁금합니다!
sangtae에 전달된 값은 sangtae 외부에서도 수정이 가능한 걸까요? subscribe 이벤트가 발생하는 것과는 별개로 수정이 되는 것처럼 보여서 문의드립니다.
외부에서도 수정이 가능하다는 게
const arr = ['one', 'two', 'three'];
const s = sangtae(arr);
arr.push('four');
// 또는
s.get().push('four');
이런 경우를 말씀하시는 게 맞을까요? 사용자가 상태는 불변값으로 관리한다는 가정 하에 만든 거라 이에 대한 고려는 안 돼 있는데 프리징 같은 것을 염두에 두고 하신 말씀이실까요?
말씀하신 경우를 염두에 둔 게 맞습니다. 프리징을 고려한 건 아닌데, 의도적으로 허용하는 부분인 것인지 궁금했었어요 🤔
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
안에서 비동기 작업은 호출하지 않는 것을 권장하는 방식으로 푸는 게 좋다고 결정했습니다.
여러 상태에 대한 파생 상태를 만들 수 있도록 개선했습니다.
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
를 노출시키지 않는 건 당장 방법이 떠오르지는 않는데 추후 개선 사항으로 생각하고 있습니다.
이것도 고민 끝에 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.set
과 computed
를 이용해서 함수와 파생 상태를 만들고, 컴포넌트에서는 이를 사용하는 것을 의도했는데 괜찮은 방향일까요? (nanostores도 참고했습니다)
감사합니다 😊
충분한 고민을 하고 결정했다면 어느 방향이든 괜찮다고 생각합니다. 기존 라이브러리에서 검증된 방식으로 해봐야 똑같은 걸 하나 더 만드는 꼴만 되는 것이니, 자신이 작성한 라이브러리에 자신의 색을 드러내는 것도 좋습니다. 아주 명백한 문제만 없다면요. 그리고 여기서는 명백한 문제가 보이지 않습니다. :)
subscribe에 key를 전달하는 것까지는 괜찮아 보이는데, key의 타입인 unknown
을 any
대신 사용한 듯한 인상이 있습니다. Map의 키로 사용되는 것이니만큼 타입을 조금 더 구체적으로 정해두면 좋을 것 같습니다.
React 버전에서 useSangtae
가 값을 읽는 역할만 하는 것도 충분히 납득할만한 범위에 있습니다. 위에서 말한 것처럼 고유의 색을 입혀가는 것도 좋습니다. 사용법이 특별히 더 번거로워지는 것도 아니고요. 👍
감사합니다 😊
프로젝트 이름
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