리액트로 첫 팀프로젝트가 시작됐다. 뭔가 프로젝트다운 프로젝트를 하는 것 같다. 인스타그램이나 페이스북,  블로그 같이 게시글을 쓰고 읽고 수정하고 삭제할 수 있는 뉴스피드 웹사이트를 구현하는 프로젝트이다. 그리고 처음으로 서버를 사용해서 작업한다!! supabase를 사용하는데 프론트엔드 애플리케이션에 백엔드 서비스를 제공하기 위해 사용하는 모델이다. 

 

 

 

 

 

 

와이어프레임

전직 웹 퍼블리셔 + 디자인을 했었던 터라 디자인은 모두 내가 맡아서 진행했다. 어떤 뉴스피드를 할까 의견이 많이 나왔는데 1. 크림같은 중고거래 웹사이트, 2. 코디한 옷 공유하는 사이트, 3. 아이스크림 후기 공유하는 사이트 세가지가 나왔다. 우리 팀명이 구구콘이라 3번으로 탕탕탕ㅋㅋㅋㅋ 아이스크림 후기를 공유? 뭔가 생소할 수 있지만 정해진 주제이니 완전 열심히 해보았다. 디자인은 아이스크림 공유하는 사이트 답게 뭔가 파스텔 톤의 귀여운 디자인으로 뽑아봤다.

 

차례대로 헤더 -> 배너 -> 게시물 -> 푸터 순이고 우리 팀의 핵심 기능은 카테고리 별 필터링 하는 기능이다. 카테고리는 브랜드별, 맛별, 종류별로 나뉘어진다. 인기 게시물과 최근 게시물 모두 카테고리 별로 확인이 가능하다.

 

 

 

 

 

사전 기획

우리는 맛있는 아이스크림을 추천하는 게시물을 작성하고 다른 사람들의 후기를 통해 아이스크림 정보를 탐색하고 나의 아이스크림 취향을 모으는 마이페이지를 주제로 정하고 구현하기로 했다. 프로젝트 시작 전 와이어프레임 제작과 함께 사전 기획을 했다. 어떤 페이지에 어떤 컴포넌트와 기능이 필요한지. 데이터 형식은 어떤식으로 넣는게 좋을지 의논하여 정하였다. 

 

 

 

 

 

 

 

역할 분담

 

내가 맡은 역할은 마이페이지. 로그인 회원 정보를 가지고 와서 데이터를 뿌려주고 회원정보를 수정한다. 수정하는 데이터는 닉네임과 프로필 이미지이다. 더해서 전체적인 웹페이지의 반응형을 구현하기로 했다.

 

 

 

 

 

 

기술적 의사결정

아래와 같이 어떤 라이브러리를 사용할껀지 팀원들과 사전에 협의하고 진행했다.

 

 

 

 

 

 

 

개발 프로세스

 

이제 마지막 ! 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);
  });

 

props-drilling에 이어 context로 props를 전달해서 기존 기능들이 정상적으로 작동되게 만들어보자!!🔥

 

 

 

📌 props-drilling으로 만든 지출 관리 사이트

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%F0%9F%90%A2

 

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

세번째 개인 프로젝트이다. 리액트로는 두번째 개인 프로젝트! 간단한 개인 지출 관리 사이트를 만들어보자!!🐢이번 과제는 props-drilling > context > redux 순으로 상태 관리를 변경해가며 구현해야

ejunyang.tistory.com

 

 

컴포넌트

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

 

 


 

 

컨텍스트 생성

📂 context >  📂 Context.jsx

import { createContext } from "react";

export const Context = createContext(null);

 

 

App.jsx

최상위 컴포넌트에서 각 컴포넌트에서 사용할 props를 Provider를 사용해 전달해준다. value로 전달해주기 위해선 App.jsx에 데이터들이 있어야한다.

import { useCallback, useEffect, useState } from "react";
import { Context } from "./context/Context";

return (
    <Context.Provider
      value={{
        exes,
        setExes,
        selectedMonth,
        setSelectedMonth,
        handleMonthSelect,
        onInsert,
        filteredList,
      }}
    >
      <Router />
    </Context.Provider>
  );
};

export default App;

 

 

ExesForm.jsx

 

useContext를 사용해서 사용하고 있던 데이터를 가져온다. 지출내역을 추가하는 컴포넌트로 onInsert 함수를 가져온다.

import React, { useCallback, useContext, useState } from "react";
import { FaPen } from "react-icons/fa6";
import styled from "styled-components";
import { Context } from "../context/Context";

const ExesForm = () => {
  const { onInsert } = useContext(Context);

  const onSubmit = useCallback(
    (e) => {
      ...
    },
    [date, item, amount, desc]
  );

  return (
   ...
  );
};

export default ExesForm;

 

 

 

ExesList.jsx

여기서 살짝 헤맸는데 해당 지출 내역을 가져와서 map메서드로 각 지출 내역을 하나씩 만들어준다. 그래서 ExesItem.jsx에도 useContext를 사용해서 데이터를 가져와야하나? 했는데 사용하기 어려워보였다. 튜터님께 여쭤보니 해당 과제가 context를 사용해보려고 하는것이지 아예 props-drilling을 사용하지 말라는건 아니라고 하셨고, 데이터를 바로 전달할 수 있는 경우에는 props로 하위 컴포넌트에게 넘겨주어도된다고 했다. 

import React, { useContext } from "react";
import ExesItem from "./ExesItem";
import { Context } from "../context/Context";

const ExesList = () => {
  const { filteredList } = useContext(Context);
  return (
    <div>
      <ul>
        {/*
        Uncaught TypeError: Cannot read properties of undefined (reading 'map')
        커밋 된 후에야 모든 효과를 실행
        React는 return에서 map을 반복실행할 때 첫 턴에 데이터가 아직 안들어와도 렌더링이 실행되며 
        당연히 그 데이터는 undefined로 정의되어 오류

        && 사용
        true && expression은 항상 expression으로 실행
        */}
        {filteredList &&
          filteredList.map((exe) => {
            return <ExesItem key={exe.id} exe={exe} />;
          })}
      </ul>
    </div>
  );
};

