React Native: Redux
Redux 에 대해서 또 복습해보는 시간을 가졌다. 한 줄로 표현하자면, Redux 란 JS 어플리케이션을 위한 상태관리 라이브러리이다.
React 에서는 Props 와 State 라는 것이 사용된다. Props 는 properties 의 줄임말로, 구성 요소가 서로 통신하는 방법이다. Props 는 상위 구성 요소에서 아래쪽으로 흐르고, 해당 값을 변경하려면 자식 관점에서 props 를 변경할 수 있으며 부모는 내부 상태를 변경해야 한다. State 는 부모 컴포넌트에서 자식 컴포넌트로 데이터를 보내는 것이 아닌 하나의 Component 내에서 데이터를 전달할 떄 사용되는 것이다. State 는 Mutable 하고, State 가 변하면 해당 컴포넌트는 자동으로 리렌더링 된다. Redux 는 이러한 State 를 관리해주는 라이브러리이다.
React 는 React 컴포넌트 자신이 개별적으로 상태관리를 하는 반면에 React + Redux 는 상태관리를 하는 전용 장소 (Store) 에서 상태를 관리하고 React 컴포넌트는 그걸 보여주기만 하는 용도로 사용이 되는 것이다.
Action 은 간단한 JS 객체이다. 여기에는 수행하는 작업의 유형을 지정하는 "type" 속성이 있고 선택적으로 Redux 저장소에 일부 데이터를 보내는데 사용되는 "payload" 속성을 가질 수도 있다. 이 Action 을 Reducer 이 받게 되는데, Reducer 은 어플리케이션 상태 변경 사항을 결정하고 업데이트된 상태를 반환하는 함수이다. 인수로 조취를 취하고 Store 내부의 상태를 업데이트 하는 것이다. 이전 State 와 Action 오브젝트를 받은 후에 이후 State 를 return 하는 것이다. Redux Store 은 애플리케이션 전체 상태 트리를 보유한다. 내부 상태를 변경하는 유일한 방법은 해당 상태에 대한 Action 을 전달하는 것 뿐이며, Redux Store 은 클래스가 아니라 몇 가지 Method 가 있는 객체이다.
이러한 Redux 를 활용하여, 우선 미들웨어 없이 Redux 카운터 앱을 만들어보는 실습을 진행하였다.
npx create-react-app redux-counter-app --template typescript
npm install redux --save
프로젝트 폴더를 생성해준 후 src 폴더 내에 reducers 폴더를 만든 후, index.tsx 파일을 아래와 같이 작성해준다.
const counter = (state=0, action: {type:string}) => {
switch (action.type) {
case "INCREMENT":
return state + 1;
case "DECREMENT":
return state - 1;
default:
return state;
}
}
export default counter;
초기 state 를 0 으로 지정하고 있으며 type 이 INCREMENT 일 때는 + 1, DECREMENT 일 때는 - 1 을 하도록 작성되었다.
이 다음에는 앱의 전체 상태 트리를 보유하는 Redux 저장소를 만든다. 이 때는 createStore() 이 사용된다. src 내의 index.tsx 를 아래와 같이 수정해준다.
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
import { createStore } from 'redux';
import counter from './reducers';
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
);
const store = createStore(counter);
root.render(
<React.StrictMode>
<App
value={store.getState()}
onIncrement={()=>store.dispatch({type:"INCREMENT"})}
onDecrement={()=>store.dispatch({type:"DECREMENT"})}
/>
</React.StrictMode>
);
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();
위에서 사용된 getState() 메서드는 애플리케이션의 현재 상태 트리를 반환한다. Store 의 Reducer 이 반환한 마지막 값과 같은 것이다.
그 후 props 를 사용할 수 있도록 App.tsx 를 아래와 같이 수정해줘야 한다.
import React from 'react';
import logo from './logo.svg';
import './App.css';
type Props = {
value: number;
onIncrement: ()=>void;
onDecrement: ()=>void;
}
function App({value, onIncrement, onDecrement}:Props) {
return (
<div className="App">
Clicked: {value} times
<button onClick={onIncrement}>
+
</button>
<button onClick={onDecrement}>
-
</button>
</div>
);
}
export default App;
여기까지 작성한 다음 버튼을 클릭해보아도 value 가 변하지 않는 것을 확인할 수 있다. 이는 subscribe() 메서드를 사용하지 않았기 때문이다. subscribe() 는 change listener 을 추가해준다. 작업이 전달될 때마다 호출되며 상태 트리의 일부가 잠재적으로 변경되었을 수 있다. 그런 다음 getState() 를 호출하여 콜백 내부의 현재 상태 트리를 읽을 수 있게끔 해주는 것이다. src 의 index.tsx 를 아래와 같이 수정해주면 되는 것이다.
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
import { createStore } from 'redux';
import counter from './reducers';
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
);
const store = createStore(counter);
const render = () => root.render(
<React.StrictMode>
<App
value={store.getState()}
onIncrement={()=>store.dispatch({type:"INCREMENT"})}
onDecrement={()=>store.dispatch({type:"DECREMENT"})}
/>
</React.StrictMode>
);
render();
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();
store.subscribe(render);
여기까지 수정하면 버튼이 잘 동작하는 것을 확인할 수 있다.
또, CombineReducer 에 대해서 알아봐야 한다. 위에서는 counter Reducer 만 사용하였지만 여러 개의 Reducer 을 사용할 때는 Root Reducer 을 따로 만들어서 그 아래에 counter reducer 과 새로운 리듀서, Sub Reducer 들을 넣어주어 사용해야 한다. 이러한 Root Reducer 을 만들 때 사용하는 것이 Combine Reducer 이다. 아까의 어플리케이션에 Todo 기능을 추가해보며 이를 실습해보았다.
우선 src 내의 reducers 아래에 todos.tsx, counter.tsx 파일을 새로 만들고 내부의 3 개 파일을 각각 아래와 같이 수정해준다.
counter.tsx
interface Action {
type: string;
}
const counter = (state=0, action: Action) => {
switch (action.type) {
case "INCREMENT":
return state + 1;
case "DECREMENT":
return state - 1;
default:
return state;
}
}
export default counter;
todos.tsx
enum ActionType {
ADD_TODO = "ADD_TODO",
DELETE_TODO = "DELETE_TODO"
}
interface Action {
type: ActionType;
text: string;
}
const todos = (state=[], action: Action) => {
switch (action.type) {
case "ADD_TODO":
return [...state, action.text];
default:
return state;
}
}
export default todos;
index.tsx
import { combineReducers } from 'redux';
import counter from "./counter"
import todos from "./todos"
const rootReducer = combineReducers({
counter,
todos
})
export default rootReducer;
index.tsx 에서 combineReducers 를 사용하여 각종 reducer 들을 묶어주고 있는 것이다. 이제 src 아래의 index.tsx 를 아래와 같이 수정하여 변경사항이 잘 적용되었는지 확인해볼 수 있다.
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
import { createStore } from 'redux';
import rootReducer from './reducers';
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
);
const store = createStore(rootReducer);
store.dispatch({
type: "ADD_TODO",
text: "ADD_TODO TEST"
})
console.log(store.getState())
const render = () => root.render(
<React.StrictMode>
<App
value={store.getState()}
onIncrement={()=>store.dispatch({type:"INCREMENT"})}
onDecrement={()=>store.dispatch({type:"DECREMENT"})}
/>
</React.StrictMode>
);
render();
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();
store.subscribe(render);
createStore 내에 counter 이 아닌 rootReducer 을 다시 넣어주었다. 그 후, store.dispatch 메서드로 console 에 새로 추가한Reducer 이 잘 적용되는지 확인해보았다.
또, Redux Provider 이라는 것에 대해서 알아보았다. <Provider> 구성 요소는 Redux Store 저장소에 액세스해야 하는 모든 중첩 구성 요소에서 Redux Store 저장소를 사용할 수 있도록 한다. React Redux 앱의 모든 React 구성 요소는 저장소에 연결할 수 있으므로 대부분의 응용 프로그램은 전체 앱의 구성 요소 트리가 내부에 있는 최상위 수준에서 <Provider> 을 렌더링한다. 그런 다음 Hooks 및 연결 API 는 React 의 컨텍스트 메커니즘을 통해 제공된 저장소 인스턴스에 액세스할 수 있는 것이라고 한다. 이러한 Provider 을 사용하기 위해서는 react-redux 라이브러리를 또 설치해줘야 한다. npm install react-redux --save 로 설치하면 된다. 아래와 같이 index.tsx 를 수정해주었다.
const render = () => root.render(
<React.StrictMode>
<Provider store={store}>
<App
value={store.getState()}
onIncrement={()=>store.dispatch({type:"INCREMENT"})}
onDecrement={()=>store.dispatch({type:"DECREMENT"})}
/>
</Provider>
</React.StrictMode>
그 후에는, Todo 기능을 추가해주기 위해 App.tsx 를 또 수정해야했다.
import React, { useState } from 'react';
import logo from './logo.svg';
import './App.css';
type Props = {
value: any;
onIncrement: ()=>void;
onDecrement: ()=>void;
}
function App({value, onIncrement, onDecrement}:Props) {
const [todoValue, setTodoValue] = useState("");
const handleChange = (e:React.ChangeEvent<HTMLInputElement>) => {
setTodoValue(e.target.value);
}
const addTodo = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setTodoValue("");
}
return (
<div className="App">
{/* Clicked: {value} times */}
<button onClick={onIncrement}>
+
</button>
<button onClick={onDecrement}>
-
</button>
<form onSubmit={addTodo}>
<input type="text" value={todoValue} onChange={handleChange} />
<input type="submit" />
</form>
</div>
);
}
export default App;
이제 useSelector 과 useDispatch 를 사용해야 했다. React 에 Hooks 가 있듯이 Redux 에도 Hooks 가 있는데 이 둘이 그것들이다. 이 두 개를 이용하여 Provider 로 둘러싸인 Component 에서 Store 에 접근이 가능하다. useSelector 은 Store 의 값을 가져올 수 있고, useDispatch 는 store 에 있는 Dispatch 함수에 접근하는 것이다. App.tsx 를 아래와 같이 다시 한 번 수정해준다.
import React, { useState } from 'react';
import logo from './logo.svg';
import './App.css';
import { useSelector } from 'react-redux/es/exports';
import { RootState } from './reducers';
import { useDispatch } from 'react-redux/es/hooks/useDispatch';
type Props = {
value: any;
onIncrement: ()=>void;
onDecrement: ()=>void;
}
function App({value, onIncrement, onDecrement}:Props) {
const counter = useSelector((state: RootState) => state.counter)
const todos: string[] = useSelector((state: RootState) => state.todos)
const dispatch = useDispatch();
const [todoValue, setTodoValue] = useState("");
const handleChange = (e:React.ChangeEvent<HTMLInputElement>) => {
setTodoValue(e.target.value);
}
const addTodo = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
dispatch({type:"ADD_TODO", text: todoValue})
setTodoValue("");
}
return (
<div className="App">
Clicked: {counter} times
<button onClick={onIncrement}>
+
</button>
<button onClick={onDecrement}>
-
</button>
<form onSubmit={addTodo}>
<input type="text" value={todoValue} onChange={handleChange} />
<input type="submit" />
</form>
<ul>
{todos.map((todo, index) => <li key={index}>{todo}</li>)}
</ul>
</div>
);
}
export default App;
또, 위 코드를 사용하기 위해서는 rootReducer 을 아래와 같이 수정해줄 필요도 있다.
import { combineReducers } from 'redux';
import counter from "./counter"
import todos from "./todos"
const rootReducer = combineReducers({
counter,
todos
})
export default rootReducer;
export type RootState = ReturnType<typeof rootReducer>;
이제 모든 기능이 잘 작동할 것이다! 결과 화면을 확인해보면 아래와 같다.
Reference: