[react] 리액트 훅 MVVM #3

개요

1부에서는 모델-렌더의 기초 개념을
2부에서는 state를 기반으로 하는 뷰모델과 커맨드의 구조를 만들어봤습니다.

이론적이고 단편적인 예제에도 슬슬 지칠 때입니다. 이번 시간에는 과감하게 여태까지 정리한 사실을 바탕으로 MVVM을 이용해 간단한 To-do앱을 만들어보겠습니다(사실 To-do앱말고 가볍게 만들어 볼 예제가 있으면 좋을텐데 마땅한 게 없어서 다들 이걸 하나봅니다 ^^)

의외로 이 단순한 앱에서 배울게 많아 분량이 꽤나 되므로 3, 4부에 걸쳐 만들도록 하겠습니다.

고작 to-do만드는데 2편이나 필요한가요?
리액트 쓰는 법은 이거라고 맘 속에 정한 게 있어서 고작이라 생각하는 거라능!

기본적인 앱의 모델 설계

To-do앱은 할 일 생성하고 삭제하거나 완료표시를 하는 앱이죠. 이 글에서는 이러한 할 일을 하나의 아이템으로 보고 종류별로 분류해 담을 수 있게 카테고리 기능을 포함하려 합니다. 우선 카테고리를 정하고 그 카테고리에 할 일을 만들어가는 방식이죠.
전체적으로 완성하고 나면 다음과 같은 모양이 될 것입니다.

왼쪽에서 카테고리가 관리되고, 이를 선택하면 오른쪽에 그 카테고리의 여러 할 일이 관리되는 식인거죠. 이 간단한 구조에서 모델을 어떻게 만들 지 생각해보죠. 모델-렌더에서는 항상 모델을 잘 정하는 것만으로도 많은 문제가 해결됩니다(렌더는 그저 이쁜 모델 뷰어일 뿐이죠)
사실 flux아키텍쳐는 나름 좋은 점도 많지만, 이러한 모델 분석 시 중앙의 싱글톤 형태로 모델을 만들도록 유도합니다. 아마 많은 분들이 떠올리신 모델은 다음과 같은 모양에 가까울 것입니다.

const model = {
  "공부":[아이템들...],
  "업무":[아이템들...],
  "운동":[아이템들...]
};

이 모델은 일견 문제 없어보이지만, 각 카테고리별로 특수한 모델을 구성하거나 수정할 때 전체 모델을 건들게 되어, 전체 앱에 대한 회귀적인 문제를 발생시키게 됩니다. 안전한 모델은 관심사에 대해 완전히 다른 엔티티를 갖는 것입니다.

const category = ["공부", "업무", "운동"...];
const todo1 = {category:"공부", todo:[할일들...]};
const todo2 = {category:"업무", todo:[할일들...]};
...

위 구조에서 category는 오직 카테고리만 관리하고, 각 카테고리에 맞는 할 일 목록은 별도의 모델 인스턴스로 관리하는 것이죠. 이러면 todo1의 확장이나 수정은 다른 할 일 목록에 영향을 주지 않아 수정 등의 여파를 최소화할 수 있습니다.

헌데 좀 더 생각해보죠 ^^; 왼쪽에 카테고리를 선택하면, 이 여파는 오른쪽에 할 일 리스트를 교체하는 형태로 일어납니다. 즉 둘은 별도의 모델이지만 category에서 특정 항목을 선택했다는 행위가 어떤 todo를 사용할지 결정하게 되므로 이를 모델에 반영해야 합니다.

const category = {
  current:"",
  list:["공부", "업무", "운동"...]
};
const todo1 = {category:"공부", todo:[할일들...]};
const todo2 = {category:"업무", todo:[할일들...]};
...

위 내용을 반영한 모델에서는 현재 선택된 카테고리가 무엇인지를 나타내는 current가 추가되고 이 current가 바뀌는 것에 맞춰 어떤 todo를 고를 지 결정할 수 있게 될 것입니다. 선택은 행위지만 결과는 상태이므로 모델에 반영되어야 합니다.

그럼 flux는 나쁜건가요?
flux구조도 인스턴스마다 별도의 모델을 만들 수는 있고, 사실 대부분의 컴포넌트는 싱글톤이라능!

공유상태의 문제와 ContextAPI

이제 특정 모델의 상태가 변경되면 그 여파가 다른 모델(혹은 그걸 소유한 컴포넌트)에게 전파되어야 한다는 사실은 인지했습니다.
category의 current가 바뀌면 할 일 목록쪽을 그리는 오른편의 컴포넌트는 거기에 맞춰 다시 그려져야하죠.

