[React] Deep Dive 모던 리액트(16) 훅을 통한 전역상태관리 구현
FrontEnd/Deep Dive

[React] Deep Dive 모던 리액트(16) 훅을 통한 전역상태관리 구현

728x90

 

 

이전 글에서 소개했듯이 정말 오랜 기간동안 리액트 애플리케이션의 상태관리는 리덕스에 의존했다. 심지어 일부 개발자들은 리액트 + 리액트를 하나의 프레임워크 정도로 생각할 정도였다.

하지만 지금은 Context API , useReducer , useState의 등장으로 리덕스 외의 다른 상태관리 라이브러리를 선택하는 경우가 많아졌다고 한다. ( 리덕스가 조금 수고스럽긴 하다)

 

useState

해당 훅의 등장으로 리액트에서는 여러 컴포넌트에 걸쳐 동일한 인터페이스의 상태를 생성하고 관리할 수 있다.

아래와 같은 훅이 있다고 생각해보자.

const useCounter = () => {
  const [counter, setCounter] = React.useState(0);

  function increase() {
    setCounter((prev) => prev + 1);
  }
  return { counter, increase };
};

 

 

위와 같이 훅을 만들면 함수형 컴포넌트 어디서든 해당 상태를 접근할 수 있다.

 

 

import React from "react";

const useCounter = () => {
  const [counter, setCounter] = React.useState(0);

  function increase() {
    setCounter((prev) => prev + 1);
  }
  return { counter, increase };
};

function Counter1() {
  const { counter, increase } = useCounter();

  return (
    <>
      <h3>Counter1 : {counter}</h3>
      <button onClick={increase}>+</button>
    </>
  );
}

function Counter2() {
  const { counter, increase } = useCounter();

  return (
    <>
      <h3>Counter2 : {counter}</h3>
      <button onClick={increase}>+</button>
    </>
  );
}

 

 

이렇게 만든 사용자 정의 훅을 활용하면 각자의 counter 변수를 관리하며 중복되는 로직 없이 코드를 짜는 것이 가능하다.

 

useReducer

useReducer역시 useState와 비슷하기 때문에 같은 역할이 가능하다. 

useState와 useReducer는 서로가 서로를 구현 가능하다.

 

 

import { useReducer } from "react";

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

function useStateWithUseReducer<T>(initialState: T) {
  const [state, dispatch] = useReducer(
    (prev: T, action: Initializer<T>) =>
      typeof action === "function" ? action(prev) : action,
    initialState
  );

  return [state, dispatch];
}

 

 

import { useCallback, useState } from "react";

function useReducerWithUseState(reducer, initialState, initializer) {
  const [state, setState] = useState(
    initializer ? () => initializer(initialState) : initialState
  );

  const dispatch = useCallback(
    (action) => setState((prev) => reducer(prev, action)),
    [reducer]
  );

  return [state, dispatch];
}

 

실제 useReducer를 타입스크립트로 작성하려면 다양한 오버로딩이 필요하다. 아래를 참조해보자.

https://github.com/DefinitelyTyped/DefinitelyTyped/blob/0c7345cb4238b8f4a460645705aef94f2f76de0f/types/react/v16/index.d.ts#L942-L1021

 

 

 

하지만 useState와 useReducer로 컴포넌트 내부의 상태의 모든 필요성 & 문제를 해결해줄 순 없다. 훅은 사용자 컴포넌트별로 초기화 되기때문에 전역상태를 관리하기에는 적합하지 않다. 이를 해결하기 위해 보통 가장 먼저 떠오르는 방법은 아래와 같다.

 

 

import React from "react";

const useCounter = () => {
  const [counter, setCounter] = React.useState(0);

  function increase() {
    setCounter((prev) => prev + 1);
  }
  return { counter, increase };
};

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

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

export default function Parent() {
  const { counter, increase } = useCounter();

  return (
    <>
      <Counter1 counter={counter} increase={increase} />
      <Counter2 counter={counter} increase={increase} />
    </>
  );
}

 

 

상위 컴포넌트의 Parent 컴포넌트를 만든 후 자식 컴포넌트들에게 props로 전달해주면 된다. 하지만 해당 방식 props를 계속 전파해야 하는 구조기에 조금은 번거롭다.

 

 

 

