개발새발 로그

React 상태 관리의 역사와 상태 관리 직접 구현하기 본문

React

React 상태 관리의 역사와 상태 관리 직접 구현하기

이즈흐 2024. 4. 17. 20:08

리액트 상태 관리 라이브러리가 내부에서 어떤식으로 구현이되는지 공부하면서
구현 방식을 처음 봤을 때 이해하기가 어려웠다.

 

그래서 직접 코드를 작성해보고, 그림으로 표현하면서 더 쉽게 정리해보았다.

 

먼저 웹 애플리케이션에서 상태로 분류될 수 있는 것들은 대표적으로 아래와 같다.

1. UI : 상호작용이 가능한 모든 요소의 현재 값(다크/라이트 모드,라디오를 비롯한 각종 input, 알림창의 노출 여부)

2. URL : 브라우저에서 관리되고 있는 상태 값

3. 폼(form): 폼이 로딩 중인지, 현재 제출했는지, 접근이 불가능한지, 값이 유효한지 등

4. 서버에서 가져온 값: 클라이언트에서 서버로 요청을 통해 가져온 값도 상태로 볼 수 있다.

 

🤔왜 상태관리 라이브러리가 필요할까?

웹 서비스에서 점차 다양한 기능이 제공됨에 따라 웹 내부에서 관리해야 할 상태도 점차 비례해서 증가하고 있다.

이러한 상태들을 효과적으로 관리하는 방법을 계속해서 고민해야 하는 시대가 도래했기 때문이다.

 

 

✨Flux의 등장

과거에 웹 개발에서는 MVC패턴을 사용했는데 웹 애플리케이션이 비대해지고, 상태도 많아짐에 따라 어디서 어떤 일이 일어나서 상태가 변했는지 등을 추적하고, 이해하기가 어려운 상황이었다.

 

페이스북 팀은 이러한 문제를 양방향 데이터 바인딩으로 봤고, 단방향으로 데이터 흐름을 변경하는 것을 제안하는데 이것이 바로 Flux 패턴의 시작이다.

 

단방향 데이터 흐름 방식의 장단점

장점

- 데이터 흐름이 모두 액션이라는 한 방향(단방향)으로 줄어드므로 데이터의 흐름을 추적하기도 쉽고 코드를 이해하기가 한결 수월해진다. 

 

단점

- 사용자의 입력에 따라 데이터를 갱신하고 화면을 어떻게 업데이트해야 하는지도 코드로 작성해야 하므로  코드의 양이 많아지고, 개발자 수고로워진다.

 

리덕스의 등장

리액트와 단방향 데이터 흐름이 점점 두각을 드러내던 와중에 등장한 것이 리덕스다.

리덕스는 Flux 구조를 구현하기 위해 만들어진 라이브러리 중 하나였다.

 

특별한 점은 여기에 Elm 아키텍처를 도입했다는 것이다.

Elm은 웹페이지를 선언적으로 작성하기 위한 언어이다.

Elm은 Model-Update-View 패턴을 사용하는 자체 아키텍처를 가지고 있다.

Elm은 Flux와 마찬가지로 데이터 흐름을 세 가지로 분류하고, 이르 단방향으로 강제해 웹 애플리케이션의 상태를 안정적으로 관리하고자 노력했다.

 

리덕스도 Flux 아키텍쳐를 기반으로 4가지의 요소로 구성되어있다.

  • 액션(Action) : 상태 변화를 일으킬 때 참조하는 객체이다.
  • 스토어(Store) : 애플리케이션의 상태 값들을 내장하고 있다.
  • 리듀서(Reducer) : 상태를 변화시키는 로직이 있는 함수이다.
  • State : 컴포넌트에 최종 출력하기 전 거치는 중간과정이다.
  • 디스패치(dispatch) : 액션을 스토어에 전달하는 것을 의미한다.
  • 구독 : 스토어 값이 필요한 컴포넌트는 스토어를 구독한다.

이를 통해 리액트에서 큰 문제였던 prop drilling문제를 해결할 수 있게 되었다.

 

리덕스의 단점