export default ExesList;

 

 

MonthList.jsx

import React, { useContext } from "react";
import "../App.css";
import styled from "styled-components";
import { Context } from "../context/Context";

const MonthList = () => {
  const { exes, selectedMonth, handleMonthSelect } = useContext(Context);

  const monthArray = [1,2,3,4,5,6,7,8,9,10,11,12];

  const totalAmount =
    exes[selectedMonth] && exes[selectedMonth].length > 0
      ? exes[selectedMonth].reduce((acc, cur) => {
          return (acc += cur.amount);
        }, 0)
      : 0;

  return (
    <>
     ...
    </>
  );
};

export default MonthList;

 

 

 

Detail.jsx

 

props-drilling으로 작업할 때 디테일 페이지에 지출내역의 setState를 가져오지 못해서 상태를 업데이트해주지 못했는데 context를 사용하면 setState 가져올 수 있다!

import React, { useCallback, useContext, useEffect, useRef } from "react";
import { useNavigate, useParams } from "react-router-dom";
import styled from "styled-components";
import { IoIosArrowBack } from "react-icons/io";
import { Context } from "../context/Context";

const Detail = () => {
  const { id } = useParams(); 
  const navigate = useNavigate(); 
  const { filteredList, setExes } = useContext(Context);

  const exe = filteredList.find((item) => item.id === id);

  return (
    <>
      ...
    </>
  );
};

export default Detail;

 

삭제할 지출 내역을 제외한 새로운 배열을 생성하고,  그 배열을 포함한 새로운 객체를 만든다. 불변성 유지를 위해 전체 월 지출내역 복사,  removeExes 안에는 전체 지출 내역 중 삭제된 지출내역을 제외한 나머지 지출내역이 들어가 있다. 로컬스토리지에 해당 데이터를 저장하고 setExes(지출 내역 업데이트)로 상태 업데이트를 해준다.

const onRemove = useCallback(
    (id) => {
      if (confirm("정말 삭제하시겠습니까?") == true) {
        const removeExes = {
          ...data,
          [exe.month]: data[exe.month].filter((ex) => ex.id !== id),
        };
        localStorage.setItem("expenseList", JSON.stringify(removeExes));
        setExes(removeExes);
        navigate("/");
      } else {
        return false;
      }
    },
    [exe]
  );

 

수정 기능도 불변성 유지를 위해 이전 데이터를 복사 후에 수정된 데이터 배열을 월 지출 내역에 업데이트 해준다. 

const onModify = useCallback(
    (id) => {
      const modifiedData = {
        id: exe.id,
        date: dateRef.current.value, //변수 접근
        item: itemRef.current.value,
        amount: +amountRef.current.value, //숫자로만 받아야하기때문에 +연산자 사용(NaN 오류 발생)
        desc: descRef.current.value,
      };
      data[exe.month] = data[exe.month].map((ex) =>
        ex.id === id ? { ...ex, ...modifiedData } : ex
      );
      setExes((prevExe) => ({
        ...prevExe,
        [exe.month]: data[exe.month],
      }));
      localStorage.setItem("expenseList", JSON.stringify(data));
      alert("수정이 완료되었습니다.");
    },
    [exe]
  );

 

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

이번 과제는 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
    );

 

 

 

 

 

 

 

https://ejunyang.tistory.com/entry/PP-%EB%A6%AC%EC%95%A1%ED%8A%B8%EB%A1%9C-ToDoList-%EB%A7%8C%EB%93%A4%EA%B8%B0-%EC%82%AD%EC%A0%9C-%EA%B8%B0%EB%8A%A5

 

[P.P] 리액트로 ToDoList 만들기 - 삭제 기능

https://ejunyang.tistory.com/entry/PP-%EB%A6%AC%EC%95%A1%ED%8A%B8%EB%A1%9C-ToDoList-%EB%A7%8C%EB%93%A4%EA%B8%B0 [P.P] 리액트로 ToDoList 만들기 - UI구현, 리스트 추가또 다른 프로젝트가 시작됐다 ㅎ 저번주 금요일에 리액트

ejunyang.tistory.com

 

 

 

UI 구현

  • Todo 추가 하기
  • Todo 삭제 하기
  • Todo 완료/취소 상태 변경하기 (진행중 ↔ 완료)

 

기능 구현

  • 제목과 내용을 입력하고, [추가하기] 버튼을 클릭하면 Working에 새로운 Todo가 추가되고 제목 input과 내용 input은 다시 빈 값으로 바뀌도록 구성해주세요.
  • [삭제하기] 버튼을 클릭하면 Working 또는 Done 에 있는 것과 상관없이 삭제처리가 되도록 해주세요.
  • Todo의 isDone 상태가 true이면, 상태 버튼의 라벨을 취소, isDone이 false 이면 라벨을 완료 로 조건부 렌더링 해주세요.
  • Todo의 상태가 Working 이면 위쪽에 위치하고, Done이면 아래쪽에 위치하도록 구현합니다.
  • Layout의 최대 너비는 1200px, 최소 너비는 800px로 제한하고, 전체 화면의 가운데로 정렬해주세요.
  • 컴포넌트 구조는 자유롭게 구현해보세요.

 

 

🍯 HINT

  • 사용한 hook은 오직 useState
  • 기능 구현을 위해 생성한 함수는 2개 입니다. onChangeHandler , onSubmitHandler
  • 사용한 javascript 내장 메서드는 map, filter 입니다.
  • todo의 initial state는 {id: 0, title: “”, body: “”, isDone: false} 입니다.

 


 

 

