세번째 개인 프로젝트이다. 리액트로는 두번째 개인 프로젝트! 간단한 개인 지출 관리 사이트를 만들어보자!!🐢

이번 과제는 props-drilling > context > redux 순으로 상태 관리를 변경해가며 구현해야한다. 과제를 진행하면서 props-drilling의 단점과 전역 상태 관리 라이브러리의 장점을 이해하도록 하려는 취지 같았다. 결론적으론 전역 상태 라이브러리를 사용하면 props-drilling 이슈를 해결할 수 있다는 것만큼은 제대로 알았다. 

 

 


 

 

와이어 프레임

 

참고로 준 프로젝트 완성본은 맨 위에 입력 폼이 있는데 디자인은 달리 들어가도된다고 해서 따로 디자인하고 작업했다. 아래와 같이 컴포넌트 분리를 했고 react-router-dom으로 페이지 분리를 먼저 해주려고 한다.

 

 

컴포넌트

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

 

 


 

 

 

 

라우터 설정

yarn add react-router-dom

 

 

📂 shared >  📂 Router.jsx

react는 SPA(Single page application)이며 따라서 하나의 프로젝트에 하나의 html만 존재한다. 업데이트 되는 새로운 페이지만 path와 element로 연결하여 컴포넌트를 불러오는 방식이다. 고유한 id 값으로 이동할 수 있도록 설정했다. BrowserRouter로  Router을 감싸는 이유는 브라우저가 깜빡거리지 않고 이동할 수 있도록 해준다.

import React, { useState } from "react";
import { BrowserRouter, Route, Routes } from "react-router-dom";
import Home from "../pages/Home";
import Detail from "../pages/Detail";
import Layout from "./Layout";

const Router = () => {
  return (
    <div>
      <BrowserRouter>
        <Routes>
          <Route path="/" element={<Layout />}>
            <Route index element={<Home />} />
            <Route path="/detail/:id" element={<Detail />} />
          </Route>
        </Routes>
      </BrowserRouter>
    </div>
  );
};
export default Router;

 

 

📂 pages >  📂 Home.jsx

아래처럼 컴포넌트를 배치했다. 프로젝트 필수 사항에 styled-components를 사용하라고해서 모든 컴포넌트에 사용했는데 css 파일로 따로 가지 않고 js파일에서 수정할 수 있고, 조건부 스타일링이 가능한 점은 편리하고 좋지만, 컴포넌트가 많아보이고 헷갈린다.. 장점이 큰 만큼 단점도 만만치않게 큰 것 같다.

 return (
    <>
      <StContainer>
        <MonthList
          key={exes.id}
          exes={exes}
          selectedMonth={selectedMonth}
          handleMonthSelect={handleMonthSelect}
        />
        <ExesList key={exes.id} filteredList={filteredList} setExes={setExes} />
        <ExesForm onInsert={onInsert} exes={exes} />
      </StContainer>
    </>
  );
};

export default Home;

 

 

 

ExesForm.jsx

지출 내역을 입력하는 컴포넌트다. 새로운 지출내역을 추가하는 객체를 만들어준다. 지출내역엔 날짜와 항목 지출내용과 금액이 들어간다. 추가적으로 month키를 넣어주었는데 해당 키로 지출 내역이 해당하는 월을 확인할 수 있다.

✔️ 등록

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

 

월마다 지출내역이 달리 보여야하기 때문에 useState로 상태를 만들어주었다. 아래는 현재 날짜로 임의로 설정해주었는데 이는 로컬 스토리지를 사용하여 마지막으로 선택된 '월'을 저장하고, 해당 페이지가 다시 시작 될 때마다 해당 값을 불러오도록 하려고한다.

const [selectedMonth, setSelectedMonth] = useState(new Date().getMonth());

 

불변성 유지를 위해 기존 지출 내역 객체를 복사해준다. addExes[selectedMonth]는 선택한 월의 지출내역 데이터이다. 해당 월의 지출내역이 없다면 빈배열을 가져오도록 하고, 새로운 지출내역은 해당 월 지출 내역에 추가해준다.

const addExes = { ..exes };
    if (!addExes[selectedMonth]) {
      addExes[selectedMonth] = [];
    }
    addExes[selectedMonth].push(newExes);
    setExes(addExes);

 

해당 월의 지출내역 데이터를 fiteredList 변수에 할당한다. 이 변수는 선택한 월에 해당하는 지출 내역을 보여주는 ExesList에 props로 내려준다. 필터링된 데이터로 지출 아이템을 하나씩 만들어줘야하기 때문!

const filteredList = exes[selectedMonth];

 

 

✔️ 로컬스토리지

지출 내역 초기값을 설정해준다. 

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

 

마지막으로 확인한 월 저장.