하지만 리덕스를 사용하면 아래와 같은 단점이 존재했다.

  • 복잡성과 보일러플레이트
    • 리덕스를 사용하면 애플리케이션에 많은 보일러플레이트 코드가 추가될 수 있습니다. 액션 타입, 액션 크리에이터, 리듀서와 같은 여러 부분들을 정의해야 하며, 이로 인해 프로젝트의 복잡성이 증가할 수 있습니다.
  • 오버헤드
    • 작은 규모의 프로젝트에서 리덕스를 사용하면 오히려 오버헤드가 될 수 있습니다. 상태를 전역에서 관리하는 것이 필요하지 않은 경우에 리덕스를 사용하면, 애플리케이션의 복잡성만 증가시키고 개발 속도를 늦출 수 있습니다.
  • 비동기 로직 처리
    • 리덕스 자체는 비동기 작업을 처리하기 위한 내장 메커니즘을 제공하지 않습니다. 따라서 비동기 작업을 처리하기 위해서는 redux-thunk, redux-saga, redux-observable과 같은 추가 미들웨어를 사용해야 하는데, 이는 추가적인 학습과 설정을 필요로 합니다.
  • React에 종속적이지 않음
    • 리덕스는 React와 함께 가장 많이 사용되지만, 리액트에 종속적인 상태 관리 라이브러리는 아닙니다. 따라서 React의 새로운 기능인 Context API나 Hooks와 같은 기능들이 등장하면서, 상태 관리를 위해 리덕스를 꼭 사용해야 하는지에 대한 논의가 있습니다.

✨Context API의 등장

Context API가 등장하기 전까지 리덕스는 리액트의 상태관리에서 중요한 축이었다.

하지만 단순히 상태를 참조하고 싶을 때 리덕스를 사용하면 준비해야하는 보일러 플레이트가 부담되는 상황이 생겼다.

이는 번거로울 뿐만 아니라 컴포넌트를 설계할 때 커다란 제약으로 작용했다.

 

리액트 팀은 리액트 16.3에서 전역 상태를 하위 컴포넌트에 주입할 수 있는 새로운 Context API를 출시 했다.

props로 상태를 넘겨주지 않더라도 Context API를 사용하면 원하는 곳에서 Context Provider가 주입하는 상태를 사용할 수 있게 된 것이다.

 

16.3버전 이전에도 context가 존재했었다?

이전에도  context가 존재했고, 이를 위한 getChildContext가 제공됐지만 문제가 있었다.

상위 컴포넌트가 렌더링되면 getChildContext도 호출됨과 동시에 shouldComponentUpdate가 항상 true를 반환해 불필요하게 렌더링 되는 점과 getChildContext를 사용하기 위해서는 context를 인수로 받아야했는데 이로 인해 결합도가 높아지는 등의 단점이 있었다.

 

 

Context API의 장점

간단한 전역 상태 관리: Context API는 React 애플리케이션 내에서 전역 상태를 관리하는 간단한 방법을 제공합니다. 이를 통해 props drilling 문제를 해결할 수 있습니다.

컴포넌트 재사용성 향상: 특정 데이터를 필요로 하는 모든 컴포넌트에 props를 전달하는 대신, Context를 사용하여 필요한 컴포넌트에서만 데이터에 접근할 수 있게 함으로써 컴포넌트의 재사용성을 높일 수 있습니다.

 

Context API의 단점

컴포넌트 리-렌더링: Context의 값이 변경될 때마다 해당 Context를 사용하는 모든 컴포넌트가 재렌더링됩니다. 이는 성능 문제를 야기할 수 있으며, 특히 대규모 애플리케이션에서 문제가 될 수 있습니다.

유지보수의 어려움: 애플리케이션의 규모가 커질수록, Context를 사용한 상태 관리의 복잡성이 증가할 수 있습니다. 이는 유지보수를 어렵게 만들 수 있으며, 특히 여러 Context를 사용하는 경우 상태 관리가 더욱 복잡해질 수 있습니다.

 

✨훅의 등장

Context API가 등장한지 1년이 채 되지않아 리액트는 16.8 버전에서 함수형 컴포넌트에서 사용할 수 있는 다양한 훅 API를 추가했다.