컴포넌트

TodoTemplate.jsx  화면을 가운데에 정렬시켜주며, 일정 리스트를 보여줍니다. children으로 내부 JSX를 props로 받아 와서 렌더링해줍니다.
TodoAdd.jsx 할 일을 추가하는 컴포넌트 입니다.
TodoList.jsx todos 배열을 props로 받아 온 후, 이를 배열 내장 함수 map을 사용해서 여러 개의 TodoItem 컴포넌트로 변환하여 보여 줍니다.
TodoItem.jsx 진행중인 할 일 정보를 보여주는 컴포넌트입니다. todo 객체를 props로 받아 와서 상태에 따라 다른 스타일의 UI를 보여줍니다
CurrentDate.jsx 현재 날짜를 알려주는 컴포넌트입니다.
Icons.jsx 배경에 꾸며주는 Sticker 요소를 보여주는 컴포넌트입니다.

 


 

 

App.jsx

Todo의 isDone 상태가 true이면, 상태 버튼의 라벨을 취소, isDone이 false 이면 라벨을 완료 로 조건부 렌더링이 필요해서 토글 함수를 만들었다. 삭제 함수와 마찬가지로 id 값을 받아와서 처리하도록 했다.

 

map과 삼항연산자를 활용했다. map()으로 배열 순회를 돌면서 배열의 id와 사용자가 선택한 id 가 같다면, todo에 isDone 키의 값이 true -> false, false -> true 로 반전시켜주는 NOT 연산자를 사용해줬다. 같지 않다면 그대로 todo가 나오도록 해주었다.

const onToggle = (id) => {
    setTodo(
      todos.map((todo) =>
        todo.id === id ? { ...todo, isDone: !todo.isDone } : todo
      )
    );
  };

 

TodoList 에서 사용할 수 있게 props로 내려준다. 그리고 진행중인 리스트와 진행 완료된 리스트를 나누어서 완료된 리스트는 진행 중인 리스트 아래로 내려야 하기때문에 TodoList 를 두개 만들어주고 안에 들어가는 props 에 filter를 써서 조건부 렌더링이 되도록 해주었다.

const workingTodo = todos.filter((todo) => !todo.isDone);
const doneTodo = todos.filter((todo) => todo.isDone);
return (
    <>
      <TodoTemplate>
        <CurrentDate todos={todos} />
        <TodoList
          key={todos.id}
          todos={workingTodo}
          onRemove={onRemove}
          onToggle={onToggle}
        />
        <TodoList
          key={todos.id}
          todos={doneTodo}
          onRemove={onRemove}
          onToggle={onToggle}
        />
        <TodoAdd onInsert={onInsert} />
      </TodoTemplate>
    </>
  );

 

 

 

TodoList.jsx

TodoItem에서 사용할 수 있도록 props를 내려주자.

const TodoList = ({ todos, onRemove, onToggle }) => {
  return (
    <div>
      {todos.map((todo) => {
        return (
          <TodoItem
            todo={todo}
            key={todo.id}
            onRemove={onRemove}
            onToggle={onToggle}
          />
        );
      })}
    </div>
  );
};

 

 

 

TodoItem.jsx

isDone 이 true 일때 체크 아이콘은 ✔️ 상태를 유지하고, isDone 이 false 라면 O 체크를 지운 아이콘을 유지하도록 했다.

{isDone && (<IoCheckmark className="check" onClick={() => onToggle(id)} />)}
{!isDone && (<PiCircleLight className="check" onClick={() => onToggle(id)} />)}

 

이렇게 코드를 적고 실행을 했는데 오류가 생겼다. isDone is not defined. 

 

코드를 확인해보니 구조할당분해에서 isDone을 빼먹어서 생긴 오류였다. isDone 을 넣어주니 정상적으로 작동하였다.

const { id, contents, isDone } = todo;

 

 

 

https://ejunyang.tistory.com/entry/PP-%EB%A6%AC%EC%95%A1%ED%8A%B8%EB%A1%9C-ToDoList-%EB%A7%8C%EB%93%A4%EA%B8%B0

 

[P.P] 리액트로 ToDoList 만들기 - UI구현, 리스트 추가

또 다른 프로젝트가 시작됐다 ㅎ 저번주 금요일에 리액트 강의가 발제 됐는데 자바스크립트 보다 훨씬 재밌고, 뭔가 이해가 더 잘 되는것 같다(?) 자바스크립트 라이브러리 아닌가..? 강의 듣는

ejunyang.tistory.com

 

 

 

UI 구현

  • Todo 추가 하기
  • Todo 삭제 하기
  • Todo 완료/취소 상태 변경하기 (진행중 ↔ 완료)

 

기능 구현

  • 제목과 내용을 입력하고, [추가하기] 버튼을 클릭하면 Working에 새로운 Todo가 추가되고 제목 input과 내용 input은 다시 빈 값으로 바뀌도록 구성해주세요.
  • [삭제하기] 버튼을 클릭하면 Working 또는 Done 에 있는 것과 상관없이 삭제처리가 되도록 해주세요.
  • Todo의 isDone 상태가 true이면, 상태 버튼의 라벨을 취소, isDone이 false 이면 라벨을 완료 로 조건부 렌더링 해주세요.
  • Todo의 상태가 Working 이면 위쪽에 위치하고, Done이면 아래쪽에 위치하도록 구현합니다.
  • Layout의 최대 너비는 1200px, 최소 너비는 800px로 제한하고, 전체 화면의 가운데로 정렬해주세요.
  • 컴포넌트 구조는 자유롭게 구현해보세요.

 

 

🍯 HINT

  • 사용한 hook은 오직 useState
  • 기능 구현을 위해 생성한 함수는 2개 입니다. onChangeHandler , onSubmitHandler
  • 사용한 javascript 내장 메서드는 map, filter 입니다.
  • todo의 initial state는 {id: 0, title: “”, body: “”, isDone: false} 입니다.

 


 

