모던리액트 5장 - (2) 리액트 훅으로 시작하는 상태 관리

nais·2024년 12월 29일
0

모던리액트

목록 보기
7/7
post-thumbnail

이전의 리액트 생태계에서는 상태 관리를 위해 리덕스에 의존했다면 현재는 Context API, useReducer,useState의 등장으로 컴포넌트 내부에 걸쳐서 상태를 관리할 수 있는 방법과 리덕스 이외에 다른 상태 관리 라이브러리가 등장했다.

리액트 16.8에서 등장한 훅과 함수 컴포넌트의 패러다임에서 애플리케이션 내부 상태관리와 새로운 라이브러리에는 어떤 것들이 있는지 정리해보았다.

5.2.1 가장 기본적인 방법 : useState 와 useReducer

  • 리액트의 훅을 기반으로 만든 사용자 정의 훅은 함수 컴포넌트라면 재사용 가능
  • useState와 useReducer 기반으로 한 상태를 지역 상태(local state)라 하고, 이 지역 상태는 해당 컴포넌트 내에서만 유요하다는 한계가 있다
const useCounter = (initCounter: number = 0) => {
  const [counter, setCounter] = useState(initCounter);

  const inc = () => {
    setCounter(prev => prev + 1);
  };

  return { counter, inc };
};


const Counter1 = ({ counter, icn }: { counter: number; icn: () => void }) => {
  return (
    <>
      <h3> Counter1 : {counter}</h3>
      <button onClick={icn}> +</button>
    </>
  );
};

const Counter2 = ({ counter, icn }: { counter: number; icn: () => void }) => {
  return (
    <>
      <h3> Counter2 : {counter}</h3>
      <button onClick={icn}> +</button>
    </>
  );
};

const Home = () => {
  const { counter, inc } = useCounter();

  return (
    <div>
      <Counter1 counter={counter} icn={inc} />
      <Counter2 counter={counter} icn={inc} />
    </div>
  );
};

지역 상태인 useCounter를 부모 컴포넌트 Home 으로 끌어올려 이 하위 값을 하위 컴포넌트에서 참조해 재사용 하게끔 만들면, 컴포넌트가 동일한 상태를 사용할 수는 있지만, props 형태로 필요한 컴포넌트에 제공하는 것은 여러 컴포넌트에 걸쳐서 공유하기 위해서는 트리 설계가 복잡해짐

5.2.2 지역 상태의 한계를 벗어나보자

(1) useState를 리액트 클로저가 아닌 완전히 다른 곳에서 초기화 된다면?


export type State = { counter: number };

//  상태를 컴포넌트 밖에 선언, 각 컴포넌트는 해당 state 를 바라본다
let state: State = { counter: 0 };

// useState 와 동일하게 구현, 게으른 초기화를 위한 함수를 받거나 값을 받을 수 있음
type Initializer<T> = T extends any ? T | ((pre: T) => T) : never;

export const getter = (): State => {
  console.log("getter", state);
  return state;
};

export const setter = (nexttState: Initializer<T>) => {
  console.log("setter", state);
  state = typeof nexttState === "function" ? nexttState(state) : state;
};

const Counter = () => {
  const state = getter();

  const handleClick = () => {
    setter((prev: State) => ({ counter: prev.counter + 1 }));
    console.log("handelClick", state);
  };

  return (
    <>
      <h3>{state.counter}</h3>
      <button onClick={handleClick}>+ </button>
    </>
  );
};

실행 결과

console.log 로 찍어 보았을 때 setter 에서는 새로운 값이 잘 불려와지지만, 컴포넌트를 리렌더링 되지 않는다.
이 컴포넌트는 함수 컴포넌트의 재실행(호출), 부모 함수 리렌더링, useState 의 두번째 인수 호출 등 리렌더링 장치가 없다

(2) useState의 인수로 컴포넌트 밖에서 선언한 State을 넘겨주는 방식