이 훅 API는 기존에 무상태 컴포넌트를 선언하기위해서만 제한적으로 사용했던 함수형 컴포넌트가 클래스형 컴포넌트 이상의 인기를 구가할 수 있도록 많은 기능을 제공했다.

 

상태 관리 라이브러리의 등장

그리고 훅이라는 새로운 패러다임의 등장에 따라 훅을 활용해 상태를 가져오거나 관리할 수 있는 다양한 상태관리 라이브러리가 등장하게 된다.

 

리덕스와의 차이점

요즘 새롭게 떠오르고 있는 많은 상태 관리 라이브러리는 기존의 리덕스와 같은 라이브러리와는 차이점이 있는데, 바로 훅을 활용해 작은 크기의 상태를 효율적으로 관리한다는 것이다.

 

Recoil, Jotai, Zustand, Valtio 의 저장소를 방문해보면 모두 peerDependencies로 리액트 16.8버전 이상을 요구하고 있다.

리덕스나 MobX도 react-redux나 mobx-react-lite 등을 설치하면 동일하게 훅으로 상태를 가져올 수 있지만

위 라이브러리는 애초에 리액트와의 연동을 전제로 작동해 별도로 다른 라이브러리를 설치안해도 되는 차이점이 있다.

 

 

📖리액트 훅으로 상태관리하기

이제 리액트 훅으로 상태 관리가 어떻게 작동하는지 알아보자

지역상태관리는 넘어가도록 하겠다!

 

여러개의 컴포넌트가 같은 상태를 바라보게 하려면 어떻게 해야할까?

보통은 상태를 컴포넌트 밖으로 한 단계 끌어 올리는 방법을 이용한다.

function App() {
  const [state,setState] = useState(0);
  return (
    <>
      <h1>Vite + React</h1>
      <Counter1 state={state}/>
      <Counter2 state={state}/>
    </>
  )
}

 

하지만 이 방법은 props 형태로 필요한 컴포넌트에 제공해야한다는 점이 불편해 보인다.

 

그러면 상태를 아예 컴포넌트 밖에서 호출하면 어떨까?

let state = {
	counte : 0
}

export function get(){
	return state
}

export function set(nextState){
	state = typeof nextState === "function" ? nextState(state) : nextState
}

하지만 이 방법은 리액트 환경에서 동작하지 않는다.

state가 잘 변경되고 있고, get으로도 최신 값을 잘 가져오고 있지만 컴포넌트가 리-렌더링이 되지 않고있다.

 

그럼 useState의 set함수를 이용해서 리-렌더링을 시키면 어떨까?

function CounterA() {
  const [count, setCount] = useState(state)

  //위에서 만든 set 함수를 내부에서 다음 상태값으로 연산한 다음 
  //전역에 설정한 state에도 넣어준다.
  function handleClick() {
    set(prev => {
      const newState = { counter: prev.counter + 1 }
      setCount(newState)
      return newState
    })
  }

  return (
    <div>
      {counter}
      <butoton onClick={handleClick}></butoton>
    </div>
  )
}

아까 위에서 만든 전역 상태를 업데이트하고, 컴포넌트 내부에서도 useState의 set함수로 연산한다음 리-렌더링을 일으킨 것이다.

 

근데 이 방법은 비효율적이고 문제가 있다.

1. 외부 상태가 있음에도 불구하고, 함수형 컴포넌트의 렌더링을 위해 함수의 내부에 동일한 상태를 관리하는 useState가 존재하는 구조다. -> 이는 상태를 중복해서 관리하므로 비효율적인 방식이라고 볼 수 있다.

2. 컴포넌트 버튼을 누르면 CounterA 컴포넌트의 값만 렌더링되고, 다른 컴포넌트 CounterB는 렌더링이 되지 않는다.

 -> 같은 상태를 공유하지만 동시에 렌더링 되지 않는다.

 

useState로 컴포넌트의 리-렌더링을 싱행해 최신값을 가져오는 방법은 어디까지나 해당 컴포넌트 자체에서만 유효한 전략이다.

즉, 반대쪽의 다른 컴포넌트에서는 여전히 상태의 변화에 따른 리-렌더링을 일으킬 무언가가 없기 때문에 클릭 이벤트가 발생하지 않는 다른 쪽은 여전히 렌더링 되지 않는다.

 