컴포넌트

TodoTemplate.jsx  화면을 가운데에 정렬시켜주며, 일정 리스트를 보여줍니다. children으로 내부 JSX를 props로 받아 와서 렌더링해줍니다.
TodoAdd.jsx 할 일을 추가하는 컴포넌트 입니다.
TodoList.jsx todos 배열을 props로 받아 온 후, 이를 배열 내장 함수 map을 사용해서 여러 개의 TodoItem 컴포넌트로 변환하여 보여 줍니다.
TodoItem.jsx 진행중인 할 일 정보를 보여주는 컴포넌트입니다. todo 객체를 props로 받아 와서 상태에 따라 다른 스타일의 UI를 보여줍니다
CurrentDate.jsx 현재 날짜를 알려주는 컴포넌트입니다.
Icons.jsx 배경에 꾸며주는 Sticker 요소를 보여주는 컴포넌트입니다.

 


 

 

 

App.jsx

filter 메서드를 사용해서 배열을 순회하도록 했고, 배열에 있는 id 와 사용자가 선택한 id 가 일치한 값은 삭제가 되고, 일치하지 않는 나머지 값들을 리턴해주도록 했다. 여기서 느슨한 비교와 엄격한 비교를 할 수 있는데 비교 방법은 아래와 같다.

 // 삭제기능
  const onRemove = (id) => {
    setTodo(todos.filter((todo) => todo.id !== id));
  };

 

만들어준 onRemove()는 TodoList 컴포넌트에서 사용할 수 있도록 props 로 내려주자.

return (
    <>
      <TodoTemplate>
        <CurrentDate todos={todos} />
        <TodoList
          key={todos.id}
          todos={todos}
          onRemove={onRemove}
        />
        <TodoAdd onInsert={onInsert} />
      </TodoTemplate>
    </>
  );

 


🐢 느슨한 비교

자체적으로 타입변환이 필요한 경우에만 사용하자.

// 느슨한 비교
console.log(5 == '5'); // true
console.log(0 == false); // true
console.log(null == undefined); // true

 

🔥 엄격한 비교

// 엄격한 비교
console.log(5 === '5'); // false
console.log(0 === false); // false
console.log(null === undefined); // false
 // 느슨한 비교
  const onRemove = (id) => {
    setTodo(todos.filter((todo) => todo.id != id));
  };
  
 // 엄격한 비교
  const onRemove = (id) => {
    setTodo(todos.filter((todo) => todo.id !== id));
  };

 

 

TodoList.jsx

import React from "react";
import TodoItem from "./TodoItem";

const TodoList = ({ todos, onRemove }) => {
  return (
    <div>
      {todos.map((todo) => {
        return (
          <TodoItem
            todo={todo}
            key={todo.id}
            onRemove={onRemove}
          />
        );
      })}
    </div>
  );
};

export default TodoList;

 

TodoItem.jsx

 

내려받은 props 를 고대로 사용만 하면 되는 TodoItem에서 마지막 작업을 해주면 된다. 버튼을 클릭할 때 함수가 실행되도록 해주면 삭제하기 버튼 완료이다. 나는 버튼 모양 대신에 내가 만든 휴지통 아이콘을 넣고싶어서 따로 import해주고 사용봤다.

import React from "react";
import deleteIcon from "../assets/delete.png";
import { IoCheckmark } from "react-icons/io5";
import { PiCircleLight } from "react-icons/pi";

const TodoItem = ({ todo, onRemove }) => {
  const { id, contents, isDone } = todo;
  return (
    <>
      <div className="todoItem">
        <p className="content">{contents}</p>
        <button onClick={() => onRemove(id)}>
          <img src={deleteIcon} />
        </button>
      </div>
    </>
  );
};

export default TodoItem;

 

삭제하기 버튼 생성 완료.. 후후... 기능 구현도 아주 잘 된다.. 후후..

 

 

 

또 다른 프로젝트가 시작됐다 ㅎ 저번주 금요일에 리액트 강의가 발제 됐는데 자바스크립트 보다 훨씬 재밌고, 뭔가 이해가 더 잘 되는것 같다(?) 자바스크립트 라이브러리 아닌가..? 강의 듣는데 이해가 잘되서 재밌던 적은 처음이었다 이게 얼마나 갈까?ㅎㅎ

 

 


 

 

UI 구현

  • Todo 추가 하기
  • Todo 삭제 하기
  • Todo 완료/취소 상태 변경하기 (진행중 ↔ 완료)

 

기능 구현

  • 제목과 내용을 입력하고, [추가하기] 버튼을 클릭하면 Working에 새로운 Todo가 추가되고 제목 input과 내용 input은 다시 빈 값으로 바뀌도록 구성해주세요.
  • [삭제하기] 버튼을 클릭하면 Working 또는 Done 에 있는 것과 상관없이 삭제처리가 되도록 해주세요.
  • Todo의 isDone 상태가 true이면, 상태 버튼의 라벨을 취소, isDone이 false 이면 라벨을 완료 로 조건부 렌더링 해주세요.
  • Todo의 상태가 Working 이면 위쪽에 위치하고, Done이면 아래쪽에 위치하도록 구현합니다.
  • Layout의 최대 너비는 1200px, 최소 너비는 800px로 제한하고, 전체 화면의 가운데로 정렬해주세요.
  • 컴포넌트 구조는 자유롭게 구현해보세요.

 

 

🍯 HINT

  • 사용한 hook은 오직 useState
  • 기능 구현을 위해 생성한 함수는 2개 입니다. onChangeHandler , onSubmitHandler
  • 사용한 javascript 내장 메서드는 map, filter 입니다.
  • todo의 initial state는 {id: 0, title: “”, body: “”, isDone: false} 입니다.

 

 


 

 

 