const initialSelectedMonth = localStorage.getItem("selectedMonth")
    ? parseInt(localStorage.getItem("selectedMonth"))
    : new Date().getMonth() + 1;

 

지출 내역과 월 초기값 상태.

const [exes, setExes] = useState(initalLocalData);
const [selectedMonth, setSelectedMonth] = useState(initialSelectedMonth);

 

데이터 저장. 

localStorage.setItem("expenseList", JSON.stringify(addExes));

 useEffect(() => {
    localStorage.setItem("selectedMonth", selectedMonth);
  }, [selectedMonth]);

 

 

 

 

ExesList.jsx

선택한 월에 해당하는 지출 내역을 보여주는 리스트 컴포넌트이다.

const ExesList = ({ filteredList }) => {
  return (
    <div>
      <ul>
        {filteredList &&
          filteredList.map((exe) => {
            return <ExesItem key={exe.id} exe={exe} />;
          })}
      </ul>
    </div>
  );
};

export default ExesList;

 

 

 

ExesItem.jsx

필터링된 데이터로 각 지출 내역을 보여주는 컴포넌트이다. 아이템을 눌렀을 때 해당하는 지출의 상세페이지로 이동해야하기 때문에(Detail.jsx) useNavigate() 훅을 사용해주었다. 

const ExesItem = ({ exe }) => {
  const navigate = useNavigate();
  const { id, date, item, amount, desc } = exe;

  const onDetailButtonHandler = (id) => {
    navigate(`/detail/${id}`, { state: { exe } });
  };
  return (
    <>
      <ExeItem onClick={() => onDetailButtonHandler(id)}>
        <ExeHead>{item}</ExeHead>
        <ExeItemP>
          {desc}
          <Sapn>{date}</Sapn>
        </ExeItemP>
        <H2>{amount}원</H2>
      </ExeItem>
    </>
  );
};

export default ExesItem;

 

 

 

 

MonthList.jsx

월별 버튼 컴포넌트이다. 1-12월까지 해당하는 버튼을 눌렀을 때 선택된 스타일링이 들어가야한다. 월별 버튼을 구현할 때 필수사항이었던 style-component를 사용해 조건부 스타일링을 해주었다.

const MonthList = ({ handleMonthSelect }) => {
  const monthArray = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12];

  return (
    <>
      <MonthWrap>
        {monthArray.map((MonthText, idx) => {
          return (
            <MonthItem
              key={idx}
              $active={selectedMonth === idx}
              onClick={() => handleMonthSelect(idx)}
            >
              {`${MonthText}월`}
            </MonthItem>
          );
        })}
      </MonthWrap>
    </>
  );
};

export default MonthList;

 

 

월이 들어가있는 배열을 순회하면서 $active props에 선택한 월과 배열에 있는 인덱스가 같다면 스타일링을 줄 수 있도록 했다.

{monthArray.map((MonthText, idx) => {
          return (
            <MonthItem
              key={idx}
              $active={selectedMonth === idx}
              onClick={() => handleMonthSelect(idx)}
            >
              {`${MonthText}월`}
            </MonthItem>
          );
        })}

 

삼항 연산자로 처리했다. props로 $active가 있다면 색상을 파란색으로 바꾸고 아니라면 회색(디폴트)색으로 변경. 폰트 색상도 동일하게 조건부 스타일링을 해주었다.

background-color: ${(props) => (props.$active ? "#89ACEC" : "#f4f5f7")};
color: ${(props) => (props.$active ? "#fff" : "#2e2e2e")};

 

상태 업테이트까지 하면 완성.

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

 

 

Detail.jsx

각 지출 내역의 상세페이지 컴포넌트이다. props-drilling으로 만들면서 디테일 페이지에는 어떻게 props를 전달해줘야하나 고민을 많이 했다.. 구글링을 하면서 useLocation으로 가져오는 방법을 찾아서 전체 지출 내역 리스트를 가져와서 사용해봤다.

const Detail = () => {
  const { id } = useParams();
  const navigate = useNavigate(); 
  const location = useLocation(); 
  const exe = location.state.exe;

  return (
    <>
      <StContainer>
        <TopWrap>
          <IoIosArrowBack
            style={{ fontSize: "25px", cursor: "pointer" }}
            onClick={onBackButtonHandler}
          />
          <H1>상세 내역</H1>
          <IoIosArrowBack style={{ fontSize: "25px", opacity: "0" }} />
        </TopWrap>

        <ExeItem>
          <ExeHead>{exe.item}</ExeHead>
          <ExeItemP>
            {exe.desc} <Sapn>{exe.date}</Sapn>
          </ExeItemP>
          <H2>{exe.amount}원</H2>
        </ExeItem>

        <DetailContainer>
          <label>날짜</label>
          <StInput ref={dateRef} defaultValue={exe.date} />
          <label>항목</label>
          <StInput ref={itemRef} defaultValue={exe.item} />
          <label>내용</label>
          <StInput ref={descRef} defaultValue={exe.desc} />
          <label>금액</label>
          <StInput ref={amountRef} defaultValue={+exe.amount} />

          <ButtonWrap>
            <EditButton onClick={() => onModify(id)}>수정</EditButton>
            <RemoveButton onClick={() => onRemove(id)}>삭제</RemoveButton>
          </ButtonWrap>
        </DetailContainer>
      </StContainer>
    </>
  );
};