그럼 다음과 같은 조건이 만족해야겠다.

1. 컴포넌트 외부 어딘가에 상태를 두고 여러 컴포넌트가 같이 쓸 수 있어야 한다.

2. 외부에 있는 상태를 사용하는 컴포넌트는 상태의 변화를 알아챌 수 있어야 하고, 상태가 변화할 때마다 리-렌더링이 일어나서 컴포넌트를 최신 상태 값 기준으로 렌더링 해야한다. 이 상태 감지는 상태를 참조하는 모든 컴포넌트에서 동일하게 작동해야한다.

3. 상태가 객체인 경우 내가 감지하지 않는 값이 변했을 때 리-렌더링이 발생하면 안된다.

 

이를 위해서 객체와 원시 값을 저장할 수 있는 store

store의 값이 변경됐음을 알리는 callback함수를 등록하는 subscribe함수를 만들었다.

 

createStore.ts

type Initializer<T> = T extends any ? T | ((prev: T) => T) : never

export type Store<State> = {
  get: () => State
  set: (action: Initializer<State>) => State
  subscribe: (callback: () => void) => () => void
}

export const createStore = <State extends unknown>(
  initialState: Initializer<State>
): Store<State> => {
  let state = typeof initialState === 'function' ? initialState() : initialState

  const callbacks = new Set<() => void>()

  const get = () => state

  const set = (nextState: State | ((prev: State) => State)) => {
    state =
      typeof nextState === 'function'
        ? (nextState as (prev: State) => State)(state)
        : nextState
    console.log(state)
    callbacks.forEach(callback => callback())
    return state
  }

  const subscribe = (callback: () => void) => () => {
    callbacks.add(callback)

    return () => {
      callbacks.delete(callback)
    }
  }

  return { get, set, subscribe }
}

//사용할 counterStore을 외부에 작성했다.
export const counterStore = createStore({ count: 1 })

useStore.ts

import { useEffect, useState } from 'react'
import { Store } from './createStore'

export const useStore = <State extends unknown>(store: Store<State>) => {
  const [state, setState] = useState<State>(store.get())

  useEffect(() => {
    const unsubscribe = store.subscribe(() => {
      setState(store.get())
    })

    return unsubscribe
  }, [store])

  return [state, store.set] as const
}

 

사용방법

function Counter1() {
  const [state, setState] = useStore(counterStore)

  function handleClick() {
    setState(prev => {
      return { count: prev.count + 1 }
    })
  }

  return (
    <>
      <h3>Counter1 : {state.count}</h3>
      <button onClick={handleClick}>플러스</button>
    </>
  )
}

function Counter2() {
  const [state, setState] = useStore(counterStore)

  function handleClick() {
    setState(prev => ({ count: prev.count + 1 }))
  }

  return (
    <>
      <h3>Counter2 : {state.count}</h3>
      <button onClick={handleClick}>플러스~</button>
    </>
  )
}

function App() {
  return (
    <>
      <h1>Vite + React</h1>
      <Counter1 />
      <Counter2 />
    </>
  )
}

export default App

 

이게 어떻게 작동하는지는 텍스트보다 그림으로 표현하는게 더 이해가 쉬울 것 같아서 그림으로 정리해봤다.

 

CrateStore 구성

createStore은 크게 5가지로 구성되어있다.

스토어 내부에서 값을 보관하기위한 state

언제든 최신 state를 가져오는 get함수

값을 업데이트하고 업데이트를 구독자들에게 알리는 set함수

현재 store을 사용하고 있는 컴포넌트들의 구독을 저장하는 subscribe함수

 

useStore의 구성

useStore 3가지의 요소만 보면 되는데 

useStateuseEffect, 그리고 반환하는 [ state, store.set ] 이다.

먼저 useState의 state와 setState, 그리고 useEffect에서 하는 일을 정리해봤다.

 

useStore에서 사용하는 state는 useState의 state이고, setState는 store.set이다.

 

사실 이렇게 설명해도 어려울 것이다.

그럼 전체 흐름도를 보자

전체 흐름도