이걸 더 깊이 생각해보면 좌측 카테고리 모델은 그 변화가 일어날 때마다 왼쪽의 카테고리 목록 뿐만 아니라 오른쪽의 할 일 목록에 동시에 영향을 끼치는 모델이라는 걸 알 수 있습니다.

이렇듯 직접적인 컴포넌트 간의 부모자식 관계가 아닌대도 여러 계층의 컴포넌트에 동시에 영향을 주고 컴포넌트 갱신을 일으키는 상태를 처리하기 위해 리액트는 state외에도 context라는 것을 준비했습니다.

본 포스팅은 리액트의 기본 기능에 대한 설명을 하는 글이 아니므로 자세한 context의 사용법은 공식 문서를 참조하시길 바랍니다…라고 하기엔 내용이 전개하기 힘들어 context건은 약간 더 설명하겠습니다 ^^;

보통 자신의 state가 변경될 때 일어나는 여파는 자기 자신의 컴포넌트가 재호출되는 것과 자기의 자식 컴포넌트가 재호출되는 것입니다.
하지만 위에 보여드렸던 앱의 모양을 보면 카테고리를 담당하는 좌측과 할일 목록을 담당하는 우측은 형제 관계지 부모 자식 관계가 아닙니다.
따라서 부모자식과 무관하게 하나의 state변경이 다른 컴포넌트에게 영향을 끼칠 방법이 필요하고 그래서 props 대신 context를 쓰게 되는거죠.

context의 원리는 다음과 같습니다.

  1. 전역에서 import 가능한 컨텍스트 객체를 생성한다.
  2. 이 영향권에 들어갈 컴포넌트의 범위를 Provider로 설정한다.
  3. 실제 컨텍스트 객체에 들어갈 값은 특정 컴포넌트의 state로 설정한다.
  4. 이제 그 컴포넌트의 state를 갱신하면 컨텍스트 영향권에 묶인 모든 컴포넌트에 변화가 전파된다.

여기서 context객체 자체는 어디서나 접근가능한 인스턴스로 선언되야 하지만 실제 그 context안에 담겨있는 값은 Provider의 value에서 설정되는 것을 알 수 있습니다.

  1. 이 value값을 특정 컴포넌트의 state로 설정하게 되면
  2. state가 갱신될 때마다 context의 value가 갱신되고,
  3. 그 영향권에 있는 모든 자식 컴포넌트는 재호출대상에 포함되므로,
  4. 재호출된 자식컴포넌트에서 context객체의 값을 얻게 되면 새로운 state값을 참조할 수 있게 되는 식입니다.

간단한 context의 사용법을 보고 이 흐름을 느껴보죠.

export const CategoryContext = React.createContext();

우선 전역에서 import할 수 있는 context의 인스턴스를 하나 생성합니다. 핵심은 이렇게 만들어진 인스턴스는 어떤 컴포넌트에서도 불러들일 수 있는 객체라는 사실입니다. 이것에 영향을 받는 Provider의 설정은 컴포넌트에서 이뤄집니다.

export const App =()=>{
  const [v] = useState(...);
  return (<>
    <h1>Todos</h1>
    <CategoryContext.Provider value={v}>
       <CategoryView />
       <TodoView/>
    </CategoryContext.Provider>
  </>);
};

위 예에서 CategoryContext의 Provider를 설정합니다. 이 범위 안에 있는 CategoryView와 TodoView가 이 context의 변화에 영향을 받는 컴포넌트가 됩니다.
실제 context에 들어갈 값은 value에 지정된 값이 되며, 이 값이 변경될 때마다 context를 수신하고 있는 영향권 내의 컴포넌트를 재호출됩니다.

근데 왜 value에 App의 state를 넣었냐는 거죠.

  1. value를 변경할수는 있지만 App컴포넌트가 재호출되지는 않습니다.
  2. App컴포넌트를 재호출할 수 있는 방법은 App의 state에 새 값을 넣는 것뿐입니다.
  3. 따라서 value가 직간접적으로 state의 업데이트로 인해 영향을 받도록 설계하지 않으면,
  4. App이 재호출되지 않아 context의 value에 들어갈 값이 다른 곳에서 변경되어도 전파될 수는 없습니다.

다음과 같이 App의 state에 전혀 영향을 받지 않는 context를 만들어보죠.