와이어프레임

컴포넌트

TodoTemplate.jsx  화면을 가운데에 정렬시켜주며, 일정 리스트를 보여줍니다. children으로 내부 JSX를 props로 받아 와서 렌더링해줍니다.
TodoAdd.jsx 할 일을 추가하는 컴포넌트 입니다.
TodoList.jsx todos 배열을 props로 받아 온 후, 이를 배열 내장 함수 map을 사용해서 여러 개의 TodoItem 컴포넌트로 변환하여 보여 줍니다.
TodoItem.jsx 진행중인 할 일 정보를 보여주는 컴포넌트입니다. todo 객체를 props로 받아 와서 상태에 따라 다른 스타일의 UI를 보여줍니다
CurrentDate.jsx 현재 날짜를 알려주는 컴포넌트입니다.
Icons.jsx 배경에 꾸며주는 Sticker 요소를 보여주는 컴포넌트입니다.

 

 

 

 

🐣 프로젝트 생성

1. CRA(Create React App)

아래처럼 성공 메세지가 띄워졌다면 app 생성 성공

yarn create react-app [원하는 프로젝트 이름]

 

 

생성한 App 파일로 Change Directory 하고 yarn을 실행해준다. 그럼 리액트 앱 생성 완료~~ EZEZ~~

cd [생성한 App 이름]
yarn start

 

 

 

2. Vite

yarn create vite [생성할 App 이름] --template react

 

 

생성 완료닷

cd [생성한 App 이름]
yarn
yarn dev

 

Vite로 생성하면 좋은 이유

WebPack을 사용하는 CRA 대신 Esbuild를 사용하는 Vite는 원래 Vue.js 애플리케이션을 위해 만들어졌지만, 
현재는 React, Svelte, Vanilla JS 등 다양한 프레임워크와 라이브러리를 지원한다. 

1. 빠른 콜드 스타트와 HMR(Hot Module Replacement)
2. 속도 측면에서 기존 CRA와는 비교가 되지 않을 정도로 빠르다
3. CRA는 기본적으로 설정을 숨기지만, Vite는 사용자가 필요에 따라 설정을 더 쉽게 조정할 수 있다
4. Go 언어 베이스의 자바스크립트 빌드 툴입니다. CRA가 채택하는 웹팩과 비교할 때, 말이 안되는 수준의 속도

 

 

 

 

 

🐥 UI 구현하기

우선 App.jsx에 리스트 배열을 만들어주고 각 컴포넌트를 배치한다. TodoList가 두개인 것은 진행 중인 리스트 목록과 완료한 리스트 목록을 UI로 구분하기 위함이다.

import React, { useState } from "react";
import "./App.css";
import TodoTemplate from "./component/TodoTemplate";
import TodoAdd from "./component/TodoAdd";
import CurrentDate from "./component/CurrentDate";
import TodoList from "./component/TodoList";

function App() {
  const [todos, setTodo] = useState([
    {
      id: Date.now(),
      contents: "주 2회 클라이밍 가기",
      isDone: false,
    },
    {
      id: Date.now() + 1,
      contents: "식단 관리하기",
      isDone: false,
    },
  ]);


  return (
    <>
      <TodoTemplate>
        <CurrentDate/>
        <TodoList/>
        <TodoList/>
        <TodoAdd/>
      </TodoTemplate>
    </>
  );
}

export default App;
import React, { useState } from "react";
import "./App.css";
import TodoTemplate from "./component/TodoTemplate";
import TodoAdd from "./component/TodoAdd";
import CurrentDate from "./component/CurrentDate";
import TodoList from "./component/TodoList";

 

컴포넌트를 앞으로도 잘 이용할것이라면 import하는 것을 잊지말자. 하면서도 import 오류가 발생하면 아차 싶은 1인..

 

TodoTemplate.jsx

템플릿은 이렇게 구현해주었다. children 으로 내부 JSX를 가져와서 템플릿 안 어떤 컨텐츠든 자유자재로 바뀌도록 해준다. 

import React from 'react'
import Icons from './Icons'

const TodoTemplate = ({children}) => {
  return (
    <div className='template'>
      <div className='wrap'>
        <Icons/>
        {children}
        </div>
    </div>
  )
}

export default TodoTemplate

 

html로 생각해보면 아래와 같다.

<div class = "template">
	<div class = "wrap"></div>
</div>

 

 

Icons.jsx

import React from 'react'
import icon1 from '../assets/icon1.png';
import icon2 from '../assets/icon2.png';
import icon3 from '../assets/icon3.png';
import icon4 from '../assets/icon4.png';

const Icons = () => {
    return (
        <div className='icons'>
            <img src={icon1} alt='todolist' />
            <img src={icon2} alt='icon-heart' />
            <img src={icon3} alt='icon-music' />
            <img src={icon4} alt='icon-sticker' />
        </div>
    )
}

export default Icons

 

 

 

CurrentDate.jsx

현재 날짜를 생성해주고 today에 할당해주었다. 나는 [ 요일, 일자 월 ] 이렇게 배치할거라 요일과 월은 따로 배열에 넣어주었다. 월, 요일 배열 선언을 마쳤다면 포뱃변수에 노출될 날짜를 할당해주면 된다.

import React from 'react'

const CurrentDate = ({todos}) => {
  const today = new Date();
  const month = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
  const week = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
  let curMonth = month[today.getMonth()];
  let dayWeek = week[today.getDay()];
  const formatDate = `${dayWeek}, ${today.getDate()} ${curMonth}`

  const taskCount = todos.length;

  return (
    <div>
        <h2 className='curdate'>{formatDate}</h2>
        <p className='taskCount'>{taskCount} tasks</p>
    </div>
  )
}

export default CurrentDate

 

 

 

🍯 리액트에서 아이콘 사용하는 방법