1. createStore로 store를 초기화하고, store 객체를 만든다. (get, set, subscribe 함수가 들어있는 객체)

2. store 객체를 useStore에 넣어준다.

3. useStore에 store을 넣어줌으로써 useStore 내에서는 구독과 state 연결까지 마친다.

4. handleClick으로 setState() (store.set임)을 하면 store내에 let state가 변경된다.

5. 스토어 내에서 set함수에서는 상태를 업데이트하고 구독자들에게 상태 변화를 알린다.

6. 구독자들이 등록했던 callback 함수 내의 setState(useState의 setState임)가 실행된다.

7. useState의 state가 최신 값으로 변경되고 리-렌더링이 된다.

 

이렇게 흐름도를 보면 이해가 빨라진다.

 

하지만 이 useStore도 완벽하지 않다.

스토어의 구조가 원시 값이라면 상관이 없짐나 객체인 경우를  가정하면 어떤 값이 바뀌든지 간에 모두 리-렌더링이 일어나게 된다.

 

이제 createStore과 useStore이 완전히 이해가 됐으므로

원하는 값이 변경됐을 때만 리-렌더링 되도록 구현해보자

 

🛠️useStore에서 useSelector 훅으로

export const useSelectorStore = <State extends unknown, Value extends unknown>(
  store: Store<State>,
  selector: (state: State) => Value
) => {
  const [state, setState] = useState(() => selector(store.get()))

  useEffect(() => {
    const unsubscribe = store.subscribe(() => {
      const value = selector(store.get())
      setState(value)
    })

    return unsubscribe
  }, [store, selector])

  return [state, store.set] as const
}

 

사용방법

function Counter() {
  const count = useSelectorStore(
    counterTextStore,
    useCallback(state => state.count, [])
  )

  function handleClick() {
    counterTextStore.set(prev => ({ ...prev, count: prev.count + 1 }))
  }

  return (
    <>
      <h3>Counter1 : {count}</h3>
      <button onClick={handleClick}>플러스</button>
    </>
  )
}

function TextEditor() {
  const text = useSelectorStore(
    counterTextStore,
    useCallback(state => state.text, [])
  )

  useEffect(() => {
    console.log('TextEditor 컴포넌트가 렌더링되었습니다.')
  })

  function handleClick(e: ChangeEvent<HTMLInputElement>) {
    counterTextStore.set(prev => ({ ...prev, text: e.target.value }))
  }
  return (
    <>
      <h3>{text}</h3>
      <input
        value={text}
        onChange={handleClick}
      />
    </>
  )
}

function App() {
  return (
    <>
      <h1>상태관리 라이브러리 만들기</h1>
      <Counter />
      <TextEditor />
    </>
  )
}

export default App

 

사용시 주의점이 하나 있는데

두번 째 인수인 selector를 컴포넌트 밖에 선언하거나 이것이 불가능하다면 useCallback을 사용해 참조를 고정시켜야한다는 것이다.

 

만약 컴포넌트 내에 이 selector 함수를 생성하고 useCallback으로 감싸두지 않는다면 컴포넌트가 리렌더링 될 때 마다 함수가 계속 재생성되어 store의 subscribe를 반복적으로 수행할 것이다.

실행 결과

 

 

useSelector는 useStore을 기반으로 만들어졌지만 한 가지 차이점이 있다.

두 번째 인수로 selector라는 함수를 받는다는 것이다.

이 함수는 store의 상태에서 어떤 값을 가져올지 정의하는 함수로 이 함수를 활용해 store.get을 수행한다.

useState는 값이 변경되지않으면 리-렌더링을 수행하지 않으므로 store의 값이 변경됐다 하더라도 selector(store.get())이 변경되지 않는다면 리-렌더링이 일어나지 않는다.

setState로 상태를 업데이트할 때 새로운 값이 이전 값과 동일하면, React는 렌더 페이즈에서 이를 감지하고, 가능한 경우 불필요한 리렌더링을 방지합니다.
이 경우, 커밋 페이즈는 발생하지 않으며, 따라서 DOM 업데이트나 생명주기 메서드 호출, useEffect 실행 등이 수행되지 않습니다.
이러한 동작은 React의 성능 최적화를 위한 중요한 메커니즘 중 하나입니다.
React는 불필요한 작업을 최소화하여 애플리케이션의 반응성을 향상시킵니다.