export default Detail;

 

 

✔️ 삭제

로컬스토리지에 저장된 데이터를 삭제하고 업데이트하는 로직으로 구현했다. 로컬스토리지에 저장되어있는 전체 지출 내역을 data 변수에 할당해준다. 

let data = JSON.parse(localStorage.getItem("expenseList"));

 

삭제 버튼 클릭 시 즉시 삭제하기 보다는 사용자에게 확인받은 뒤 삭제처리하도록 했다. data[exe.month]는 해당하는 월의 지출내역을 담고있다. 배열이기때문에 filter 메서드를 사용해 삭제하려는 데이터는 제외한 나머지 데이터들을 removeData 변수에 할당해주었다.

그리고 여기서 해당하는 월의 지출내역에 다시 재할당 해준다. 아무리 구글링해도 setState는 props로 가져올수가 없어서 상태를 업데이트 해주는 대신 저장된 데이터를 업데이트해주는 로직으로 구현한 것이다..

const onRemove = (id) => {
    if (confirm("정말 삭제하시겠습니까?") == true) {
      const removeData = data[exe.month].filter((ex) => ex.id !== id);
      data[exe.month] = removeData;
      navigate(-1);
    } else {
      return false;
    }
  };

 

 

 

✔️ 수정

useRef를 사용해서 수정 기능을 구현했다. 리렌더링되도 초기값이 변하지 않는다. 

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

 

이것도 삭제와 같은 방법으로 상태 업데이트가 아니라 저장된 로컬스토리지 데이터에 수정된 데이터를 덮어씌우는 로직으로 갔다. 

수정하려는 객체를 하나 만들어주고 변수명.current.value로 수정하려는 데이터에 접근할 수 있다. 해당 월 지출 내역을 순회하면서 수정하려는 데이터의 id가 해당 월 지출내역의 id가 같다면 기존 지출내역을 복사하고 수정된 데이터도 복사해 합쳐준다. 

const onModify = (id) => {
    const modifiedData = {
      id: exe.id,
      date: dateRef.current.value,
      item: itemRef.current.value,
      amount: +amountRef.current.value,
      desc: descRef.current.value,
    };
    const modifyExes = data[exe.month].map((ex) =>
      ex.id === id ? { ...ex, ...modifiedData } : ex
    );
    data[exe.month] = modifyExes;
    localStorage.setItem("expenseList", JSON.stringify(data));
    navigate(-1);
  };

 

처음 짠 코드는 아래와 같다. 이렇게 했더니 정상적으로 동작하지 않았다. 기존에 있는 데이터는 month 값이 있지만 수정한 객체는 month 값이 들어가 있지 않아서 생긴 오류였다. 그래서 위와 같이 스프레드 연산자를 사용해 불변성을 유지하고 기존 데이터에 있는 month의 값도 들어갈 수 있었다. 

const modifyExes = data[exe.month].map((ex) =>
      ex.id === id ? modifiedData : ex
    );

 

 

 

 

 

useContext 

useContext는 컴포넌트에서 context를 읽고 구독할 수 있게 해주는 React Hook입니다.

 

 

기본구문

const value = useContext(SomeContext)

 

매개변수

SomeContext : 이전에 createContext로 생성한 context입니다. context 자체는 정보를 보유하지 않으며, 컴포넌트에서 제공하거나 읽을 수 있는 정보의 종류를 나타낼 뿐입니다.

 

반환값

호출한 컴포넌트에서 트리상 위에 있는 가장 가까운 SomeContext.Provider에 전달된 value입니다. 이러한 provider가 없는 경우 반환되는 값은 해당 context에 대해 createContext에 전달한 defaultValue가 됩니다. 반환된 값은 항상 최신 값입니다. React는 context가 변경되면 context를 읽는 컴포넌트를 자동으로 리렌더링합니다.

 

 


 

 

 

React Context의 필요성

일반적으로 부모컴포넌트에서 자식 컴포넌트에게 데이터를 전달해줄때 props로 전달해준다. 하지만 큰 단점이 있는데 컴포넌트가 깊어질수록 해당 컴포넌트가 어느 컴포넌트에서 왔는지 파악하기 어려워진다. 이것을 props-drilling이라고 한다.

 