useState 상태를 분리하기

 

결국 useState의 문제 & 한계는 명확하다. useState는 리액트가 만든 클로저 내부에서 관리되기 때문에 컴포넌트마다 지역 상태로 관리된다. 만약 이 useState가 리액트 클로저가 아닌 다른 JS 실행문맥 어디선가 초기화된다면 어떻게 될까?

 

아래와 같은 코드를 한번 생각해 보자.

 

export type State = { counter: number };

let state: State = { counter: 0 };

export function get(): State {
  return state;
}

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

export function set<T>(nextState: Initializer<T>) {
  state = typeof nextState === "function" ? nextState(state) : nextState;
}

function Counter() {
  const state = get();

  function handleClick() {
    set((prev: State) => ({ counter: prev.counter + 1 }));
  }

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

 

 

아쉽게도 위와 같은 코드는 동작하지 않는다. 만약 해당 코드를 console.log()로 살펴보면 set을 통한 업데이트나 get을 통해 변수값에 조회하는 것도 가능하단 것을 알 수 있다.

하지만 위 컴포넌트는 리렌더링 되지 않는다. 리렌더링이 일어나지 않기때문에 useState,useReducer의 두번째 인자가 필요하다.

 

import { useState } from "react";

export type State = { counter: number };

let state: State = { counter: 0 };

export function get(): State {
  return state;
}

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

export function set<T>(nextState: Initializer<T>) {
  state = typeof nextState === "function" ? nextState(state) : nextState;
}

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

  function handleClick() {
    set((prev: State) => {
      const newState = { counter: prev.counter + 1 };
      //setCount가 호출
      setCount(newState);
      return newState;
    });
  }

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

 

 

위 방식처럼 setCount를 억지로 넣는다면 렌더링이 가능할 것이다. 하지만 딱봐도 해당 방식은 외부에 상태가 있음에도 내부에 useState를 추가해야 하는 불필요한 구조를 가지고 있음을 알 수 있다.

 

심지어 이런 방식은 문제점 또한 있다. 결국 useState는 자기 컴포넌트만 리렌더링 시키므로 다른 컴포넌트에서 해당 state를 접근한다면 리렌더링이 되지 않고 + 버튼을 한번 눌러야만 리렌더링이 일어날 것이다.

 

 

 

즉 이런 전역상태를 관리하기 위해서는 아래 세 조건을 만족해야 한다.

 

1. 컴포넌트 외부 어딘가에 상태를 두어야 함.
2. 상태변화가 감지될때마다 해당 상태를 쓰는 컴포넌트의 리렌더링이 일어나야 함.
3. 객체 값이 변해도 내가 감지하지 않는 값이 변하지 않으면 리렌더링이 일어나면 안됨
{ a:1,b:2} 와같은 객체에서 내가 b만 사용하고 있다면 a의 변화에 리렌더링이 일어나면 안된다.

 

 

위 조건을 만족하는 상태관리 코드를 한번 만들어보자. 이 이름은 store로 정의해 볼 것이다.

 

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

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;

    callbacks.forEach((callback) => callback());
    return state;
  };

  const subscribe = (callback: () => void) => () => {
    callbacks.add(callback);
    return () => {
      callbacks.delete(callback);
    };
  };

  return { get, set, subscribe };
};

 

 

 

즉 createStore는 자신이 관리해야 하는 상태를 내부 State가 아닌 내부 변수로 가진 후  get , set 함수로 해당 변수를 관리하고 있다. 해당 createStore를 사용하려면 만들어진 store의 값을 참조하고 이 값에 따라 렌더링을 유도할 훅이 필요하다.

 

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;
};

 

 

 

위 방식으로 훅을 만들면 store를 인수로 받은 후 state를 통해 값을 업데이트할 수도 있고, store의 값이 변경되더라도 이를 감지하여 리렌더링 할 수 있다.

 

또한 useEffect()의 클린업 함수를 통해 callback이 계속 쌓이는 현상을 방지했다.

 

해당 코드를 실행하는 최종 예제는 아래와 같다.

 