즉 text의 값을 변경한다면 모아높은 callback에서는 setState(text)와 setState(count)를 하는데

setState(count)는 변한게 없으니 리-렌더링을 일으키지 않는 것이다.

 

 

🛠️useSyncExternalStore을 사용할 수도 있다?

바로 코드를 보자

useStore.ts

export function useSyncExternalSelectStore<
  State extends unknown,
  Value extends unknown
>(store: Store<State>, selector: (state: State) => Value) {
  return useSyncExternalStore(store.subscribe, () => selector(store.get()))
}

사용방법

function Counter() {
  const count = useSyncExternalSelectStore(
    counterTextStore,
    useCallback(state => state.count, [])
  )

  function handleClick() {
    counterTextStore.set(prev => ({ ...prev, count: prev.count + 1 }))
  }

  return (
    <>
      <h3>Counter1 : {count}</h3>
      <button onClick={handleClick}>플러스</button>
    </>
  )
}

function TextEditor() {
  const text = useSyncExternalSelectStore(
    counterTextStore,
    useCallback(state => state.text, [])
  )

  useEffect(() => {
    console.log('TextEditor 컴포넌트가 렌더링되었습니다.')
  })

  function handleClick(e: ChangeEvent<HTMLInputElement>) {
    counterTextStore.set(prev => ({ ...prev, text: e.target.value }))
  }
  return (
    <>
      <h3>{text}</h3>
      <input
        value={text}
        onChange={handleClick}
      />
    </>
  )
}

function App() {
  return (
    <>
      <h1>상태관리 라이브러리 만들기</h1>
      <Counter />
      <TextEditor />
    </>
  )
}

export default App

이 코드를 실행하면 위에서 봤던 실행결과와 마찬가지로 동일하게 수행되는 것을 볼 수 있다.

 

🤔useSyncExternal을 사용하면 좋은 점이 무엇일까?

위에서만든 useStore나 useSelectorStore는 모두 useEffect의 의존성 배열에 store나 selector가 들어가 있다.

이 객체가 임의로 변경될 경우 불필요하게 리-렌더링이 발생하는 문제가 있다.

 

이를 방지하기위해 useSyncExternalStore에서는 예외처리를 추가해 이러한 변경이 알려지는 동안에는 store나 selector의 변경을 무시하고 한정적으로 원하는 값을 반환하게끔 훅이 작성돼 있다. (useSyncExternalStore 내부 코드에서)

 

이는 위에서 작성한 코드보다 훨씬 더 안정적으로 상태를 제공할 수 있게하는 안전장치로 보면 된다.

1. 동시성 모드 지원: useSyncExternalStore는 React의 동시성 모드와 완벽하게 호환됩니다. 이는 애플리케이션의 성능을 극대화하고, 사용자 경험을 향상시키는 데 도움이 됩니다.
 -> react 18에서 useDefferedValue나 useStartTranstion이 생긴 이후로 생긴 동시성 문제를 해결해줌

2. 리렌더링 최적화: useSyncExternalStore는 React의 내부 알고리즘을 사용하여 상태 변화가 있을 때만 컴포넌트를 리렌더링합니다. 이는 불필요한 리렌더링을 줄이고 애플리케이션의 성능을 개선합니다.

3. 코드 단순화: useState와 useEffect를 사용하는 복잡한 구현 대신, useSyncExternalStore를 사용하면 코드를 더 단순하고 명확하게 만들 수 있습니다. 이는 코드의 가독성과 유지보수성을 향상시킵니다.

4. 안정적인 업데이트 메커니즘: useSyncExternalStore는 React의 업데이트 메커니즘을 사용하여 외부 상태의 변화를 안정적으로 관리합니다. 이는 애플리케이션의 안정성을 높이는 데 도움이 됩니다.

 

 

 

🤔같은 store 구조에서 서로 다른 데이터를 공유하는 방법은?

위에서 만든 훅에서의 단점이 하나 존재한다.

이 훅과 store를 사용하는 구조는 반드시 하나의 store만 가지게 된다든 것이다.

하나의 스토어를 가지면 이 스토어는 마치 전역 변수처럼 작동하게 되어 동일한 형태의 여러개의 스토어를 가질 수 없게 된다.

만약 훅을 사용하는 서로 다른 스코프에서 스토어의 구조는 동일하되, 여러개의 서로 다른 데이터를 공유해 사용하고 싶다면 어떻게 할까?

 

가장 먼저 떠오르는 방법은 createStore을 이용해 동일한 타입으로 스토어를 여러개 만드는 것이다.

export const store1 = createStore({ count: 1, text: 'hi' })
export const store2 = createStore({ count: 1, text: 'hi' })
export const store3 = createStore({ count: 1, text: 'hi' })

 

그러나 이 방법은 완벽하지도 않고, 매우 번거롭다.

 

1. 먼저 해당 스토어가 필요할 때마다 반복적으로 스토어를 생성해야한다.

 

2. 또한 훅은 스토어에 의존적인 1:1 관계를 맺고 있으므로 스토어를 만들 때 마다 해당 스토어에 의존적인 useStore와 같은 훅을 동일한 개수로 생성해야한다.

 

3. 마지막으로 훅을 하나씩 만들었다고 하더라도 이 훅이 어느 스토어에서 사용가능한지 가늠하려면 오직 훅의 이름이나 스토어의 이름에 의지해야 한다는 어려움이 있다.

 

 

이 문제를 해결하는 방법은 Context다.

 

🛠️Context를 활용하자!

Context를 활용해 해당 스토어를 하위 컴포넌트에 주입한다면 컴포넌트에서는

자신이 주입된 스토어에 대해서만 접근할 수 있게 될 것이다.

 

먼저 설계 흐름도를 보자

흐름도

ConterStoreContext를 호출하게 되면 createStore를 통해서 store를 생성하게 된다.

store이 중복 생성되지 않도록 useRef에 store 객체를 저장했다.

useRef로 생성된 객체의.current속성값을 변경하는 것은, React의 상태 관리 시스템 밖에서 일어나는 변경이며,
이러한 변경은 React에 의해 감지되지 않습니다.(useRef는 모든 리-렌더링 조건에 무조건 반응하지 않는다)

 

이때 CounterStoreContext를 호출하지 않아도 store을 사용할 수 있도록 createContext에 인자로 createStore을 넣어줬다.

 

그럼 이제 이 Context에서 내려주는 값을 사용하기 위한 useContext가 필요하다

그래서 새로운 훅인 useCounterContextSelector을 만들어 줘야한다.

내부에서는 useContext를 이용해 Provider로 내려주는 store를 받고

받은 store를 useSyncExternalStore을 이용해서 구독하고 store의 값을 가져온다.

 

그리고 useSyncExternalStore의 값store.set을 반환해서
store.set이 일어나면 저장소의 값이 변경되고, 

이를 useSyncExternalStore로 구독해줬던 모든 컴포넌트들에게 알린다.
이때 받아왔던 콜백함수에서 만약 각 컴포넌트가 selector한 값이 변경되지않았다면 리-렌더링을 생략하게 된다.(selector의 기능)

 

그래서 여러개의 store(Provider)를 생성하면 아래와 같이 구성된다.

즉  하나의 store로 여러 컴포넌트에서 따로 관리할 수 있게 되었다.

 

전체 코드

CounterStoreContext.tsx

//어떠한 Context를 만들지 미리 정의
export interface CounterStore {
  count: number
  text: string
}

export const CounterStoreContext = createContext<Store<CounterStore>>(
  createStore<CounterStore>({ count: 0, text: 'hello' })
) //만약 상위에서 Provider로 감싸주지 않고, value가 없다면 실행해서 넣어줌

export const CounterStoreProvider = ({
  initialState,
  children
}: PropsWithChildren<{ initialState: CounterStore }>) => {
  const storeRef = useRef<Store<CounterStore>>()


  // 최초에 한 번만 생성함
  if (!storeRef.current) {
    storeRef.current = createStore(initialState)
  }

  return (
    <CounterStoreContext.Provider value={storeRef.current}>
      {children}
    </CounterStoreContext.Provider>
  )
}

