리액트의 전역 상태 관리

rO_Or·2024년 12월 4일

간단한 공부

목록 보기
11/12

Context API

Context API는 리액트 v16.3에 추가됐다.
이걸 활용하면 전역 상태 관리를 쉽게 사용할 수 있다.

문제점

하지만 이걸 전역 상태 관리로 쓰기에는 적합하지 않는 경우가 있다.

성능 문제

Context API 값을 구독한 모든 컴포넌트가 리렌더링되는 문제점이 있다.
만약 Context API로 상태를 전달했다면, 불필요한 컴포넌트까지 리렌더링이 돼서 성능이 하락할 수도 있다.

한 가지 상태만 관리 가능

앱이 커지면 관리해야될 상태가 많아지게 되는데
그러면 Context API를 사용하는 개수가 늘어나게 돼서 관리하기 어렵게 된다.

<AuthContext.Provider>
  <ThemeContext.Provider>
  	<DataContext.Provider>
  		// ...
  	</DataContext.Provider>
  </ThemeContext.Provider>
</AuthContext.Provider>

다른 훅과 같이 써야 업데이트 가능

Context API는 단순히 상태를 전달하는 역할만 해서
useReducersetState를 같이 전달해야 상태를 업데이트할 수 있다.

전역 상태 관리 라이브러리를 쓰는 이유

Props Drilling 문제를 해결할 수 있다.

상태 관리 라이브러리들은 리렌더링 최적화 기능을 제공하는 경우가 있어서 불필요한 렌더링을 방지해준다.

상태의 변화를 추적하고 디버깅하기가 수월해진다.

Redux

ReduxFlux 패턴을 따르는 자바스크립트에서 대표적인 상태 관리 라이브러리이다.

Flux 패턴은 단방향으로 데이터가 흐르는 패턴이다.
단방향으로 데이터가 이동하므로, 데이터 예측과 디버깅을 쉽게 할 수 있다.

Flux

Action

"무슨 일이 일어나고 있는지?"
액션은 어떤 일이 있는지 정의한다.

{
  type: "ADD", // 어떤 일인지
  payload: {id: 1, count: 1} // 데이터
}

Dispatch

디스패치는 액션을 스토어에 전달한다.
앱에서 어떤 일이 생겨야하는지 스토어에 알려주는 역할이다.

dispatch({type: "ADD", payload:{id: 1, count: 1}});

스토어

스토어는 앱의 전체 상태를 저장 및 관리하는 곳이다.
디스패치를 통해 액션을 받으면, 업데이트하고 새로운 상태를 만든다.

리듀서

리듀서는 스토어가 상태를 업데이트할 때 사용하는 함수다.

function addCount(state = [], action) {
  switch(action.type) {
    case "ADD":
      // 로직...
      return [...state, action.payload];
    default:
      return state;
  }
}

Subscribe

스토어는 상태가 바뀌면 모든 구독자들에게 알려준다.
React에서는 이를 이용해서 리렌더링을 하게 된다.

장점

스토어 한 곳에서 상태를 괸리하게 되므로 편리하고 복잡한 상태 관리가 쉬워진다.
정해진 규칙에 따라 상태를 변경하므로, 변화를 예측하기 쉽다.
리덕스에서 제공하는 개발 도구를 사용해서 디버깅이 수월해진다.
여러 미들웨어 기능이 있어, 비동기 작업이나 로깅 등 기능 추가가 쉽다.
redux-toolkit를 사용하면 리덕스를 쉽게 사용할 수 있다.

단점

'액션', '리듀서', '스토어' 같은 개념을 알아둬야한다.

아주 작은 기능이라고 해도, 리덕스로 구현하려면
액션, 리듀서 등, 미리 작성해야 하는 코드량이 많다.

추가 도구 없이(미들웨어 등) 기본 리덕스만으로는 한계가 있다.
redux-thunk: 비동기 작업 처리
redux-saga: 복잡한 비동기 흐림 관리
redux-logger: 상태 변경 로그

리덕스와 다른 미들웨어까지 학습해야 하므로 러닝커브가 높아진다.

redux-toolkit 사용해보기

redux-toolkit를 쓰면 그냥 리덕스를 썼을 때보다
더 쉽게 상태 관리를 할 수 있다.

npm install redux redux-react @reduxjs/toolkit
먼저 위와 같이 총 3개를 설치해줘야 한다.

store 작성

스토어는 앱의 전체 상태를 저장 및 관리하는 곳이다.

