이제 마지막 ! context 에 이어 리덕스 툴킷 라이브러리로 전역상태 관리하기!

 

 

📌 Context로 만든 지출 관리 사이트

https://ejunyang.tistory.com/entry/%EB%A6%AC%EC%95%A1%ED%8A%B8-%EA%B0%9C%EC%9D%B8-%EC%A7%80%EC%B6%9C-%EA%B4%80%EB%A6%AC-%EC%82%AC%EC%9D%B4%ED%8A%B8-%EB%A7%B9%EB%93%A4%EA%B8%B0-Context%F0%9F%90%A2

 

[리액트] 개인 지출 관리 사이트 맹들기 - Context🐢

props-drilling에 이어 context로 props를 전달해서 기존 기능들이 정상적으로 작동되게 만들어보자!!🔥   📌 props-drilling으로 만든 지출 관리 사이트https://ejunyang.tistory.com/entry/%EB%A6%AC%EC%95%A1%ED%8A%B8-%EA

ejunyang.tistory.com

 

 

 

컴포넌트

컴포넌트 설명
ExesForm.jsx 지출내역을 입력하는 컴포넌트이다. 메인 페이지 우측 하단 버튼을 누르면 모달처럼 뜨도록 구현했다.
MonthList.jsx 월별 버튼 컴포넌트이다. 해당 월을 누를 때마다 다른 스타일링이 들어간다.
ExesList.jsx 월별 버튼을 눌렀을 때 하단에 해당 월의 지출내역이 나타나는 지출 내역 리스트 컴포넌트이다.
ExesItem.jsx 각 지출 내역의 컴포넌트이다.

 

 


 

 

리덕스 설치 및 생성

📂 redux >  📄 configStore.js

yarn add @reduxjs/toolkit react-redux

 

우선 만들어야할 리듀서 함수는 지출 내역과 월버튼이다. 매번 월별 지출내역을 초기값으로 가져와야하기때문에 전역에서 상태관리하는게 좋겠다고 생각이 들었다. 

import { configureStore } from "@reduxjs/toolkit";
import expense from "../slices/expense";
import month from "../slices/month";

const store = configureStore({
  reducer: {
    expense: expense,
    month: month,
  },
});

export default store;

 

 

 

📂 redux >  📂 slices > 📄 expense.js

 

지출 내역 초기값을 세팅해주고, 추가, 수정, 삭제 리듀서 함수를 만들어보자

.

inisialState는 초기값을 나타낸다. 로컬스토리지에 expenseList 변수명으로 저장되어있는 데이터를 객체화하여 가져온다. 데이터가 있으면 객체화해서 가지고오고, 없다면 빈 객체로 반환한다. 

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

const initialState = {
  expenseList: localStorage.getItem("expenseList")
    ? JSON.parse(localStorage.getItem("expenseList"))
    : {},
};

const expense = createSlice({
  name: "expense",
  initialState: initialState,
  reducers: {},
});

export const { addExpense } = expense.actions;
export default expense.reducer;

 

 

추가하는 리듀서함수를 만들어보자. 리듀서 함수는 두가지 매개변수를 갖는다. state(기존 객체) 와 action(업데이트할 객체) . 이 두가지 매개변수를 가지고 상태를 업데이트하고 최신 상태를 store에 제공해준다.

내 코드는 우선 월별 지출내역이 없다면 빈배열로 반환하도록 되어있고, 새로운 지출내역을 월별 지출내역에 push 해준다. 그리고 로컬스토리지에 저장. 여기서 내가 받아야할 action.apyload는 선택한 월과 새로운 지출내역이다. 프롭스 드릴링이나 context 를 사용했다면 추가 후에 setState를 사용해 상태를 업데이트해줘야하는데 리듀서 함수 자체가 setState 역할을 하기 때문에 할 필요가 없다.

addExpense: (state, action) => {
      const { selectedMonth, newExes } = action.payload;
      if (!state.expenseList[selectedMonth]) {
        state.expenseList[selectedMonth] = [];
      }
      state.expenseList[selectedMonth].push(newExes);
      localStorage.setItem("expenseList", JSON.stringify(state.expenseList));
      return state;
    },

 

 

📂 redux >  📂 slices > 📄 month.js

선택한 월의 지출내역을 볼 수 있도록 month slice도 생성해주었다. 

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

let selectedMonth = JSON.parse(localStorage.getItem("selectedMonth"));

if (!selectedMonth) {
  selectedMonth = new Date().getMonth();
}

const initialState = {
  selectedMonth: selectedMonth,
};

const monthSlice = createSlice({
  name: "month",
  initialState: initialState,
  reducers: {
    setSelectedMonth: (state, action) => {
      state.selectedMonth = action.payload;
    },
  },
});