이 Context에서 내려주는 값을 사용하기 위해서 새로운 훅이 필요하다.

Context에서 제공하는 스토어에 접근해야하기 때문이다.

useContext를 사용해 스토어에 접근할 수 있는 새로운 훅을 만들어보자

useCounterContextSelector.ts

export function useCounterContextSelector<Value extends unknown>(
  selector: (state: CounterStore) => Value
) {
  const store = useContext(CounterStoreContext)

  const subscription = useSyncExternalStore(store.subscribe, () =>
    selector(store.get())
  )

  return [subscription, store.set] as const
}

불필요한 반복을 줄이기위해 useSyncExternalStore을 사용했다.

그리고 store에 접근하기 위해서 useContext를 사용했다.

스토어에서 값을 찾는 것이 아니라 Context.Provider에서 제공된 스토어를 찾게 만드는 것이다.

사용방법

unction Counter() {
  const [counter, setStore] = useCounterContextSelector(
    useCallback(state => state.count, [])
  )

  useEffect(() => {
    console.log('Counter 컴포넌트가 렌더링되었습니다.')
  })

  function handleClick() {
    setStore(prev => ({ ...prev, count: prev.count + 1 }))
  }

  return (
    <>
      <h3>Counter1 : {counter}</h3>
      <button onClick={handleClick}>플러스</button>
    </>
  )
}

function TextEditor() {
  const [text, setStore] = useCounterContextSelector(
    useCallback(state => state.text, [])
  )

  useEffect(() => {
    console.log('TextEditor 컴포넌트가 렌더링되었습니다.')
  })

  function handleClick(e: ChangeEvent<HTMLInputElement>) {
    setStore(prev => ({ ...prev, text: e.target.value }))
  }
  return (
    <>
      <h3>{text}</h3>
      <input
        value={text}
        onChange={handleClick}
      />
    </>
  )
}

function App() {
  return (
    <>
      <h1>상태관리 라이브러리 만들기</h1>
      <Counter />
      <TextEditor />
      <CounterStoreProvider initialState={{ count: 10, text: 'bye' }}>
        <Counter />
        <TextEditor />
        <CounterStoreProvider initialState={{ count: 20, text: 'good' }}>
          <Counter />
          <TextEditor />
        </CounterStoreProvider>
      </CounterStoreProvider>
    </>
  )
}

export default App

먼저 최상단에 존재하는 <Counter>와 <TextEditor>는 <CounterStoreProvider>가 존재하지 않아도 각각 초깃값을 가져오는데 이는 createContext에서 인수를 전달했기 때문이다.

그리고 아래의 <Counter>와 <TextEditor>은 Contex가 가장 가까운 Proivder를 참조하는 것을 이용해 각각 가까운 Provider를 참조하게 된다.

 

🎉얻을 수 있는 효과!

이를 통해서 

스토어를 사용하는 컴포넌트는 해당 상태가 어느 스토어에서 온 상태인지 신경쓰지 않아도 된다.

단지 해당 스토어를 기반으로 어떤 값을 보여줄지만 고민하면 되므로 좀 더 편리하게 코드를 작성할 수 있다.

 

또한 Context와 Provider를 관리하는 부모 컴포넌트의 입장에서는 자신이 자식 컴포넌트에 따라 보여주고 싶은 데이터를 Context로 잘 격리하기만 하면 된다.

이처럼 부모와 자식 컴포넌트의 책임과 역할을 이름이 아닌 명시적인 코드로 나눌 수 있어 코드 작성이 한결 용이해진다.

 

 

📘마무리 하며...

넓은 스코프의 상태관리를 예제를 통해서 알아보았다.

다양한 상태관리 라이브러리가 나오는 요즘, 실제 상태 라이브러리가 어떤 식으로 작동하고 있는지를 알아보거나

직접 상태관리와 렌더링을 일으킬 수 있는 코드를 고민해 본 적은 많지 않을 것이다.

그래도 다양한 예제를 직접 구현함으로써 리액트에서의 상태 관리와 렌더링에 대해 더 넓은 시야를 갖게 될 수 있을 것이다.

728x90
반응형
LIST