const Counter1 = () => {
  const [count, setCount] = useState(state);

  const handleClick = () => {
    setter((pre: State) => {
      const newState = { counter: pre.counter + 1 };
      setCount(newState);

      return newState;
    });
  };
  return (
    <>
      <h3> counter1: {count.counter}</h3>
      <button onClick={handleClick}> +</button>
    </>
  );
};

const Counter2 = () => {
  const [count, setCount] = useState(state);

  const handleClick = () => {
    setter((pre: State) => {
      const newState = { counter: pre.counter + 1 };
      setCount(newState);

      return newState;
    });
  };
  return (
    <>
      <h3> counter2: {count.counter}</h3>
      <button onClick={handleClick}> +</button>
    </>
  );
};

const Counter = () => {
  return (
    <>
      <div>
        <Counter1 />
        <Counter2 />
      </div>
    </>
  );

실행 결과

원하는 값을 안정적으로 렌더링 하지만, 같은 상태를 바라보는 반대쪽 컴포넌트에서는 렌더링되지 않는다.
반대 쪽 컴포넌트는 버튼을 눌러야 그제서야 렌더링되어 최신값을 불러 오는 것을 확인

함수 외부에서 상태를 참조하고, 이를 통해 렌더링까지 자연스럽게 일어나러면 다음과 같은 조건을 만족해야함

1) 꼭 window 나 global 에 있어야 할 필요는 없지만, 컴포넌트 외부 어딘가에 상태를 두고 여러 컴포넌트가 같이 써야한다
2) 이 외부에 있는 상태를 사용하는 컴포넌트 상태의 변화를 알아챌 수 있어야하고, 상태가 변화될 때 마다 리렌더링이 일어나서 컴포넌트를 최신 상태 값 기준으로 렌더링 해야 한다. (이 상태를 참조하는 모든 컴포넌트에 해당)
3) 상태가 원시 값이 아닌 객체인 경우 그 객체에 내가 감지하지 않는 값이 변한다 하더라도 리렌더링이 발생해서는 안된다.

(3) 위 3가지 조건에 맞는 새로운 상태 관리 코드를 작성해보자


// useState 와 동일하게 구현, 게으른 초기화를 위한 함수를 받거나 값을 받을 수 있음
type Initializer<T> = T extends any ? T | ((pre: T) => T) : never;

// 상태는 객체, 원시값 일 수도 있다
type Store<State> = {
  get: () => State;
  set: (action: Initializer<State>) => State;
  subscribe: (callback: () => void) => () => void; // 2 번의 조건을 만족하기 위해 store 값이 변경 될 때 마다 알려주는 callback 함수 실행
};

export const createStore = <State extends unknown>(initialState: Initializer<State>): Store<State> => {
  // state 의 값은 스토어 내부에서 보관해야 하므로 변수로 선언
  // 초기값은 게으른 초기화를 위한 함수 또한 그냥 값을 받을 수 있도록 선언
  let state = typeof initialState! == "function" ? initialState : initialState();

  // callbacks 는 자료형에 상관없이 유일한 값을 지정할 수 있는 Set 을 사용한다.
  const callbacks = new Set<() => void>();

  const get = () => state;
  const set = (nexttState: State | ((prev: State) => State)) => {
    // 인수가 함수라면 실행하여 새로운 값을 받도록, 아니라면 새로운 값 그대로 사용
    state = typeof nexttState === "function" ? (nexttState as (prev: State) => State)(state) : nexttState;

    // 값의 설정이 발생하면 callbacks 돌면서 저장되어있는 콜백 모두 시행( ex. useState 실행하여 스토어 업데이트, 컴포넌트의 렌더링이 유도될 것이다.
    callbacks.forEach(callback => callback());
    return state;
  };

  // 콜백 함수를 인수로 받아서 받은 함수를 목록에 추가
  const subscribe = (callback: () => void) => {
    callbacks.add(callback);

    //클린 업 실행 시 이를 삭제해서 반복적으로 추가 되는 것을 막는다
    // useEffect 의 클린업 함수와 같은 역할 
    return () => {
      callbacks.delete(callback);
    };
  };

  return { get, set, subscribe };
};