아래와 같다. 1️⃣ 명령어 입력 -> 2️⃣ React Icons에 들어간다 -> 3️⃣ 원하는 아이콘 검색 후 import

yarn add react-icons // yarn

https://react-icons.github.io/react-icons/

 

React Icons

 

react-icons.github.io

 

 

 

✨ TodoAdd.jsx

contents로 받은 값으로 새로운 객체를 생성해 기존 객체에 추가해준다. 여기서 스프레드 연산자를 사용한 이유는 불변성을 유지하기 위해서이다. 배열이니 push로 추가해도되지 않을까 할 수 있지만 push한다면 배열에 직접적으로 추가하는 것이기 때문에 값은 변할지 몰라도 메모리 주소는 바뀌지 않는다. 배열 중간에 것을 삭제하면 오류가 날 수 있고, 이것은 불변성 유지가 불가능하다고 볼 수 있다.

 

그래서 스프레드 연산자로 기존 객체를 복사해주고 그 뒤로 생성한 객체를 배열에 넣어주었다. 이러면 불변성을 유지할 수 있다.

// 할 일 추가
  const onInsert = (contents) => {
    const newTodo = {
      id: Date.now(),
      contents: contents,
    };

    setTodo([...todos, newTodo]);
  };

 

onInsert 함수를 만들어주었으니 추가 컴포넌트에서 사용할 수 있도록 props로 받아오자.

<TodoAdd onInsert={onInsert} />

 

추가 컴포넌트에서 기능 구현을 위해 사용한 리액트 Hook은 useState(), useRef(), useEffect()이다. 차례대로 이해한대로 정리를 해보면 아래와 같다.

 

✅ useSatae() : 컴포넌트의 상태를 관리해준다. 가변하는 사용자 입력 값을 동적으로 사용하기 위함.

✅ useRef() : current라는 키값을 지닌 프로퍼티가 생성되고, 값에 어떤 변경을 줄때도 current를 이용한다.
DOM요소에 접근이 가능하면, 불필요한 재렌더링을 하지 않는다는 장점이 있다.

✅ useEffect() : 컴포넌트가 렌더링될때마다 특정 작업을 실행 할 수 있다. (처음 시작할때 / 사라질때 / 업데이트 될때

 

 

 

 

우선, useState로 상태를 선언해준다. 초기값은 빈 문자열로 해준다. 위에 새로운 객체를 추가하는 함수 onInsert를 만들때 contents 값을 받아와 객체를 생성해주도록 했기때문에 onInsert(contents=사용자로부터 받아온 값) 으로 넣어준다. 생성할때 onClick이 아니라 onSubmit를 사용해준 이유는 form 태그를 사용했기 때문이다. 🍯 onSubmit 은 form 태그 내부에 이벤트를 실행할 수 있다. form 제출 이벤트가 발생할 때의 동작을 지정한다. 

const TodoAdd = ({ onInsert }) => {
    const [contents, setContents] = useState('');

    const onChange = (e) => {
        setContents(e.target.value);
    }

    const onSubmit = (e) => {
        onInsert(contents);
        setContents(''); //빈칸
    }


return (
        <>
            <div className='inserPosition'>
                <form className='insertForm' onSubmit={onSubmit}>
                    <p className='modalTitle'>Today</p>
                    <input type='text' value={contents} placeholder='할 일을 입력하고 Enter를 눌러주세요.' onChange={onChange} />
                </form>
            </div>

            <button style={addBtnstyle}}>
                <HiOutlinePlus style={{ color: '#fff', margin: '0' }} />
            </button>
        </>
    )
}

 

이때 오류가 났었는데 console.log로 확인했을 땐 값이 잘 들어가는데 새로고침되면서 값이 안보이는 오류가 생겼다. 이럴때에는 e.preventDefault(); 를 사용해주면 된다.preventDefault 를 통해 이러한 동작을 막을 수 있다.

e.preventDefault();

 

 

추가로 나는 + 버튼을 눌렀을 때 input창을 띄우는 작업을 하고 싶었다. 감이 안와서 튜터님께 여쭤보니 하고싶은 기능을 말로 해보라고 하셨다. "+ 버튼을 눌렀을 때, 인풋창을 띄우고 싶어요."  말 그대로다 처음에 useState로 인풋창의 상태를 false로 지정해주었다.

const [open, setOpen] = useState(false); //인풋창 띄우기(현재 false)

 

그리고 버튼을 눌렀을 때를 코드로 작성해보면 아래와 같다. 난 왜 응용을 못할까.. 🤬 말 그대로 버튼을 눌렀을 때 부정 연산자를 사용해서 상태를 반전(true - false, false - true) 해주는 것이다. 버튼에서 open 상태를 사용할 수 있게 속성으로 가지고 왔다.

<button style={addBtnstyle} onClick={() => setOpen(!open)} open={open}>
<HiOutlinePlus style={{ color: '#fff', margin: '0' }} />
</button>

 

이제 +를 누르면 인풋창이 뜨겠지? 했지만 오류.. 왜그런가 했더니 open 상태가 true가 되었을 때 어떤 화면이 렌더링 되는지 안정해줬기 때문이다. inserPosition 이 클래스를 띄우면 되는데 어찌할까 구글링하다 논리곱연산자로 구현할 수 있는 방법을 찾았다. 

논리곱연산자는(&&) 좌변 true일 때 우변을 실행한다. 아래 코드를 해석해보자면 속성에 open이 true 이면 <div> 태그를 실행해줘~ 다. 

return (
        <>
        {open && (
            <div className='inserPosition'>
                <form className='insertForm' onSubmit={onSubmit}>
                    <p className='modalTitle'>Today</p>
                    <input type='text' ref={inputEl} value={contents} placeholder='할 일을 입력하고 Enter를 눌러주세요.' onChange={onChange} />
                </form>
            </div>
        )}
            <button 
            style={addBtnstyle} 
            className={`rotateBtn ${open ? 'rotated' : ''}`} 
            onClick={() => setOpen(!open)} 
            open={open}>
                <HiOutlinePlus style={{ color: '#fff', margin: '0' }} />
            </button>
        </>
    )

 