export const category = {
  current:"",
  list:["공부", "업무", "운동"]
};
export const App =()=>{
  return (<>
    <h1>Todos</h1>
    <CategoryContext.Provider value={category}>
       <CategoryView />
       <TodoView/>
    </CategoryContext.Provider>
  </>);
};

category 자체는 외부에서 import할 수 있습니다.
만약 category.list에 새로운 값을 추가했다고 해면 객체의 상태가 변했을 뿐 App컴포넌트가 재호출되는 것은 아닙니다.
따라서 App이 호출만 되었다면 context의 value가 변했겠지만, App자체가 호출된 적이 없어 이 변화가 전파될 일 자체가 없습니다.

따라서 다음과 같이 state를 사용하도록 변경해야 합니다.

export const category = {
  current:"",
  list:["공부", "업무", "운동"]
};
export const App =()=>{
    const [cat, setCat] = useState(category);
    cat.setCat = setCat;
    return (<>
        <h1>Todos</h1>
        <CategoryContext.Provider value={cat}>
            <CategoryView />
            <TodoView/>
        </CategoryContext.Provider>
    </>);
};

이렇게 되면 context를 수신하는 쪽에서는 category객체와 그 객체에 내장된 setCat을 이용해 App의 state를 업데이트할 수 있고 결과 App이 재호출되면서 context의 value가 갱신되어 변화를 반영할 수 있게 되는거죠.

이건 공식문서를 비롯한 거의 모든 리액트관련 정보에서 ContextAPI를 사용하는 방법으로 나오는 구조입니다. 근데 이상하지 않나요?

뷰컴포넌트의 인스턴스마다 Context를 만들 수도 있나요?
만드는 건 자유지만 그걸 가져다가 쓰는 쪽이 어떻게 알게 하느냐가 문제라능!

state를 이용한 공유상태처리

뭐가 이상하냐면 짜피 App의 state를 재설정하면 context는 상관없이 App의 자손컴포넌트는 전부 재호출된다는 것입니다.
딱히 App의 state를 참조할 방법만 있으면 번거로운 context자체를 쓸 이유는 1개도 없다는 거죠.
이를 코드로 표현하면 다음과 같습니다.

export const category = {
  current:"",
  list:["공부", "업무", "운동"]
};
export const App =()=>{
    const [cat, setCat] = useState(category);
    cat.setCat = setCat;
    return (<>
        <h1>Todos</h1>
        <CategoryView />
        <TodoView/>
    </>);
};

context를 제거했습니다. 그럼 어떻게 CategoryView와 TodoView에서는 App의 state를 얻을 수 있을까요?
간단합니다. js는 싱글쓰레드이기 때문에 절대로 멀티쓰레드 경쟁이 일어나지 않습니다. 따라서 다음과 같이 처리하면 됩니다.

export const category = {
  instance:{
    current:"",
    list:["공부", "업무", "운동"]
  }
};
export const App =()=>{
  const [cat, setCat] = useState(category.instance);
  cat.setCat = setCat;
  category.instance = cat; //여기!
  return (<>
    <h1>Todos</h1>
    <CategoryView />
    <TodoView/>
  </>);
};

이제 category의 instance는 항상 현재 state를 갖게 됩니다. 따라서 App의 state를 가져가 쓰고 싶은 컴포넌트는 그저 category를 import해서 그 안에 instance를 얻기만 해도 충분합니다.

물론 context는 복잡한 캐스캐이딩 정책을 비롯한 몇몇 기능이 제공됩니다. 하지만 루트 클래스에서 컴포넌트 상의 범위를 지정하는 구조인 이상 state의 변화로 자식 컴포넌트가 호출되는 것과 큰 차이는 없습니다.

따라서 이 포스팅에서 만들게 되는 예제에서는 contextAPI를 사용하지 않고 이 방법의 공유객체를 사용해 구현해볼 예정입니다.

첫 포스팅 때부터, 말씀드렸지만 리액트는 결국 가벼운 모델-렌더러로 state의 변화에 반응하여 컴포넌트를 재호출하고 그 여파로 호출된 컴포넌트의 자식 컴포넌트를 연쇄로 호출해 반환된 뷰의 차이점을 분석해 업데이트해주는 라이브러리입니다. 모든 건 state와 그 변화를 일으켜 컴포넌트를 재호출하는 구조인거죠.

그럼 Context는 쓸모 없는 건가요?
‘계층구조와 무관한 상태공유’ 측면보다는 다른 특징들이 더 의미있다능!