// redux/store.ts
import { configureStore } from "@reduxjs/toolkit";

// 스토어 생성
const store = configureStore({
    reducer: {
      // reducer를 작성하는 곳.
    },
});

export default store;

그 다음 앱을 프로바이더로 감싸서 어느 곳에서든 스토어를 사용할 수 있게 해줘야 한다.

// main.tsx

import { Provider } from 'react-redux'
import store from './redux/store.ts'

createRoot(document.getElementById('root')!).render(
    <Provider store={store}>
      <App />
    </Provider>
);

slice 작성

sliceredux-toolkit에서 상태 관리의 기본 단위이다.
statereducer action을 한 곳에 모아둔 곳이다.

// redux/countSlice.tsx
const initialState = {
  count: 0,
}

먼저 상태의 초기 값을 선언해 준다.
그 다음 createSlice로 slice를 생성하면 된다.

// ...
import { createSlice } from "@reduxjs/toolkit";

export const countSlice = createSlice({
  name: 'count', // slice의 이름을 지정.
  initialState, // 상태의 초기 값
  reducers: {
    // 리듀서를 작성
    add: (state, action) => {
      // action에는 type과 payload가 들어있다.
      return state + action.payload;
    },
    sub: (state, action) => {
      return state - action.payload;
    },
  },
});

작성한 다음 이것을 다른 곳에서 쓸 수 있게 export 해주면 된다.

// ...
const reducer = countSlice.reducer;
export const countActions = countSlice.actions;
export default reducer;

작성이 끝난다면 이 리듀서를 스토어에 등록해줘야 한다.

// redux/store.ts

import { configureStore } from "@reduxjs/toolkit";
import countReducer from './countSlice';

const store = configureStore({
    reducer: {
        counter: countReducer, // 추가된 부분
    },
})

사용하기

실제 사용할 컴포넌트에서 사용려면 useDispatch useSelector를 사용하면 된다.

// counter.tsx
import { useDispatch, useSelector } from 'react-redux';
import { countActions } from '@/redux/countSlice';

const Counter = () => {
  	const { add, sub } = countActions;
    const dispatch = useDispatch();
  	const count = useSelector((state) => state.count);

    const handleAdd = () => {
        dispatch(add(10));
    }
    const handleSub = () => {
        dispatch(sub(10));
    }
    return (
        <div>
            <h2>{counter}</h2>
            <div>
                <button onClick={handleAdd}>++</button>
                <button onClick={handleSub}>--</button>
            </div>
        </div>
    )
};

export default Counter;

Jotai

Recoil이라는 상태 관리 라이브러리에 영감을 받아 만들었다고 한다.
Atomic 방식으로 상태 관리를 한다고 한다.

작은 원자들을 하나씩 쌓아서 큰 형태로 만들어 나가는 느낌(bottom-up)으로 앱이 만들어진다고 한다.

리액트의 useState와 비슷하게 사용할 수 있어서 러닝 커브가 낮다.

설치

npm install jotai

상태 만들기

import { atom, useAtom } from "jotai";

const counter = atom<number>(0);

const App = () => {
  const [count, setCount] = useAtom(counter);
  
  return(
    <div>
      {count}<br />
      <button onClick={()=>setCount(prev => prev+1)}></button>
    </div>
  )
}

export default App;

마치 useState를 쓰듯이 사용하면 된다.

읽기 전용, 쓰기 전용

jotai는 읽기 전용으로도 쓰기 전용으로도 쓸 수 있다.

// ...
const count = useAtomValue(counter); // 읽기
const setCount = useSetAtom(counter); // 쓰기
// ...

좀 더 복잡한 작업을 해서 상태를 변경시킬 수도 있다.

const counterAction = atom((get) => get(counter), (get, set) => {
  const value = get(counter);
  const newValue = value + 1;
  set(counter, newValue);
});

이 action도 읽기, 쓰기만 할 수 있다.

// ...
const counterWithString = atom((get) => get(counter) + ' 번');
const onlyCounterWrite = atom(null, (get, set, newValue) => {
  set(counter, newValue);
});
// ...

비동기 atom도 지원해주는데
redux였다면 redux-thunk 같은 라이브러리를 설치해서 사용하는 경우가 많지만, jotai는 내부적으로 지원해준다.

// ...
export const asyncAtom = atom(async (get) => {
    await new Promise((resolve) => setTimeout(resolve, 5000));
    return get(counter);
});
// ...

이렇게 async로 atom을 만든 다음 사용할 때는
jotai에서 자체적으로 제공하는 loadable를 사용하면 된다.

// ...
  const loadableAtom = loadable(asyncAtom);
  const [value] = useAtom(loadableAtom);
// ...
<div>
  {
      value.state === 'hasError' && <div>ERROR</div>
  }
  {
      value.state === 'loading' && <div>LOADING</div>
  }
  {
      value.state === 'hasData' && <div>{value.data}</div>
  }
</div>

Provider

jotai는 기본적으로 전역으로 상태 관리를 해주지만
따로 분리시켜서 상태를 관리할 수 있다.

동일한 atom을 사용하더라도, Provider로 감싸면 내부의 상태는 독립적으로 작동한다.

import React from 'react';
import { atom, useAtom, Provider } from 'jotai';

const countAtom = atom(0);

const Counter = () => {
  const [count, setCount] = useAtom(countAtom);
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount((c) => c + 1)}>Increment</button>
    </div>
  );
};

const App = () => (
  <div>
    <Provider>
      <h1>Counter 1</h1>
      <Counter />
    </Provider>
    <Provider>
      <h1>Counter 2</h1>
      <Counter />
    </Provider>
  </div>
);

export default App;

Zustand

redux와 비슷하게 스토어를 두고 상태 관리를 한다.

설치

npm i zustand

상태 만들기

스토어를 만든 다음, 초기값과 변경시킬 액션을 선언해 주면 된다.
보통 이름은 use(이름)Store으로 많이 한다.

// store/count.ts
import { create } from 'zustand';

export const useCountStore = create((get, set) => {
	count: 0,
      add: () => {
        const { count } = get();
        set({ count: count + 1 });
      },
});

get은 상태에서 값을 가져올 수 있는 함수이고
set은 상태의 값을 변경시킬 수 있는 함수이다.

get을 사용하지 않고 상태를 변경시킬 수도 있다.

// ...
	sub: () => {
      set(state => ({ count: state.count - 1 }));
    }
// ...

상태 사용하기

사용하고 싶은 컴포넌트에서 스토어 훅을 호출하면 상태와 액션을 얻을 수 있다.
상태가 변경될 경우, 컴포넌트는 다시 렌더링이 된다.

// App.tsx
import { useCountStore } from './store/count';

function App() {
  const count = useCountStore(state => state.count);
  const add = useCountStore(state => state.add);
  const sub = useCountStore(state => state.sub);
  return (
    <div>
      <h2>{count}</h2>
      <button onClick={add}>++</button>
      <button onClick={sub}>--</button>
    </div>
  )
}

콜백 없이도 스토어 훅에서 상태나 액션 등, 스토어 객체를 얻을 수 있지만
컴포넌트에서 사용하지 않는 상태가 변경되어도 렌더링되기 때문에 권장하진 않는다.

// ...
// count외에 min이나 max 등
// 이 컴포넌트에서 사용하지 않아도 min이 변경되면 다시 렌더링 된다.
const { count, add, sub } = useCountStore();

// ...

보통 액션은 많이 작성되기 때문에 이를 분리시켜서 스토어를 생성할 수도 있다.

export const useCountStoreAction = create<{
    count: number,
    actions: {
        add: () => void,
        sub: () => void,
    }
}>(set => ({
    count: 0,
    actions: {
        add: () => set(state => ({ count: state.count + 1 })),
        sub: () => set(state => ({ count: state.count - 1 })),
    },
}));

// --- cut ---
  const count = useCountStoreAction(state => state.count);
  const { add, sub } = useCountStoreAction(state => state.actions);

미들웨어 사용

zustand는 미들웨어를 사용할 수 있게 제공하는데
그 중 하나인 immer를 쓰면 중첩된 객체를 쉽게 변경할 수 있다.

먼저 immer를 설치한다.
npm i immer

그 다음 아래와 같이 액션을 작성하면 된다.

import { immer } from 'zustand/middleware/immer';

export const useUserStore = create(
        immer<UserState & UserActions>(set => ({
            ...userInit,
            actions: {
                signIn: () => {
                    set({
                        user: {
                            email: 'abc@acb.net',
                            displayName: 'abc',
                            isValid: true,
                        }
                    })
                },
                setDisplayName: name => {
                    // immer 사용으로 짧아짐
                    set(state => {
                        if (state.user) {
                            state.user.displayName = name;
                        }
                    })
                }
            }
        }))
)
profile
즐거워지고 싶다.

0개의 댓글