실행해보면 오류 없이 잘 나타나는 것을 확인 할 수 있다. 

 

진짜 진짜 마지막으로 +버튼을 누르고 할일을 바로 치려는데 자동 커서가 안되어있어서 인풋창에 커서를 눌렀다가 써야한다는 불편함이 있었다. 그래서 열심히 구글링 다시 시작. 자동커서를 구현하기 위해선 다들 useRef 훅을 사용하는 것 같았다.

const 변수명 = useRef(초기값)

 

userRef를 먼저 생성해주고, input창에 속성으로 ref값을 설정해준다. 

const inputEl = useRef(null);
<input type='text' 
ref={inputEl} 
value={contents} 
placeholder='할 일을 입력하고 Enter를 눌러주세요.' 
onChange={onChange} />

 

버튼을 클릭했을때 input element가 focus 되어야하기 때문에 useEffect를 사용하였다. 이것도 구글링을 통해서 알게된 훅이다. 그러면 랜더링될 때마다 input 엘리먼트가 focus를 유지하는 걸 확인 할 수 있다.

useEffect(() => {
       if(inputEl.current !== null) inputEl.current.focus();  //자동커서
})

 

 


 

UI + 추가기능 완성본

 

 

 

 

 

 

🔗 참조 사이트

 

https://xiubindev.tistory.com/100

 

React Hooks : useEffect() 함수

useEffect 함수는 리액트 컴포넌트가 렌더링 될 때마다 특정 작업을 실행할 수 있도록 하는 Hook 이다. useEffect는 component가 mount 됐을 때, component가 unmount 됐을 때, component가 update 됐을 때, 특정 작업을

xiubindev.tistory.com

https://programming119.tistory.com/100

 

[JS] event.preventDefault() 간단 설명 😊/ preventDefault란?

preventDefault 란? a 태그나 submit 태그는 누르게 되면 href 를 통해 이동하거나 , 창이 새로고침하여 실행됩니다. preventDefault 를 통해 이러한 동작을 막아줄 수 있습니다. 주로 사용되는 경우는 1. a 태

programming119.tistory.com

+ 강의자료

 

 

🔗  이전 삽질

https://ejunyang.tistory.com/entry/TP-%EB%B0%94%EB%8B%90%EB%9D%BCJS%EB%A1%9C-%EC%BA%90%EB%9F%AC%EC%85%80%EA%B8%B0%EB%8A%A5-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0

 

[T.P] 바닐라JS로 캐러셀기능 구현하기

🔗 레퍼런스https://www.wavve.com/ Wavve(웨이브)세상 얕은 콘텐츠부터 세상 딥한 콘텐츠까지 JUST DIVE! Wavvewww.wavve.com  ✅  구현 사항1. 한 눈에 데이터 3개 노출하기 (swiper-slide)2. 버튼으로 슬라이드

ejunyang.tistory.com

 

 

 

 


✅  구현 사항

1. 한 눈에 데이터 3개 노출하기 (swiper-slide)

2. 버튼으로 슬라이드 동작 구현하기 (swipe-button-next / swipe-button-prev)

3. tmdb 데이터 가져와서 뿌리기


 

 

html

<!-- 슬라이드 영역 -->
        <div id="main-banner" class="swiper-container banner">
            <div class="main01-nav">
                <!-- prev, next -->
                <button class="swiper-button-next"></button>
                <button class="swiper-button-prev"></button>
            </div>

            <div id="wrapper" class="swiper-wrapper-banner">

            </div>
        </div>
<!-- 슬라이드 영역 -->

 

tmdb 데이터를 받아와서 dom을 생성해줄거라서 html에 원래 만들어놓은 코드는 빼고 시작! 전 코드보다 아주 많이 간결해졌다.

css는 건드린게 없어서 생략하고 바로 js 파일 수정을 해보자.

 

 

 

Javascript

//tmdb api
const options = {
    method: "GET",
    headers: {
        accept: "application/json",
        Authorization:
            "Bearer eyJhbGciOiJIUzI1NiJ9.eyJhdWQiOiI5ZDE3MGZlMGJlM2UwZDE3NzkyMGE3MDQxZmQ1NGM4NiIsInN1YiI6IjY2MjVkYTMzMjIxYmE2MDE3YzE1NDQ1ZSIsInNjb3BlcyI6WyJhcGlfcmVhZCJdLCJ2ZXJzaW9uIjoxfQ.tRZNYVownPtQj6yIFrodZCqJZfvFyuPxHjF_kH8JMyI",
    },
};

// 영화 장르 ID 정보
fetch("https://api.themoviedb.org/3/movie/popular?language=ko-KR&page=1", options)
    .then((response) => response.json())
    .then((data) => {
        let movieList = data['results'];
        initSlider(movieList);
    });

 

패치를 가져와서 정보를 movieList에 저장해주고, movieList 데이터를 인자로 받아 아래 만든 캐러셀 함수를 호출해준다. 패치 데이터 저장하는게 제일 쉽다 진짜.. 이것만 하고싶다..

 

