5. 리액트와 상태 관리 라이브러리
상태 관리는 왜 필요하고 어떻게 작동하는가
5.1 상태 관리는 왜 필요한가?
✨ 웹 내부에서 상태란 어떠한 의미를 지니고 지속적으로 변경되는 값을 의미
- 상태는 웹의 발전에 따라 다양해지고 있으며 이를 효율적으로 관리하는 방법을 고민해야 한다.
5.1.1 리액트 상태 관리의 역사
🔖 Flux 패턴의 등장
Context API가 나오기 전까지는 이렇다할 상태관리에 관련된 기능이 없었다.웹 앱이 발전함에 따라 복잡성이 증가했고, 이러한 문제의 원인이 '양방향 바인딩' 으로 귀결되었다.
❗뷰(HTML)과 모델(JS) 서로가 서로를 변경 가능하여 데이터의 복잡성과 관리 난이도가 증가
- 때문에 이를 완화하고자 단방향 데이터의 흐름을 제안하였다. (Flux 패턴)

액션(action) : 작업 처리 액션 및 액션에 포함할 데이터, 각각 정의하여 디스패치로 보냄
디스패처(dispatcher) : 액션을 스토어로 보내는 역할, 콜백 함수 형태로 액션 관련 요소를 스토어로 보냄
스토어(store) : 실제 상태에 따른 값과 상태 변경 메서드 보유, 액션 타입에 따라 변경 여부 정의
뷰(view) : 컴포넌트에 해당하는 부분, 스토어의 데이터를 통해 화면 렌더링 및 자체적인 액션 호출을 통해 상태 업데이트 또한 가능
☝️ 물론 사용자 입력에 따른 상태 갱신과 코드의 양 증가로 수고로운 점은 존재
✋ BUT!!
👍 데이터가 한방향으로 흐르므로 추적이 쉽고 코드 이해가 용이
🔖 시장 지배자 리덕스의 등장
- Flux가 리액트를 씹어먹을 무렵 얍삽하게 Flux 구조 표현 및 Elm 아키텍쳐 도입으로 시장 선점
// 액션은 상태 변경을 위한 정보를 담고 있는 객체
export const increment = () => {
return {
type: "INCREMENT",
};
};
export const decrement = () => {
return {
type: "DECREMENT",
};
};
const initialState = {
count: 0,
};
// 리듀서는 액션을 기반으로 새로운 상태를 반환하는 함수
const counterReducer = (state = initialState, action) => {
switch (action.type) {
case "INCREMENT":
return {
...state,
count: state.count + 1,
};
case "DECREMENT":
return {
...state,
count: state.count - 1,
};
default:
return state;
}
};
// 스토어는 리덕스 상태 트리를 담고 있는 객체
import { createStore } from "redux";
import counterReducer from "./reducer";
const store = createStore(counterReducer);
export default store;
모델(상태)와 뷰(HTML), 업데이트(모델 수정)의 콜라보
Elm은 데이터 흐름을 세가지로 분류 및 단방향으로 강제해 상태를 안정적으로 관리 유도글로벌 상태 객체를 통해
props drilling를 해결하고 간단한 상태 접근이 가능해 많은 사랑을 받았다. (현재진행형)
❗하지만 하나의 상태 관리만으로도 수행해야 할 작업이 너무 많았다.
🔖 Context API, useContext
- 너무 복잡했던 리덕스를 대체하기 위해 리액트에서 자체적으로 개발한 상태 주입용 API
import React, { createContext, useState } from "react";
// Context 생성
export const ThemeContext = createContext();
// Context Provider 정의
export const ThemeProvider = ({ children }) => {
const [theme, setTheme] = useState("light");
const toggleTheme = () => {
setTheme((prevTheme) => (prevTheme === "light" ? "dark" : "light"));
};
return <ThemeContext.Provider value={{ theme, toggleTheme }}>{children}</ThemeContext.Provider>;
};
// Provider 사용
const App = () => {
return (
<ThemeProvider>
<div>
<ThemeToggler />
<ThemeDisplay />
</div>
</ThemeProvider>
);
};
// Context 사용
const ThemeToggler = () => {
const { theme, toggleTheme } = useContext(ThemeContext);
return <button onClick={toggleTheme}>Toggle to {theme === "light" ? "dark" : "light"} theme</button>;
};
props drilling을 효과적으로 방지하지만 결국 상태 관리에 목적을 두지 않기 때문에 사용 시 유의해야한다.
🔖 훅의 탄생과 React Query, SWR
훅과 함수형 컴포넌트의 간단한 상태값 관리에 따라 상태 관리 라이브러리 또한 변화했다.
이 중 가장 각광받은 것이 바로
React Query와SWR이다.
const { data, isLoading, isError } = useQuery({
queryKey: ["test"],
queryFn: getTestData,
});
// 로딩중
if (isLoading) {
return <div>Loading...</div>;
}
// 에러
if (isError) {
return <div>Error fetching data</div>;
}
const postWriteData = async () => {
// 생략
};
const mutation = useMutation({
mutationFn: postWriteData,
onMutate() {
console.log("mutation 실행 전");
},
onSuccess(data) {
console.log(data);
},
onError(err) {
console.error(err);
},
onSettled() {
console.log("finally 처럼 마지막에 실행");
},
});
React Query의 경우,useQuery와useMutation을 통해 데이터 페칭과 더불어 해당 상태값을 간결하고 효율적으로 관리
🔖 Recoil, Zustand, Jotai, Valtio 에 이르기까지
- 훅을 활용해 상태를 가져오고 관리하는 다양한 라이브러리
최근 각광받는 Zustand에 대해 알아보자
- 표면적으로 인기있는 이유는 API가 간단하며, 효율적으로 리렌더링하고 미들웨어를 지원한다.
// src/store.js
import create from "zustand";
const useStore = create((set) => ({
count: 0,
increase: () => set((state) => ({ count: state.count + 1 })),
decrease: () => set((state) => ({ count: state.count - 1 })),
}));
export default useStore;
// src/App.js
import React from 'react';
import useStore from './store';
const Counter = () => {
const { count, increase, decrease } = useStore();
return (
<div>
<h1>{count}</h1>
<button onClick={increase}>Increase</button>
<button onClick={decrease}>Decrease</button>
</div>
);
};
const App = () => {
return (
<div>
<h1>Zustand Counter Example</h1>
<Counter />
</div>
);
};
export default App;
5.1.2 책 정리 + 주관적인 정리
🔖 책 정리
- 상태 관리 라이브러리를 잘 뜯어본다면 더욱 성장할 수 있는 계기가 될 것이다.
🏷️ 주관적인 정리
단순히 상태 관리 라이브러리에 대해 막연히 알고만 있었는데, 이번 계기로 조금 더 자세하게 알 수 있어서 좋았다.
함수형 컴포넌트와 훅에 등장에 따라 변화한 상태 관리 라이브러리의 동작 원리와 로직에 대한 흥미가 생겼다.
5.2 리액트 훅으로 시작하는 상태 관리
✨ 함수 컴포넌트의 패러다임과 더불어 내부 상태 관리에 용이한 라이브러리를 알아보자
5.2.1 가장 기본적인 방법 : useState와 useReducer
useState를 활용한 갖가지 커스텀 훅을 통해 내부(지역) 상태 관리를 쉽고 안정적으로 할 수 있게 되었다.
import { useState } from "react";
const useCounter = (initialValue = 0) => {
const [count, setCount] = useState(initialValue);
const increment = () => setCount((prevCount) => prevCount + 1);
const decrement = () => setCount((prevCount) => prevCount - 1);
const reset = () => setCount(initialValue);
return { count, increment, decrement, reset };
};
export default useCounter;
각각의 컴포넌트에서 이를 사용하면 컴포넌트마다 각각 다른 상태를 사용할 수 밖에 없다.
부모 컴포넌트에서 한번 사용하고 이를 각각의
props로 내려주는 방식을 채택하면 야매적으로 전역 상태 관리 느낌을 낼 수 있다.
5.2.2 지역 상태의 한계를 벗어나보자 : useState의 상태를 바깥으로 분리하기
🔖 useState가 리액트 클로저가 아닌 완전히 다른 곳에서 초기화 및 관리된다면?
단순히 외부에 정의해놓고 사용한다면, 리액트의 가장 기본적인 리렌더링 원칙을 지키지 않아 리렌더링이 실행되지 않는다.
외부에 정의한 것을 내부에서 또 상태값으로 정의하는 방법도 있겠지만, 애초에 의미가 없을 듯 하다.
✨ 위 조건을 다 충족시키는 새로운 상태 관리 코드
store로 정의한 상태값과 변경을 알리는callback, 이를 등록할subscribe함수가 필요
// 외부 상태 관리 시스템
// createStore: 초기 상태를 매개변수로 받아 상태 관리 시스템을 생성
const createStore = (initialState) => {
// state: 현재 상태를 저장
let state = initialState;
// listeners: 상태 변경 시 호출할 콜백 함수들을 저장하는 Set
const listeners = new Set();
// getState: 현재 상태를 반환
const getState = () => state;
// setState: 상태를 새로운 값으로 업데이트하고, 모든 구독된 콜백 함수를 호출
const setState = (newState) => {
state = newState;
listeners.forEach((listener) => listener(state));
};
// subscribe: 상태 변경 시 호출할 콜백 함수를 추가, 이 함수는 구독을 해제하는 함수를 반환
const subscribe = (listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
};
return {
getState,
setState,
subscribe,
};
};
// 사용 예시
// 초기 상태
const initialState = { count: 0 };
// store 생성
const store = createStore(initialState);
// 상태 변경 시 호출할 콜백 함수
const handleStateChange = (newState) => {
console.log("상태가 변경되었습니다:", newState);
};
// 상태 변경 구독
const unsubscribe = store.subscribe(handleStateChange);
// 상태 변경
store.setState({ count: store.getState().count + 1 });
// 구독 해제
unsubscribe();
// 상태 변경 (구독 해제 후에는 콜백이 호출되지 않음)
store.setState({ count: store.getState().count + 1 });
🏷️ 사용 예시
initialState: 초기 상태를 정의
store: 상태 관리 시스템을 생성
handleStateChange: 상태 변경 시 호출할 콜백 함수를 정의
store.subscribe: 상태 변경을 구독
store.setState: 상태를 변경
unsubscribe: 상태 변경 구독을 해제
🔖 Store를 사용해 컴포넌트 렌더링을 유도할 커스텀 훅
const useStore = (store) => {
const [state, setState] = useState(store.getState());
useEffect(() => {
const unsubscribe = store.subscribe((newState) => {
setState(newState);
});
return () => {
unsubscribe();
};
}, [store]);
return state;
};
🏷️ 사용 예시
인수로 사용할
store받기useState를 통해 스토어 값을 초기값으로 상태값 생성useEffect를 통해subscribe등록 ->store값이 변경될 때마다 구독된 함수를 실행하여state의 값 변경 보장클린업 함수로
unsubscribe를 등록하여callback이 계속 쌓이는 현상 방지
❗스토어의 구조가 객체값이라면?!
객체의 일부만 변경되더라도 객체 주소가 변경되므로 리렌더링이 계속 일어난다.
selector를 정의하여 가져오고 싶은 값을 선택하도록 유도할 수 있다.
5.2.3 useState와 Context를 동시에 사용해보기
앞에서 만든 훅과 스토어는 반드시 하나의 스토어당 한가지 값만 가지게 된다.
여러개 만들 수도 있겠지만 좀 귀찮다..😂
👍 스토어와 context를 함께 사용하여 상태관리 로직을 만들어보자
import React, { createContext, useContext, useState } from "react";
// 스토어 생성 함수
const createStore = (initialState) => {
const StoreContext = createContext();
const StoreProvider = ({ children }) => {
const [state, setState] = useState(initialState);
const updateState = (newState) => {
setState((prevState) => ({ ...prevState, ...newState }));
};
return <StoreContext.Provider value={{ state, updateState }}>{children}</StoreContext.Provider>;
};
const useStore = () => {
const context = useContext(StoreContext);
if (!context) {
throw new Error("useStore must be used within a StoreProvider");
}
return context;
};
return { StoreProvider, useStore };
};
// 초기 상태 정의
const initialState = {
count: 0,
};
// 스토어 생성
const { StoreProvider, useStore } = createStore(initialState);
export { StoreProvider, useStore };
createStore함수를 통해 스토어를 생성하고createStore함수는 초기 상태를 받고, 해당 상태를 관리하는StoreProvider와useStore훅을 반환useStore훅은 컨텍스트에서 상태와 상태 업데이트 함수를 가져와, 컴포넌트에서 이를 쉽게 사용StoreProvider: 애플리케이션의 상위 컴포넌트에서 상태를 제공하는 컨텍스트 공급자이며 모든 하위 컴포넌트는 이 공급자 안에서 상태에 접근 가능useStore: 스토어 컨텍스트에서 상태와 상태 업데이트 함수를 반환하는 커스텀 훅이며 컴포넌트에서 이 훅을 사용하여 상태를 가져오고 업데이트 가능
import React from "react";
import { StoreProvider, useStore } from "./store";
const Counter = () => {
const { state, updateState } = useStore();
const increment = () => updateState({ count: state.count + 1 });
const decrement = () => updateState({ count: state.count - 1 });
return (
<div>
<p>Count: {state.count}</p>
<button onClick={increment}>Increment</button>
<button onClick={decrement}>Decrement</button>
</div>
);
};
const App = () => (
<StoreProvider>
<Counter />
</StoreProvider>
);
export default App;
StoreProvider는 상태와 상태를 업데이트하는updateState함수를 제공하고 애플리케이션의 상위 컴포넌트에서 이를 감싸는 형태로 사용
5.2.4 상태 관리 라이브러리 Recoil, Jotai, Justand 살펴보기
🔖 최근 들어 각광받고 있는 세가지 라이브러리에 대해 알아보자
🏷️ 페이스북이 만든 상태 관리 라이브러리 Recoil
훅의 개념으로 상태 관리를 시작한 최초의 라이브러리 중 하나
상태 개념인
Atom을 선보임Recoil의 핵심 구조인
RecoilRoot,atom,useRecoilValue,useRecoilState에 대해 알아보자
// RecoilRoot
function App() {
return (
// RecoilRoot로 최상위 컴포넌트를 감싸야 한다.
<RecoilRoot>
<MyComponent />
</RecoilRoot>
);
}
Recoil에서 생성되는 상태값을 저장하기 위한 스토어를 생성한다.useStoreRef를 통해 상태값을 저장하는 조상 스토어의 존재를 확인한다.스토어는 크게 스토어 아이디를 가져오는
getNextStoreID(), 값을 가져오는getState, 값을 수정하는replaceState로 이루어진다.앞서 구현한 스토어와 비슷하게 로직이 짜여져있다. (상태 전파값에 의존하여 콜백 실행 등)
// atom
import { atom } from "recoil";
const countState = atom({
key: "countState", // 각 atom의 고유한 식별자
default: 0, // 초기 상태 값
});
atom은Recoil의 기본 단위이며, 상태의 일부를 나타내고 이를 읽고 쓸 수 있는 단일 조각이다.key와default값을 설정하여 생성한다.
// useRecoilValue
import { useRecoilValue } from "recoil";
import { countState } from "./state"; // countState atom을 정의한 파일을 임포트
function CounterDisplay() {
const count = useRecoilValue(countState); // countState의 현재 값을 읽음
return <div>Count: {count}</div>;
}
- 주어진
atom이나selector의 값을 읽기 위해 사용한다.
// useRecoilState
import { useRecoilState } from "recoil";
import { countState } from "./state";
function Counter() {
const [count, setCount] = useRecoilState(countState); // countState의 현재 값을 읽고, 업데이트 함수를 제공
const increment = () => setCount(count + 1);
return (
<div>
<p>Count: {count}</p>
<button onClick={increment}>Increment</button>
</div>
);
}
- 조금더
useState와 비슷한 훅이며, 상태 값과 상태를 업데이트하는 함수를 반환한다.
👍 간단한 사용법
<RecoiRoot />를 선언해 스토어를 만든다.atom단위값을 등록한다.atom의key를 바탕으로 상태 변화를 구독하고 상태를 읽거나 변경하여 최신atom값을 가져와 컴포넌트를 리렌더링한다.
🏷️ 조타이~
기본적으로
atom에 영감을 받아 만들어진 라이브러리기본적으로 상향식 접근법을 취하며, 이는
Context의 불필요 리렌더링을 방지하고자 의도한 것메모이제이션과 같은 최적화를 거치지 않아도 리렌더링이 발생하지 않도록 설계
// atom
import { atom } from "jotai";
// 아톰 정의
export const countAtom = atom(0);
- 조금 더 간단하게,
Jotai의atom은key를 넘겨주지 않아도 되며,config라는 객체를 반환하여init,read,write를 통해 사용한다.
// CounterDisplay.js
import React from "react";
import { useAtomValue } from "jotai";
import { countAtom } from "./state";
const CounterDisplay = () => {
const count = useAtomValue(countAtom);
return <p>Count: {count}</p>;
};
export default CounterDisplay;
useAtomValue를 통해Provider없이 기본 스토어를 루트에 생성해 값을 읽어들이거나,atom자체의 값을 키로서 저장해 매핑하고 사용하기도 한다.또한 리렌더링을 위해 사용하는
rerenderIfChanged를 통해atom의 값이 변경되는 경우useAtomValue를 사용하는 쪽에선 언제든 최신 렌더링이 가능하다.
import React from "react";
import { useAtom } from "jotai";
import { countAtom } from "./state";
const Counter = () => {
const [count, setCount] = useAtom(countAtom);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
<button onClick={() => setCount(count - 1)}>Decrement</button>
</div>
);
};
export default Counter;
useState와 동일한 형태의 배열을 반환하고 동작도 비슷하다.
👍 간단한 사용법
atom이라는 API를 사용하는데, 컴포넌트 외부에서도 선언할 수 있다.또한 함수를 인수를 받을 수 있어 다른
atom으로부터atom을 만들 수 있다.useAtom,useAtomValue를 사용해 용도에 맞는 다양한 작업을 할 수 있다.
🏷️ 작고 빠르며 확장에도 유연한 Zustand
- 리덕스에 영감을 받아 만들어졌으며, 하나의 스토어를 중앙 집중형으로 활용해 내부에서 상태를 관리한다.
import create from "zustand";
// Zustand 스토어 생성
const useStore = create((set) => ({
count: 0,
// 상태 변경 함수 : partial과 replace로 나누어짐
setState: {
partial: (newState) => set((state) => ({ ...state, ...newState })),
replace: (newState) => set(() => newState),
},
// 구독 및 리스너 관리
subscribe: (listener) => {
const listeners = new Set();
listeners.add(listener);
// 상태 변경 시 리스너 호출
const unsubscribe = () => {
listeners.delete(listener);
};
return unsubscribe;
},
// 리스너 초기화 함수
destroy: () => {
// ...
},
}));
const store = useStore;
const createStore = () => ({
getState: () => store.getState(),
setState: store.setState,
subscribe: store.subscribe,
destroy: store.destroy,
});
export default createStore;
기본적으로 방식은 비슷하나,
partial과 replace` 로 나누어져 값의 일부분이나 값 자체 변경을 원하는 대로 할 수 있도록 구현했다.getState나listener등을 보면 상태값 변경 후 리렌더링 필요 컴포넌트에 값을 전파하기 위해 만들어졌음을 알 수 있다.리액트 프레임워크와 독립적으로 구성되어 바닐라 자바스크립트에서도 사용 가능하다.
👍 간단한 사용법
import React from "react";
import createStore from "./store";
const store = createStore();
const Counter = () => {
const { count } = store.getState();
const increase = () => store.setState.partial({ count: count + 1 });
const decrease = () => store.setState.partial({ count: count - 1 });
// 상태 변경 구독
React.useEffect(() => {
const unsubscribe = store.subscribe(() => {
// 상태가 변경되면 다시 렌더링
console.log("State changed:", store.getState());
});
return () => unsubscribe();
}, []);
return (
<div>
<p>Count: {count}</p>
<button onClick={increase}>Increase</button>
<button onClick={decrease}>Decrease</button>
</div>
);
};
createStore를 통해 스토어를 만들고, 컴포넌트 내부에서 사용 가능한 훅을 받는다.이를 통해
getter,setter모두에 접근해 사용 가능하다.리액트 컴포넌트 외부에 store를 만드는 것도 가능하다. (리액트 코드와 연관없기 때문)
d.ts이슈 없이 자연스럽게 타입스크립트 사용도 가능하며 미들웨어도 지원한다.개인적으로
Zustand는 익숙하기도 하고 마음에 들어 따로 공부하고 싶다.
5.2.5 책 정리 + 주관적인 정리
🔖 책 정리
리액트에서 리렌더링을 일으키는 방식은 한정되어 있기 때문에 결국 라이브러리의 개발 로직이 조금씩 다르더라도 궁극적인 목적은 같다.
빠르게 변화하는 리액트 생태계에 발빠르게 대처하는 라이브러리를 선택하는 것이 좋다.
🏷️ 주관적인 정리
상태 관리 라이브러리 자체에 익숙하지 않다보니 겁나 생소하기도 하고 이해하기 힘들었다. (사실 지금도 이해는 잘 안된다.)
최근 프로젝트 진행하면서 상태 관리 라이브러리에 대한 것도 팀원들과 많이 얘기했었는데, 이번 기회에 사용해보면 좋을 듯 하다.
'Front-End Study > 모던 리액트 딥다이브 스터디' 카테고리의 다른 글
| 모던 리액트 딥다이브 - 12회차 [7-1 ~ 7-7] (0) | 2024.07.11 |
|---|---|
| 모던 리액트 딥다이브 - 11회차 [6-1, 6-2, 6-3, 6-4] (0) | 2024.07.11 |
| 모던 리액트 딥다이브 - 9회차 [4-3] (1) | 2024.06.25 |
| 모던 리액트 딥다이브 - 8회차 [4-1, 4-2] (0) | 2024.06.25 |
| 모던 리액트 딥다이브 - 7회차 [3-1, 3-2, 3-3] (2) | 2024.06.19 |