input 태그에 유저 입력을 받고, 식물 데이터 중 검색한 단어와 일부 일치하는 이름의 식물만을 필터링 하려 한다.
컴포넌트 구조는 아래와 같다.
src/
├── Pages
│ ├── Home.tsx
│ └── DetailPage.tsx
├── Components
├── Header
│ ├── Nav.tsx
│ └── Input.tsx
├── Content.tsx
│ └── GardenList.tsx
└── ...
덧붙여, 라우팅 구조는 아래와 같다.
<Router>
<Header /> //모든 페이지에 동일하게 렌더링
<Routes>
<Route path="/" element={<Home />} />
<Route path="/detail/:name" element={<DetailPage />} />
</Routes>
</Router>
말하자면,
input에 들어간 입력값(State)을 최상위 컴포넌트(App) 까지 끌어올리고, 그 아래 요소들에 최소 3번 이상의 뎁스를 거쳐 전달해야한다는
것이다.
Props Drilling이 발생하고 말았다.
이것을 해결하기 위해 상태관리 라이브러리를 사용한다.
Redux, Recoil, Zustand 등 여러 라이브러리가 있는데, 현 시점에서 사용량이 가장 많은 Redux를 써보기로 했다.
Redux 어떻게 쓰는걸까?
- Redux 설치하기
- Store 생성하기
- 2-1 초기상태 정의
- 2-2 Action type 정의 및 Action 객체 생성
- 2-3 Reducer 정의
- 2-4 Store 내보내기
- 2-5 기타
- Provider로 프로젝트와 스토어 연결하기
- useDispatch로 상태관리 하려던 부분 연결하기
1. Redux 설치하기
우선 해당 라이브러리를 설치한다.
// npm 사용 시
npm install redux react-redux
// yarn 사용 시
yarn add redux react-redux
설치가 되었다면 package.json 파일에 의존성이 추가되었는지 확인한다.
"dependencies": {
"redux": "^4.2.1",
"react-redux": "^8.0.5",
}
알아보니 원래 redux만 설치하는 것이 표준이었던 모양인데... 호환이 잘 되지 않는다.
최근에는 불변성 관리 등을 보다 효율적으로 해낼 수 있도록 업데이트된 redux toolkit 사용을 권장한다고 한다.
다음 글에서는 redux toolkit를 토대로 이 글을 다시 작성하고, 이 둘의 차이점을 짚어보려 한다.
2-1. Store 생성하기 - 초기상태 정의
src 폴더 하위에 redux 폴더를 생성하고, store.ts 파일을 추가한다.
모든 파일을 종류별로 묶어 폴더에 각각 저장하는 것이 좋지만, 이 글 작성의 편의를 위해 우선 store.ts에 모두 쓰도록 하겠다.
우선 나의 프로젝트의 경우, 관리하고자 하는 상태는 2가지이다.
- input 입력값
- 렌더링될 gardenData 배열
이를 바탕으로 초기값(initialState)을 할당해준다.
// 초기상태
const initialState = {
gardenData: [],
searchQuery: "",
};
2-2. Store 생성하기 - Action type 정의 및 Action 객체 생성
Redux에서 action 객체란?
상태 변경에 필요한 정보를 담은 상자라고 생각하면 쉽다.
이 상자 안에는 type, payload 두가지 상자가 있다.
// action 객체 기본형 예제
// action type 선언
const EXAMPLE_ACTION_OBJECT = "EXAMPLE_ACTION_OBJECT" // 이 둘의 철자가 반드시 같아야 한다
// action 생성
export const exampleActionObject = (payload: any) => ({ // payload의 타입은 임시로 선언했다
type: EXAMPLE_ACTION_OBJECT,
payload,
});
이로써 action 객체를 성공적으로 생성한 것이다!
세부 설명은 접은 글을 참고하기 바란다.
더보기
그래서 대체 Action 객체가 뭐냐고!
필자는 type과 payload의 역할이 무엇인지부터 이해하는데 며칠 헤매고 말았다.
type : 무슨 작업을 해야하는지 작성하는 칸
payload : 그 작업에 필요한 데이터를 넣는 칸
아무래도 type이 Typescript의 "type"과 헷갈린 듯하다.
그 타입이 아니라... "어떤 함수랑 연결할래?" 라는 뜻이다.
또한 첫 줄에 action type 선언부를 보면, 왼쪽 상수의 이름과 오른쪽 문자열 값이 동일해야한다.
이런 형태 또한 의아했다... '왜 그냥 string이라고 하지 않고?'
당연하다...
연결할 함수의 이름을 달아두는 것이다.
type이 아니라 name이라고 이해하면 빠르겠다.
풀어 쓰자면-
type: action을 식별하는 역할. Reducer에서 이 type을 기준으로 상태를 변경한다.
payload: action을 수행하는 데에 전달되는 매개변수
Action 객체에 대한 예제를 하나 덧붙인다.
const SET_SEARCH_QUERY = "SET_SEARCH_QUERY" // 액션 타입 선언
const setSearchQuery = (example) => ({
type: "SET_SEARCH_QUERY", // 액션 타입 기재
payload: example // 업데이트할 데이터를 payload로 전달
});
const action = setSearchQuery("장미")
console.log(action)
// 출력: { type: "SET_SEARCH_QUERY", payload: "장미" }
거듭 강조하자면 액션 타입 선언부의 좌항과 우항의 철자는 반드시 같아야 한다.
왜 상수로 선언할까?
그냥 문자열로 지정한다면 오타로 인한 오류 추적이 어려워진다.
이것을 상수로 선언함으로써 오타 가능성을 낮추고, 안정성을 높일 수 있다.
그렇다면 action 객체와 action type의 이름 또한 같아야 할까?
그렇지는 않다.
다만 코드 가독성을 높이기 위해 네이밍을 통일할 뿐이다.
이제 내 프로젝트에 맞춰 2가지 상태에 대한 action 객체를 생성해준다.
// 액션 타입 선언
const SET_GARDEN_DATA = "SET_GARDEN_DATA"; // 렌더링할 데이터 상태
const SET_SEARCH_QUERY = "SET_SEARCH_QUERY"; // 인풋 데이터 상태
// 액션 객체 생성
export const setGardenData = (payload: string) => ({
type: SET_GARDEN_DATA,
payload,
});
export const setSearchQuery = (payload: string) => ({
type: SET_SEARCH_QUERY,
payload,
});
2-3. Store 생성하기 - Reducer 정의
Reducer는 Redux에서 상태를 변경하는 순수 함수이다.
쉽게 말해 action 객체를 일종의 명령으로 받고, state를 새로운 값으로 받아 새로운 상태를 반환한다.
아래는 reducer 작성 예제이다.
// reducer 작성 방법
const reducer = (state, action) => {
switch (action.type) {
case "ACTION_TYPE_1": // 액션 타입에 따라 다른 동작 수행
// 상태 업데이트 로직
return { ...state, updatedKey: newValue };
case "ACTION_TYPE_2":
// 상태 업데이트 로직
return { ...state, anotherKey: anotherValue };
default:
// 아무 액션도 매칭되지 않으면 기존 상태 반환
return state;
}
};
단순히 switch 문을 통해 각 타입(실행할 함수)에 따라 분기처리를 하면 된다.
주의할 점은, Reducer는 불변성을 지키기 때문에 상태를 직접 변경하지 않으므로, 배열을 업데이트 하는 경우 스프레드 연산자를 통해 배열을 복사한 뒤 업데이트한다.
// 리듀서 정의
const gardenReducer = (state = initialState, action: any) => { // 초기값 적용, action의 타입은 임시
switch (action.type) {
case SET_GARDEN_DATA: // 렌더링 데이터 배열 상태의 경우
return {
...state, // 기존 상태를 복사한 후
gardenData: action.payload, // gardenData의 상태를 업데이트
};
case SET_SEARCH_QUERY: // 인풋 상태가 바뀐 경우
return {
...state,
searchQuery: action.payload, // searchQuery의 상태를 업데이트
};
default:
return state;
}
};
2-4. Store 생성하기 - Store 내보내기
createStore을 임포트하여 생성한 리듀서를 담아 내보낸다.
import { createStore } from "redux";
const store = createStore(gardenReducer);
export default store;
2-5. Store 생성하기 - 기타
더보기
이 부분은 optional하니 스킵해도 좋다.
reducer 함수가 여러개인 경우
combineReducers 를 임포트하여 합쳐준다.
현재 프로젝트의 경우 리듀서가 하나만 필요하지만, 두개라고 가정한다면 아래와 같다.
import { combineReducers } from "redux";
const userReducer = (state = { isLoggedIn: false }, action) => { // 로그인 여부 상태관리를 가정
switch (action.type) {
case "LOGIN":
return { ...state, isLoggedIn: true };
default:
return state;
}
};
const gardenReducer = (state = { gardenData: [] }, action) => {
switch (action.type) {
case "SET_GARDEN_DATA":
return { ...state, gardenData: action.payload };
default:
return state;
}
};
const rootReducer = combineReducers({ // 두개의 리듀서를 하나의 객체로 모아주기
user: userReducer, // user 상태 관리
garden: gardenReducer, // garden 상태 관리
});
// 이렇게 작성하면 전체 상태 트리는 아래와 같다.
{
user: { isLoggedIn: false },
garden: { gardenData: [] },
}
Store 내보내기 수정
createStore를 임포트하고, 리듀서를 담아준다.
import { createStore } from "redux";
const store = createStore(rootReducer);
export default store; // 완성된 스토어 내보내기
RootState 타입 정의
이대로 내보내고 사용하려 하면 RootState의 타입을 지정하라는 타입스크립트 에러가 나타난다.
따라서 아래 코드를 추가해줌으로써 해결한다.
export type RootState = ReturnType<typeof store.getState>;
아래는 GPT의 도움을 받은 부분으로, 추후 내용을 추가할 수 있다.
RootState란?
- Redux 스토어의 전체 상태 트리의 타입을 정의합니다.
- store.getState()의 반환 타입을 TypeScript가 추론하여, 전체 상태 구조를 RootState로 정의합니다.
ReturnType이란?
- TypeScript의 유틸리티 타입으로, 함수가 반환하는 값의 타입을 추론합니다.typeof store.getState의 반환 타입이 전체 - 상태 구조이므로 이를 RootState로 사용합니다.
const userReducer = (state = { isLoggedIn: false }, action) => {
switch (action.type) {
case "LOGIN":
return { ...state, isLoggedIn: true };
default:
return state;
}
};
const gardenReducer = (state = { gardenData: [] }, action) => {
switch (action.type) {
case "SET_GARDEN_DATA":
return { ...state, gardenData: action.payload };
default:
return state;
}
};
const rootReducer = combineReducers({
user: userReducer, // user 상태 관리
garden: gardenReducer, // garden 상태 관리
});
최종 코드
이로써 아래와 같이 store.ts 가 완성되었다.
그러나 이것은 현재 지원되지 않을 수 있는 구버전의 방식이기 때문에, 다음 글에서 신버전으로 다시 써보도록 하겠다.
(그렇다고 개념 자체가 달라지는 것은 아니라, 오히려 간소화시키는 작업이기 때문에 이 내용을 숙지하고 넘어가는 것이 좋다)
import { createStore, combineReducers } from "redux";
// 초기상태
const initialState = {
gardenData: [],
searchQuery: "",
};
// 액션 타입
const SET_GARDEN_DATA = "SET_GARDEN_DATA";
const SET_SEARCH_QUERY = "SET_SEARCH_QUERY";
// 액션 생성
export const setGardenData = (payload: string) => ({
type: SET_GARDEN_DATA,
payload,
});
export const setSearchQuery = (payload: string) => ({
type: SET_SEARCH_QUERY,
payload,
});
//리듀서 정의
const gardenReducer = (state = initialState, action: any) => {
switch (action.type) {
case SET_GARDEN_DATA:
return {
...state, // 기존 상태를 복사한 후
gardenData: action.payload, // gardenData 상태 업데이트
};
case SET_SEARCH_QUERY:
return {
...state,
searchQuery: action.payload, // searchQuery 상태 업데이트
};
default:
return state;
}
};
// 여러 리듀서를 관리할 수 있도록 combineReducers 사용
const rootReducer = combineReducers({
garden: gardenReducer,
});
const store = createStore(rootReducer);
export default store;
// RootState 타입 정의
export type RootState = ReturnType<typeof store.getState>;