// 인수로 사용할 store 를 받는다
const useStore = <State extends unknown>(store: Store<State>) => {
  // useState가 컴포넌트의 렌더링을 유도
  const [state, setState] = useState<State>(() => store.get());

  // useEffect는 store 의 값을 가져와 setState를 수행하는 함수는 store 의 subscribe에 등록된 함수를 실행
  // useStore 내부에서는 store 의 값이 변경될 때 마다 state 의 값이 변경되는 것을 보장 받는다
  useEffect(() => {
    const unsubscribe = store.subscribe(() => {
      setState(store.get());

      return unsubscribe;
    });
  }, [store]);

  return [state, store.set] as const;
};

const store = createStore({ count: 0 });

const Counter1 = () => {
  const [state, setState] = useStore(store);

  const handleClick = () => {
    setState(pre => ({ count: pre.count + 1 }));
  };

  return (
    <>
      <h3>Counter1 : {state.count}</h3>
      <button onClick={handleClick}>+</button>
    </>
  );
};

const Counter2 = () => {
  const [state, setState] = useStore(store);

  const handleClick = () => {
    setState(pre => ({ count: pre.count + 1 }));
  };

  return (
    <>
      <h3>Counter2 : {state.count}</h3>
      <button onClick={handleClick}>+</button>
    </>
  );
};

const Home = () => {
  return (
    <div>
      <Counter1 />
      <Counter2 />
    </div>
  );
};

const store = createStore({ count: 0 });

const Counter1 = () => {
  const [state, setState] = useStore(store);

  const handleClick = () => {
    setState(pre => ({ count: pre.count + 1 }));
  };

  return (
    <>
      <h3>Counter1 : {state.count}</h3>
      <button onClick={handleClick}>+</button>
    </>
  );
};

실행 결과

Counter 1, Counter2 가 store의 상태 변화에 의해 동시에 두 컴포넌트가 모두 정상적으로 리렌더링 되는 것을 확인 우리가 알고있는 스토어의 형태로 결과가 나타났다!

5.2.3 useState 와 Context를 동시에 사용해보기

  • 위에 (3) 번에서 실습한 내용을 기반으로 useStore에도 단점이 있다 바로 이 훅과 스토어를 사용하는 구조는 반드시 하나의 스토어만 가지게 된다는 것
  • 하나의 스토어를 가지면 이 스토는 전역 변수처럼 작동하게 되어서 동일한 형태의 여러 개의 스토어를 가질 수 없게된다
  • 훅은 사용하는 서로 다른 스코프에서 스토어의 구조는 동일하되 , 여러개 다른 데이터를 공유하는 방법으로는 리액트의 Context 를 사용할 수 있다.
  • Context 를 활용해 해당 스토어를 하위 컴포넌트에 주입한다면 자신이 주입된 스토어에 대해서만 접근 할 수 있게 될 것이다.
  • Context 와 Provider를 관리하는 부모 컴포넌트의 입장에서는 자식 컴포넌트의 책임과 역할을 이름이 아닌 명시적인 코드로 나눌 수 있어 코드 작성이 용이하다.
  1. useState, useReducer 가 가지고 있는 한계, 컴포넌트 내부에서만 사용할 수 있는 지역 상태라는 점을 극복하기 위해 외부 어딘가에 상태를 저장해 둘 수 있고, 혹은 격리된 자바스크립트 스코프 어딘가일 수도있다.
    2.이 외부의 상태 변경을 각자의 방식으로 감지해 컴포넌트 렌더링을 일으킨다
profile
왜가 디폴트값인 프론트엔드 개발자

0개의 댓글