export const { setSelectedMonth } = monthSlice.actions;
export default monthSlice.reducer;

 

리듀서 함수 생성 후 다른 컴포넌트에서 사용할 수 있게 export 해줘야한다는 것을 잊지 말자.

export const { addExpense } = expense.actions;
export default expense.reducer;
export const { setSelectedMonth } = monthSlice.actions;
export default monthSlice.reducer;

 

 

 

 

 

 

Home.jsx

이제 추가하는 함수가 필요한 컴포넌트에 적용해보도록하자!

useSelector 로 구독할 대상을 설정한다. 구독해야할 대상은 지출내역 데이터가 담긴 expense slice와 어떤 월을 선택했는지 확인할 수 있는 month slice 두개이다. 그리고 useDispatch로 리듀서 함수를 호출해준다. 대신 액션객체를 담아서! 액션 타입에 따라 상태를 어떻게 업데이트해주는지 리듀서 함수에서 정의해주기 때문이다.

import { useDispatch, useSelector } from "react-redux";
import { addExpense } from "../redux/slices/expense";

const Home = () => {
  const dispatch = useDispatch();
  const exes = useSelector((state) => state.expense.expenseList);
  const selectedMonth = useSelector((state) => state.month.selectedMonth);

  console.log("selectedMonth1", selectedMonth);
  const handleMonthSelect = (idx) => {
    dispatch(setSelectedMonth(idx));
  };

  const onInsert = useCallback((date, item, amount, desc) => {
    dispatch(
      addExpense({
        selectedMonth: selectedMonth,
        newExes: {
          id: uuidv4(),
          date,
          item,
          amount,
          desc,
          month: selectedMonth,
        },
      })
    );
  });

 

 

새로운 지출내역을 담아주는 onInsert 함수에 dispatch(리듀서함수(액션객체))를 사용해보자.

우리가 받아야할 액션.페이로드는 seletedMonth와 newExes 였다. 선택한 월과 새로운 지출내역 데이터를 담는 객체를 넣어준다. 리듀서 함수 안 액션 객체 속 우리가 받아야하는 값 페이로드가 객체로 들어가는 이유는 리듀서 함수 정의할 때 객체로 받았기 때문!

const { selectedMonth, newExes } = action.payload;
const onInsert = useCallback((date, item, amount, desc) => {
    dispatch(
      addExpense({
        selectedMonth: selectedMonth,
        newExes: {
          id: uuidv4(),
          date,
          item,
          amount,
          desc,
          month: selectedMonth,
        },
      })
    );

 

dispatch로 선택된 월을 변경한다. 

 const handleMonthSelect = (idx) => {
    dispatch(setSelectedMonth(idx));
  };

 

 

 

 

 

Detail.jsx

📂 redux >  📂 slices > 📄 expense.js 

위 파일에서 삭제, 수정 리듀서 함수를 생성해준다. 삭제는 id 를 액션 페이로드로 받고, 수정은 수정된 데이터를 액션 페이로드로 받아줬다. 초기값이 객체형태였기 때문에 filter(),map() 메서드를 사용하지 못해서 for-in 문으로 객체 month를 키값으로 순회해서 배열로 가지고 왔다. 각 월 지출 내역 데이터를 expensesOfMonth 변수에 할당해주고 filter를 사용해 삭제할 데이터를 제외한 나머지 데이터들을 반환하도록 해주고 반환된 데이터를 기존 월별 데이터에 재할당해주었다. 그리고 로컬스토리지에 저장.

removeExpense: (state, action) => {
      const { id } = action.payload;
      for (const month in state.expenseList) {
        const expensesOfMonth = state.expenseList[month];
        const filteredExpenses = expensesOfMonth.filter(
          (expense) => expense.id !== id
        );
        state.expenseList[month] = filteredExpenses;
      }
      localStorage.setItem("expenseList", JSON.stringify(state.expenseList));
    },
  
  export const { addExpense, removeExpense, modifyExpense } = expense.actions;

 

수정도 마찬가지고 for-in문을 사용해주었고, map메서드를 사용해 수정하려는 데이터의 id가 기존에 있던 데이터와 같다면 기존 데이터를 수정된 데이터로 덮어준다.

 modifyExpense: (state, action) => {
      const { modifiedData } = action.payload;
      for (const month in state.expenseList) {
        const expensesOfMonth = state.expenseList[month];
        const modifyExpense = expensesOfMonth.map((ex) =>
          ex.id === modifiedData.id ? { ...ex, ...modifiedData } : ex
        );
        console.log("modifyExpense", modifyExpense);
        state.expenseList[month] = modifyExpense;
      }
      localStorage.setItem("expenseList", JSON.stringify(state.expenseList));
    },
  },

 

 

 

useSelector로 월 지출내역을 가지고와서 상세페이지에 노출시키려고 보니 오류가 발생했다. 확인해보니 내가 가지고온 데이터는 전체 월 지출내역. 상세페이지에서 필요한 데이터는 해당 월의 지출내역. 완전히 다른 데이터를 가지고 온 것이다. 그래서 가지고온 데이터를 내가 선택한 월과 같은 데이터만 가지고 오도록 find 메서드를 사용해주었다.

import { useDispatch, useSelector } from "react-redux";
import { removeExpense, modifyExpense } from "../redux/slices/expense";

const Detail = () => {
  const { id } = useParams();
  const navigate = useNavigate();
  const dispatch = useDispatch();
  const allExpenses = useSelector((state) => state.expense.expenseList);

  let exe = null;
  for (const month in allExpenses) {
    const expensesOfMonth = allExpenses[month];
    exe = expensesOfMonth.find((expense) => expense.id === id);
    if (exe) break;
  }

  const dateRef = useRef(exe.date);
  const itemRef = useRef(exe.item);
  const descRef = useRef(exe.desc);
  const amountRef = useRef(exe.amount);

  const onRemove = useCallback(() => {
    if (confirm("정말 삭제하시겠습니까?") == true) {
      dispatch(removeExpense({ id }));
      navigate("/");
    } else {
      return false;
    }
  });

  const onModify = useCallback(() => {
    dispatch(
      modifyExpense({
        modifiedData: {
          id: exe.id,
          date: dateRef.current.value,
          item: itemRef.current.value,
          amount: +amountRef.current.value,
          desc: descRef.current.value,
        },
      })
    );

    //navigate(-1);
  });

 

 

길고 길었던 코드가 짧아졌다. 프롭스 드릴링 이슈를 해결하는것 뿐만 아니라 코드도 간결하게 보여줄 수 있는 것 같다. 

const onRemove = useCallback(() => {
    if (confirm("정말 삭제하시겠습니까?") == true) {
      dispatch(removeExpense({ id }));
      navigate("/");
    } else {
      return false;
    }
  });

  const onModify = useCallback(() => {
    dispatch(
      modifyExpense({
        modifiedData: {
          id: exe.id,
          date: dateRef.current.value,
          item: itemRef.current.value,
          amount: +amountRef.current.value,
          desc: descRef.current.value,
        },
      })
    );

    //navigate(-1);
  });

 

Redux❓

리덕스는 자바스크립트 앱의 상태를 관리하기 위한 전역 상태 관리 라이브러리이다. 

 

 

Redux를 사용하는 이유❓

리덕스를 사용하면 상태값을, 컴포넌트에 종속시키지 않고, 상태 관리를 컴포넌트의 바깥에서 관리 할 수 있게 된다.

 

 

👾 useState의 불편함

- 컴포넌트에서 컴포넌트로 State를 보내기위해서는 반드시 부-모 관계여야한다.

- State를 전달해주기만 하는 List 컴포넌트에도 불필요하게 State 값을 내려주어야한다.

- 자식 컴포넌트에서 부모 컴포넌트로 값을 보낼 수 없다.

- 자식이 많아진다면 상태 관리가 매우 복잡해진다

- 상태를 관리하는 상위 컴포넌트에서 계속 내려 받아야한다. (Props drilling 이슈)

 

✨ 리덕스를 통해 해결가능한 점

1. 전역 상태 저장소 제공

2. Props drilling 이슈 해결

 

 

 

 

 

 

 

✏️ 내가 이해한대로 그리는 리덕스 구조

간단한 로직이다. view에서 상태 변화가 생겼다면 dispatch 메서드에 액션 객체(데이터)를 담아 reducer를 호출해준다. dispatch에 전달인자로 담긴 action을 reducer에서는 매개변수로 받아 실행한다. 그리고 액션의 타입에 따라 상태를 업데이트 해주고 store에 변경된 최신 상태를 제공한다. 마지막으로 useSelector 를 통해 store에서 초기/최신 상태를 받아 UI 를 렌더링한다.

 

📌 Action : { type, payload } 상태 변화를 일으키는 객체이다. type은 필수 속성이고 payload는 선택 속성이다. 추가적인 데이터를 받을 때 사용한다. 
📌 Dispatch 액션 객체를 전달인자로 받아 리듀서를 호출하는 함수이다. 리듀서 함수는 dispatch를 통해 액션 객체를 매개변수로 받는다.
📌 Reducer 기존 상태와 액션 객체를 매개변수로 가지며 이 두가지를 참조하여 상태를 변경한다. 액션 객체에서 action.type에 따라 상태를 어떻게 변경할지 정의하는 함수이다. 상태 변경 후 최신 상태를 Store에게 제공한다.

 

 

 

1. 스토어 생성 및 스토어 구독

 

B 컴포넌트에서 일어난 상태 변화가 G 컴포넌트에 반영된다고 가정해보자.

스토어를 생성한 후에 G 컴포넌트에서 useSelector 로 store을 구독하고 state의 최신상태를 받는다.

useSelector((state) => state.구독대상)

 

 

 

 

 

2. 상태 변경 요청

 

B 컴포넌트에서 상태를 업데이트할 일이 생긴다. 이 때 dispatch라는 함수에 액션을 담아 store에 상태 업데이트를 요청한다.

dispatch 함수에 담은 액션을 전달인자라고 하는데 이는 리듀서를 호출할 때 필요한 매개변수로 사용한다.

 

 

 

 

 

3. 리듀서로 상태 업데이트

B 컴포넌트로부터 전달받은 action 객체의 타입에 따라 상태를 업테이트 해줘야한다. 이때 액션의 타입에 따라 어떻게 상태를 업데이트 해주는지 정의하는 함수를 리듀서 함수라고 한다. 아래 두가지 매개변수를 참조하여, 새로운 상태 객체를 만들어서 최신 상태를 반환한다.

 

📌 reducer 함수는 두가지 매개변수를 갖는다.

- state : 현재 상태

- action : 액션 객체

 

 

 

 

4. 구독 알림

위 과정을 다 거치면 컴포넌트는 새로운 상태를 받게되고, 이에 따라 컴포넌트는 리렌더링한다.

 

 

 

 

 

 

 

Redux 설치

yarn add redux react-redux

 

 

파일 생성

 

📂 redux : 리덕스와 관련된 코드를 모두 모아 놓을 폴더 

     📂 config : 리덕스 설정과 관련된 파일들을 놓을 폴더

        📂 cofigStore.js : 중앙 state 관리소

     📂 module : State들의 그룹 폴더

        📂 text.js :하나의 모듈

 

 

Store 생성

combineReducers은 여러 개의 독립적인 reducer의 반환 값을 하나의 상태 객체로 만든다.

import { combineReducers, createStore } from "@reduxjs/toolkit";
import texts from "../module/text";

// 1. combineReducers로 리듀서 조합
const rootReducer = combineReducers({ texts });

 

 

조합한 리듀서로 스토어를 생성한다.

import { combineReducers, createStore } from "@reduxjs/toolkit";
import texts from "../module/text";

// 1. combineReducers로 리듀서 조합
const rootReducer = combineReducers({ texts });

// 2. 조합한 리듀서들로 스토어 생성
const store = createStore(rootReducer);

export default store;

 

 

 

 

리듀서 생성

texts라는 리듀서에 onAddText라는 액션 타입을 만들어준다. 이때 texts(리듀서)는 state와 action 두개의 매개변수를 갖는다. 두개의 매개변수를 참조하여 상태를 업데이트한다. 아래 코드를 보면 초기값은 로컬스토리지에 저장되어있는 데이터가 있다면 불러온 데이터가 초기값이 되고 없다면 빈 배열이 초기값이 된다. 

 

액션 타입 onAddText인 경우 처리는 다음과 같다. 기존 상태(...state)를 복사하여 변경하고, text 속성에 새로운 배열을 할당한다. 기존 text 배열과 새로운 항목(action.payload)을 합쳐서 새로운 배열을 생성한다.

// 초기값 설정
const initialState = {
  text: localStorage.getItem("texts") || [],
};
console.log(initialState);

const texts = (state = initialState, action) => {
  switch (action.type) {
    case "onAddText":
      return {
        ...state,
        text: [...state.text, action.payload],
      };
    default:
      return state;
  }
};

export default texts;

 

 

 

 

리스트를 추가할 때 input창에 입력하는 값이 출력되어야한다. 리듀서 함수에서 만든 액션 타입을 dispatch 메서드를 사용해서 스토어에 상태 변경을 요청하는 방법은 아래와 같다. 

import React, { useState } from "react";
import { useDispatch } from "react-redux";

function TextInput() {
  const dispatch = useDispatch();
  const [inputValue, setInputValue] = useState("");

  const handleSubmit = (e) => {
    e.preventDefault();
    if (inputValue.trim()) {
      dispatch({ type: "onAddText", payload: inputValue });
      setInputValue("");
    }
  };

  return (
   <form onSubmit={handleSubmit}>
      <input
        type="text"
        value={inputValue}
        onChange={handleChange}
        placeholder="Enter text"
      />
      <button type="submit">Add</button>
    </form>
  );
}

export default TextInput;

+ Recent posts