Category를 통해 싱글톤 모델 이해하기

이제 진짜 뷰모델이 될 Category를 작성해볼 건데 그 전에 싱글톤 컨텍스트와 인스턴스 컨텍스트에 대해 생각해보죠.

앞에서도 잠깐 다뤘지만 하나의 객체만 존재하고 그 객체로 뭔가 하려는 구조를 싱글톤 컨텍스트라 합니다.
이 구조 하에서는 언제나 유일한 객체 하나만 존재하기 때문에 메모리도 절약되고 관리하기도 굉장히 편리합니다.

반대로 같은 구조의 인스턴스를 여러 개 만들어낼 수 있는 구조를 인스턴스 컨텍스트라합니다.
이 구조에서는 개별 인스턴스마다 별도의 상태를 가질 수 있으므로 여러 상태를 동시에 관리할 수 있습니다.
대신 싸질러 양상한 인스턴스를 관리할 방법이 필요하게 됩니다(보통 라우터라 부릅니다)

뷰모델이 되었든, 커맨드가 되었든 혹은 뷰컴포넌트가 되었든 해당 객체가 싱글톤인가 인스턴스인가를 구분할 필요가 있습니다.
이건 사실 절대적인 정답이 있는 게 아니라 개발자가 그때 그때 해야할 의사결정입니다. 똑같은 도메인이라 할지라도 그걸 싱글톤으로 구현할지 개별 인스턴스로 구현할지는 사람마다 다릅니다.

가장 쉽게는 App이라는 컴포넌트를 볼까요. 거의 모든 리액트 앱의 루트가 되는 컴포넌트로 App이 존재하는데, 순수한 리액트 컴포넌트들은 무조건 클래스처럼 인스턴스로 사용될 수 있습니다. 즉 다음과 같은 코드가 가능은 하죠.

export const Root =()=>{
return (<>
<App />
<App />
<App />
</>);
};
[/js]

이 경우 App은 명백하게 인스턴스 컨텍스트로 사용되고 있습니다. 하지만 보통은 App이 Root 역할을 합니다. 그렇다면 App은 딱 한 번만 등장하게 되죠.
즉 컴포넌트로서는 여러 번 등장하지만, 우리가 설계 상 App을 싱글톤으로 사용하게 되는 것입니다.

App컴포넌트가 싱글톤이면 당연히 그 App이 사용하는 state도 싱글톤이라 확정할 수 있습니다. 왜냐면 App의 인스턴스가 딱 하나니까 그 state도 딱 하나인 것이죠. 이미 공유 상태에서 다룬 것처럼 Category는 App수준의 state였습니다. 그러므로 Category는 싱글톤 객체라고 판단할 수 있습니다. 이를 바탕으로 싱글톤인 Category모델을 구성해볼 수 있습니다.

객체가 싱글톤이란 얘기는 static으로만 구성해도 충분하다는 뜻입니다. 그렇다면 state의 의미는 뭘까요? state안에 있는 값은 어떤 의미를 갖게 되나요?

아무런 의미도 없습니다. 그저 state는 컴포넌트를 다시 호출하는 트리거 외엔 아무런 의미도 없습니다. 여기서 다시 한 번 리액트란 뭔가를 생각해보죠.

  1. state의 값이 바뀌면 해당 컴포넌트를 호출해준다.
  2. 그 결과 ReactElement를 반환하게 되고,
  3. 이전 렌더링 시점과 차이를 분석하여 DOM에 반영한다.

즉 state란 그저 그 컴포넌트를 호출하기 위한 트리거인 셈입니다. 특히나 인스턴스의 메모리가 필요없고 싱글톤인 경우는 이미 단일 객체에서 정보를 보관하고 있으므로 state는 그야말로 컴포넌트 재호출용 이상도 이하도 아닙니다. 가장 무성의하게 state를 구성합시다 ^^;

export class Category implements StateSetter{

  //무성의한 state값 1, -1을 번갈아가면서 쓴다.
  private static updater = 1;

  //state의 setter를 잡아둔다.
  private static setState:any;

  //외부에 제공되는 use
  static use(){
    //세터를 넣어줌
    Category.setState = useState(Category.updater)[1];
  }

  //App을 다시 그리게 트리거한다.
  private static flush(){
    //1, -1을 번갈아가면서 state의 값만 바꿔준다.
    Category.setState(Category.updater *= -1);
  }

  //모든 상태는 싱글톤인 static에 의존한다-----------------

  //카테고리 리스트와 add, remove
  static readonly list = new Set<string>();
  static add(v:string){
    Category.list.add(v);
    Category.flush();
  }
  static remove(v:string){
    Category.list.delete(v);
    Category.flush();
  }

  //현재 선택한 카테고리
  private static _current = "";
  static get current(){return this._current;}
  static set current(v){
    Category._current = v;
    Category.flush();
  }
}

위 코드를 보면 모든 모델의 상태는 static으로 갖고 있으며, state는 그저 App컴포넌트를 재호출하기 위한 트리거로만 사용됩니다.
또한 외부에서는 Category의 static속성을 참고하여 모든 걸 해결하게 됩니다.

이렇듯 싱글톤으로 사용될게 확정인 객체는 손쉽게 static에 몰아서 구축할 수 있습니다. 이때 state는 컴포넌트를 재호출하여 갱신할 목적으로만 사용되는 것이죠.
Category 모델을 구축했으니 이를 이용하는 정식 App을 구현합니다.

export const App =()=>{
    Category.use();
    return (<>
        <h1>Todos</h1>
        <CategoryView/>
        <TodoView/>
    </>);
};

사실 싱글톤이라 딱히 내부에 변수로 잡을 필요도 없습니다. 이제 Category의 싱글톤 속성을 건드리면 App은 재호출될테고 그 자손 컴포넌트는 전부 재호출 될 것입니다.

state의 진짜 의미란 무엇인가요?
컴포넌트를 재호출하기 트리거이자, 컴포넌트별 인스턴스 메모리라능!

CategoryView 구축

드디어 좌측을 구성할 UI인 CategoryView와 CategoryCommand를 구축할 차례입니다. 우선 뷰부터 볼까요.

export const CategoryView =()=>{
  const {add, remove, select} = CategoryCommand;
  return (<nav>
    {[...Category.list].map(key=>(<div key={key}>
      <span onClick={select(key)}>{key}</span>
      <span onClick={remove(key)} STYLE="color:red">x</span>
    </div>))}
    <input onKeyDown={add} placeholder="input new category"/>
  </nav>);
};

뷰는 Command로부터 필요한 행위를 수령합니다. 헌데 렌더링할 모델은 Category클래스의 static 속성으로부터 인수하게 됩니다. context같은 것의 도움은 딱히 필요없고 이렇게 얻게 된 list가 항상 최신 상태가 됩니다. 선택을 위한 select나 remove, add등은 최종적으로는 Category의 static속성과 상호작용하겠지만 뷰에서는 그 모두를 Command에게 맡깁니다.
하지만 add와 다르게 select와 remove를 얻는 방법은 특이합니다. select(key)를 호출하여 리스너를 받아오는 방식을 사용합니다.
remove도 마찬가지로 remove자체가 리스너가 아니라 remove(key)를 호출한 결과를 리스너를 받는거죠. 이는 key별로 한 번만 리스너를 만들고 이를 재활용하여 쓰기 위해서입니다. 이런 방식의 캐쉬가 가능한 이유는 key에 해당되는 카테고리 이름이 값이기 때문에 이 값에 맞는 리스너가 항상 동일한 작동을 하기 때문입니다.

이제 행위를 담당하는 CategoryCommand도 볼 건데, 위에서 언급했던 싱글톤과 인스턴스에 대한 생각을 좀 해보죠.
CategoryCommand는 싱글톤일까요? 아니면 인스턴스일까요?
사실 이 답변은 CategoryCommand의 문제가 아니라 CategoryView의 문제입니다.
CategoryView가 싱글톤이라면 당연히 그 state인 CategoryCommand도 싱글톤입니다. 하지만 CategoryView가 여러번 나올 수 있는 인스턴스라면 CategoryCommand도 인스턴스여야합니다.
인스턴스가 되려면 컴포넌트마다 고유한 객체가 되어야하니 당연히 useState를 사용해 컴포넌트 고유의 객체로 생성해야할 것입니다.
반대로 싱글톤이라면 싱글톤 객체의 상태변화가 있다면 useState를 브릿지하고 상태변화가 없다면 그냥 static을 사용하면 충분합니다.

CategoryView는 이 to-do앱에서 오직 한 번만 등장하는 컴포넌트입니다. 따라서 싱글톤입니다. 그렇다는 건 CategoryCommand도 싱글톤이므로 그저 static으로 만들면 됩니다. 특히 CategoryCommand은 행위만 공급하지 상태를 갖고 있지 않습니다. 따라서 CategoryCommand가 변화하여 컴포넌트를 다시 그리는 일은 없으므로 state를 사용할 이유는 전혀 없습니다. 따라서 Category모델의 여러 속성을 조정하는 행위만 static으로 기술하면 됩니다.