function initSlider(movieList) {
    const wrapper = document.querySelector('.swiper-wrapper-banner')
    const prevBtn = document.querySelector(".swiper-button-prev");
    const nextBtn = document.querySelector(".swiper-button-next");

    movieList.forEach(a => {
        let slideimage = 'https://image.tmdb.org/t/p/w500' + a['poster_path'];
        let slidetitle = a['title'];
        let ogtitle = a['original_title'];
        let movieid = a['id'];
}

 

movieList 데이터를 순회해서 각 변수에 데이터 값을 할당해준다. 배너에 필요한 데이터는 이미지와 타이틀, 영문 타이틀이고, movieid는 배너를 눌렀을 때 id가 동일한 상세페이지로 이동해야하기 때문에 같이 선언해준다. 맨 위 wrapper, prevBtn, nextBtn은 따로 dom을 만들지 않고 이미 html에 있는 요소이므로 querySelector를 사용해서 가져왔다.

 

const swiper_slide = document.createElement('div');
        swiper_slide.className = 'swiper-slide';
        const img = document.createElement('img');
        img.className = 'banner-img';

        const slide_txt = document.createElement('div');
        slide_txt.className = 'slide-txt';
        const titleEl = document.createElement('h1');
        titleEl.className = 'slide-title'
        const ogtitleEl = document.createElement('p');
        ogtitleEl.className = 'slide-en-title';
        slide_txt.append(titleEl, ogtitleEl);


        img.src = slideimage;
        titleEl.innerText = slidetitle;
        ogtitleEl.innerText = ogtitle;

        swiper_slide.append(img, slide_txt);

 

이제 dom 을 만들어보자! 이 작업이 제일 신난다(?) 말 그대로 엘리먼트를 생성하고 생성한 엘리먼트에 클래스 네임을 붙여주어 만들어진 엘리먼트에 append하는 코드.. 쉽다 쉬워.. 이것만 하고싶다22 

 

const slide_txt = document.createElement('div'); 
const titleEl = document.createElement('h1'); 
const ogtitleEl = document.createElement('p');

 

배너 위에 올라가는 텍스트들은 slide-txt 안에 h1, p태그가 있는 구조인데 위처럼 엘리먼트만 생성해주면 slide_txt안에 h1, p태그가 생기는게 아니라 다 따로 생성되기 때문에 slide_txt.append(titleEl, ogtitleEl); 이걸 해줘야 slide_txt 안에 태그들이 들어간다.

 

 

이렇게 생긴다고 보면된다! 그래서 slide-txt에 css 를 주면 h1, p 태그 모두 적용이 가능하다. 배너 위에 올라가서 position : absolute; 를 주어야하는데 h1, p 태그 따로 주고싶지 않아서 append 했다.

 

swiper_slide.addEventListener("click", () => 
window.location.href = `detailed-page.html?id=${movieid}`
);
wrapper.append(swiper_slide);

 

배너를 클릭했을 때 id값이 동일한 상세페이지로 이동하게 해주고 html에 위에서 만든 dom을 뿌려주면 우선 영화 데이터 가져오기는 끝!

 

   const slideCount = 20; //array(20)
   //슬라이드 0번째에 있는 컨텐츠의 크기를 확인 => 이미지 사이즈 : 1250px / margin : 0 10px
   const size = 1270 

   //currentIndex = 1로 초기값 슬라이드[1]
   let currentIndex = 1;

   function updateSliderPosition() {
   	wrapper.style.transform = `translateX(${-size * currentIndex + 70}px)`; //-1270  * 1 + 70
}

 

다음은 슬라이드가 움직이도록 만들어보자. 우선 내가 가져온 영화의 수는 20개라서 slideCount도 동일하게 값을 주었다. 사이즈는 배너의 크기를 저장하는 변수로 이미지 사이즈 1250px에 margin 사이드 10px씩 더하면 메인 화면에 보이는 사이즈는 1270px 이다.

아래 +70은 메인 배너 사이드로 보이는 다음 배너와 이전배너가 조금씩 보여야하기 때문에 준 값이다.

 

function goToSlide(index) {
            wrapper.style.transition = "0.3s ease";
            //인덱스가 범위를 벗어 나는지 확인
            //노출되는 슬라이드
            if (index < 0) {
                currentIndex = slideCount - 1;
            } else if (index >= slideCount) {
                currentIndex = 0;
            } else {
                currentIndex = index;
            }
            updateSliderPosition();
        }

        //슬라이드 무한루프
        wrapper.addEventListener("transitionend", () => {
            // currentIndex가 마지막일 인덱스일 경우
            if (currentIndex === slideCount - 1) {
                //첫번째 인덱스로 돌아감
                currentIndex = 1;
                wrapper.style.transition = "0s";
                wrapper.style.transform = `translateX(${-size * currentIndex + 70}px)`;
            }
            // 첫번째 인덱스일 경우
            if (currentIndex === 0) {
                // 마지막 슬라이드 이전 슬라이드
                currentIndex = slideCount - 2;
                wrapper.style.transition = "0s";
                wrapper.style.transform = `translateX(${-size * currentIndex + 70}px)`;
            }
        });

        updateSliderPosition();

        nextBtn.addEventListener("click", () => goToSlide(currentIndex + 1));
        prevBtn.addEventListener("click", () => goToSlide(currentIndex - 1));
    });

 

데이터를 불러오기 전과 다른 점은 변수만 달라졌다는 것! 데이터가 할당된 변수로만 바꿔줬다. 

 


 

 

🔥 완성

 

흠이라면.. tmdb에서 제공하는 이미지의 화질이 좋지 않다는 것 .. img를 100% 채웠더니 다 깨진다 .. 영화진흥위원회 API를 사용해도된다고 했었는데 여긴 이미지 자체를 제공하지 않아서 기존에 작업하던 tmdb로 작업하라고 했다. 새로운 api를 사용할 수 있는 기회였는데 아쉽지만 차라리 잘됐다(?) 처음부터 시작하는 것보다 내가 원래 하던걸 제대로 마무리 하는게 나으니께 후후 "오늘도 나는 성장했다" 재훈이가 맨날 ㅋㅋㅋㅋ 오류 해결하고 하는 말인데 왠지 모르게 웃기면서도 뭔가 진짜 성장하는 것 같은 느낌이 든다..! 앞으로 나도 자주 써야할 듯~ㅋㅋ

+ Recent posts