prop drilling의 문제점

  1. 깊이가 너무 깊어지면 이 prop이 어떤 컴포넌트로부터 왔는지 파악이 어려워진다.
  2. 어떤 컴포넌트에서 오류가 발생할 경우 추적이 힘들다.

 

 

Props 사용이 적절할 때❓

리스트 컴포넌트에서 아이템 컴포넌트로 상태를 전달하는 경우. 이 경우는 이번 프로젝트를 하면서 많이 생소했다. props drilling으로 만든 코드를 context 로 변경하는 과정에서 리스트 컴포넌트에는 도저히 context를 사용할 수 없을 것 같아서였다. map을 돌린 애들로 아이템을 만드는데 여기서 context를 어떻게 사용할까 했는데 오늘 강의 때 단순한 데이터 전달은 Props 사용이 적절한 때라고 알려주셔서 이해할 수 있었다.

export default function TodoList({ isDone }) {
  const { todos, setTodos } = useContext(TodoContext);
  const filteredTodos = todos.filter((todo) => todo.isDone === isDone);
  return (
    <div>
      <h2>{isDone ? "Done" : "Working..."}</h2>
      {filteredTodos.map((todo) => (
        <TodoItem key={todo.id} todo={todo} setTodos={setTodos} />
      ))}
    </div>
  );
}
export default function TodoListWrapper() {
    return (
        <>
            <TodoList isDone={false} />
            <TodoList isDone={true} />
        </>
    );
}

 

 

Context API

아래 그림처럼 useContext hook을 통해 우리는 쉽게 전역 데이터를 관리할 수 있다. Context Provider 의 모든 children 컴포넌트들에게 context value를 공유할 수 있고, 전역상태관리를 하게 되면 props 를 이용한 상태 공유를 하지 않아도 된다.

 

  • createContext : context를 생성
  • useContext : context를 구독하고 해당 context의 현재 값을 읽기
  • Provider : context를 하위 컴포넌트에게 전달

 

 

 

 

createContext

Context Provider 의 모든 children 컴포넌트들에게 context value를 공유할 수 있다. 

const TodoContext = createContext(null);

 

 

Provider

 context를 하위 컴포넌트에게 전달한다.

function App () {
	const [todos, setTodos] = useState([]);
	
	return (
		<TodoContext.Provider value={{todos, setTodos}}>
			<TodoForm />
			<TodoListWrapper />
		</TodoContext.Provider>	
	);
}

 

예시

// src/context/TodoContextProvider.jsx
import React, { createContext, useState } from 'react';

// 함수 컴포넌트 외부에서 Context 생성
export const TodoContext = createContext(null);

const TodoContextProvider = ({ children }) => {
  const [todos, setTodos] = useState([]);

  const addTodo = (text) => {
    setTodos([...todos, { id: Date.now(), text, isDone: false }]);
  };

  return (
    <TodoContext.Provider value={{ todos, addTodo }}>
      {children}
    </TodoContext.Provider>
  );
};

export default TodosContextProvider;

 

useContext

context를 구독하고 해당 context의 현재 값을 읽어올 수 있다. useContext() 괄호 안은 리턴 + 구독을 뜻한다. 아래 코드는 todoContext를 구독하겠다는 말과 같다.

function TodoForm() {
	const { setTodos } = useContext(TodoContext);
	return (
		...
	)
}

 

 

 

실습을 하던 중에 디테일 페이지에 데이터를 받아야하는데 부모 컴포넌트와 종속성이 없어 데이터를 받아오지 못하는 상황이 있었다. 라우터 간 데이터 전달은 context api로 할 수 있는데 props-drilling으로 작업하고 있어서 어찌해야할 방법을 몰랐다. 그런데 설마 했던 방법이 맞았다..! 라우터 컴포넌트에서 상태를 전달하는 것..! 너무나도 생소해서 여긴 아니겠지 하는 생각으로 갈피를 잡지 못했었다. 

// 여러 페이지 컴포넌트에 동일한 상태 공유가 필요할 때
function Router() {
	return (
	<BrowserRouter>
	    <Routes>
        	<Route path="/" element={<Home />} />
        	<Route path="/detail/id" element={<Detail />} />
	    </Routes>
	</BrowserRouter>		
	)
}

 

Route.jsx에다 상태를 만들어주고 props로 내려주면 되는것.. 방법을 못찾아서 useLocation으로 데이터를 받아왔었는데 props-drilling의 불편함을 어느정도 알 수 있게해준 오류였다.

function Router() {
const [todos, setTodos] = useState([]);
	return (
	<BrowserRouter>
	    <Routes>
        	<Route path="/" element={<Home />} />
        	<Route path="/detail/id" element={<Detail todos={todos}/>} />
	    </Routes>
	</BrowserRouter>		
	)
}

 

+ Recent posts