export class CategoryCommand {

  //select, remove를 카테고리명별로 리스너 캐쉬를 잡음
  private static readonly selects = new Map<string, ()=>void>();
  private static readonly removes = new Map<string, ()=>void>();

  //추가
  static add = ({target, keyCode}: KeyboardEvent)=>{
    const input = target as HTMLInputElement;
    if (!input.value.trim() || keyCode !== 13) return;
    Category.add(input.value.trim());
    input.value = "";
  }

  //선택시 캐쉬에 없으면 캐쉬에 새 리스너를 넣어준다.
  static select = (key:string)=>{
    const {selects} = CategoryCommand;
    if (!selects.has(key)) selects.set(key, ()=>Category.current = key);
    return selects.get(key)!!;
  }

  //상동
  static remove = (key:string)=>{
    const {removes} = CategoryCommand;
    if (!removes.has(key)) removes.set(key, ()=>Category.remove(key));
    return removes.get(key)!!;
  }
}

커맨드에서는 Category의 여러 속성을 이용하는 지식을 캡슐화하여 뷰에 제공하고 있습니다. 기저가 되는 모델조차 싱글톤 컨텍스트이므로 state나 props의 관여없이 읽어올 수 있습니다.

사소한 리스너도 전부 캐쉬잡으면 좋은가요?
캐쉬를 쓰는 이유는 증분렌더링을 최소화해주기 때문이라능!

결론

이번 시간은 to-do앱을 구성하는 좌측 카테고리메뉴까지 구현해보았습니다.
리액트에서 싱글톤과, 인스턴스 컨텍스트의 차이와 그에 따라 어떻게 state와 연동하는지 섬세한 차이도 살펴봤죠.
contextAPI가 어떤 경우에 무력화되어 싱글톤 state공유와 같아지는도 살펴봤네요.

다음에는 우측의 카테고리별 할 일 목록쪽을 만들며 인스턴스컨텍스트를 따르는 컴포넌트를 구성해 to-do앱을 최종적으로 완성해보죠.

이번 포스팅에서 다룬 전체 코드는 다음과 같습니다.

export const App =()=>{
    Category.use();
    return (<>
        <h1>Todos</h1>
        <CategoryView/>
        {/*<TodoView/>*/}
    </>);
};
//뷰모델
export class Category implements StateSetter{
  private static updater = 1;
  private static setState:any;
  static use(){
    Category.setState = useState(Category.updater)[1];
  }
  private static flush(){
    Category.setState(Category.updater *= -1);
  }
  static readonly list = new Set<string>();
  static add(v:string){
    Category.list.add(v);
    Category.flush();
  }
  static remove(v:string){
    Category.list.delete(v);
    Category.flush();
  }
  private static _current = "";
  static get current(){return this._current;}
  static set current(v){
    Category._current = v;
    Category.flush();
  }
}

//커맨드
export class CategoryCommand {
  private static readonly selects = new Map<string, ()=>void>();
  private static readonly removes = new Map<string, ()=>void>();

  static add = ({target, keyCode}: KeyboardEvent)=>{
    const input = target as HTMLInputElement;
    if (!input.value.trim() || keyCode !== 13) return;
    Category.add(input.value.trim());
    input.value = "";
  }
  static select = (key:string)=>{
    const {selects} = CategoryCommand;
    if (!selects.has(key)) selects.set(key, ()=>Category.current = key);
    return selects.get(key)!!;
  }
  static remove = (key:string)=>{
    const {removes} = CategoryCommand;
    if (!removes.has(key)) removes.set(key, ()=>Category.remove(key));
    return removes.get(key)!!;
  }
}
//뷰
export const CategoryView =()=>{
  const {add, remove, select} = CategoryCommand;
  return (<nav>
    {[...Category.list].map(key=>(<div key={key}>
      <span onClick={select(key)}>{key}</span>
      <span onClick={remove(key)} STYLE="color:red">x</span>
    </div>))}
    <input onKeyDown={add} placeholder="input new category"/>
  </nav>);
};
기존에 리덕스와 ContextAPI를 쓰던 코드랑 너무 달라요!
짜피 이 시리즈는 MVVM과 모델-렌더관점에서 기저 렌더러로만 리액트를 볼거라능!