import React, { useEffect, useState } from "react";

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

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> => {
  //초기값 or 게으른 초기화 함수
  let state =
    typeof initialState !== "function" ? initialState : initialState();

  //콜백함수를 Set으로 선언(중복X)
  const callbacks = new Set<() => void>();

  const get = () => state;

  //값을 설정하고 callback을 등록
  //콜백이 무한히 중첩 안되도록 방지되어 있음
  const set = (nextState: State | ((prev: State) => State)) => {
    state =
      typeof nextState === "function"
        ? (nextState as (prev: State) => State)(state)
        : nextState;

    callbacks.forEach((callback) => callback());
    return state;
  };

  const subscribe = (callback: () => void) => {
    callbacks.add(callback);
    return () => {
      callbacks.delete(callback);
    };
  };

  //외부에서 사용할 수 있게 반환
  return { get, set, subscribe };
};

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;
};

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

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

  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(store);

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

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

export default function MyComponent() {
  return (
    <>
      <Counter1 />
      <Counter2 />
    </>
  );
}

 

위와 같이 각각 store의 상태가 변경됨과 동시에 두 컴포넌트가 모두 정상적으로 리렌더링 되는 것을 확인할 수 있다. 물론 이 useStore도 완벽한 것은 아니다. 스토어의 구조가 객체인 경우 store의 값이 변경되면 모든 컴포넌트가 리렌더링 되므로 방금 말했던 컴포넌트가 쓰는 부분만 변경되는지를 검사할 수 없다.

 

 

 

이건 useStoreSelector 훅을 만들어서 감시할 값을 설정해주는 방식으로 해결할 수 있다.

import React, { ChangeEvent, useCallback, useEffect, useState } from "react";

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

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> => {
  //초기값 or 게으른 초기화 함수
  let state =
    typeof initialState !== "function" ? initialState : initialState();

  //콜백함수를 Set으로 선언(중복X)
  const callbacks = new Set<() => void>();

  const get = () => state;

  //값을 설정하고 callback을 등록
  //콜백이 무한히 중첩 안되도록 방지되어 있음
  const set = (nextState: State | ((prev: State) => State)) => {
    state =
      typeof nextState === "function"
        ? (nextState as (prev: State) => State)(state)
        : nextState;

    callbacks.forEach((callback) => callback());
    return state;
  };

  const subscribe = (callback: () => void) => {
    callbacks.add(callback);
    return () => {
      callbacks.delete(callback);
    };
  };

  //외부에서 사용할 수 있게 반환
  return { get, set, subscribe };
};

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;
};

export const useStoreSelector = <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;
};

const store = createStore({ count: 0, text: "hi" });

function Counter() {
  const counter = useStoreSelector(
    store,
    useCallback((state) => state.count, [])
  );

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

  useEffect(() => console.log("Counter Rendered"));

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

const textSelector = (state: ReturnType<typeof store.get>) => state.text;

function TextEditor() {
  const text = useStoreSelector(store, textSelector);
  useEffect(() => console.log("TextEditor Rendered"));

  function handleChange(e: ChangeEvent<HTMLInputElement>) {
    store.set((prev) => ({ ...prev, text: e.target.value }));
  }

  return (
    <>
      <h3>{text}</h3>
      <input value={text} onChange={handleChange}></input>
    </>
  );
}

export default function MyComponent() {
  return (
    <>
      <Counter />
      <TextEditor />
    </>
  );
}

 

 

 

위와같이 렌더링이 각각 변경점만 감지해서 되게 된다.

 

페이스북 팀에서는 위 방식의 Hook을 이미 구현해두었으며 이게 useSubscription이다.

 

import React, { useMemo } from "react";

function NewCounter() {
  const subscription = useMemo(
    () => ({
      getCurrentValue: () => store.get(),
      subscribe: (callback: () => void) => {
        const unsubscribe = store.subscribe(callback);
        return () => unsubscribe();
      },
    }),
    []
  );

  const value = useSubscription(subscription);

  return <>{JSON.stringify(value)}</>;
}

 

 

해당훅은 방금 구현한 예제와 똑같이 작동한다.단 리액트 18버전에 들어오며 useSubscription 훅 자체가 useSyncExternalStore로 제작 되어 있다. 이는 나중에 알아보자.

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

728x90