본문 바로가기

TIL

TIL - React와 Redux를 사용한 메모 앱 구현

오늘은 Mac 기본 메모 앱을 참고하여 React와 Redux를 사용한 메모 앱을 구현했습니다. 주요 기능은 다음과 같습니다

  1. 메모의 CRUD 기능: 메모 추가(Create), 삭제(Delete), 수정(Update), 선택(Read) 기능
  2. 메모 항목 시간 순 정렬: 메모 리스트를 Date()객체를 활용해서 최신 순으로 정렬
  3. 메모 리스트 제목과 메모 내용 연동: 메모를 수정하면 사이드바에 메모 리스트의 메모 제목이 같이 수정

주요 구현 내용

 

Redux를 사용한 상태 관리

  • memo.reducer.js에서 메모와 관련된 상태를 관리하는 리듀서를 작성했습니다.
  • 메모의 추가, 삭제, 수정, 선택 기능을 위한 액션과 액션 크리에이터를 정의했습니다.
  • 로컬 스토리지를 사용하여 메모 데이터를 저장하고 불러왔습니다.

Router 설정:

  • createBrowserRouter를 사용하여 라우팅을 설정했습니다.
  • 공통 레이아웃을 적용하여 각 페이지가 동일한 레이아웃을 가지도록 구성했습니다.

메모 목록과 메모 내용 컴포넌트:

  • MemoSide 컴포넌트에서 메모 목록을 표시하고, 새로운 메모를 추가하거나 선택된 메모를 삭제하는 기능을 구현했습니다.
  • MemoArticle 컴포넌트에서 선택된 메모의 내용을 편집할 수 있도록 했습니다.

스타일링:

  • styled-components를 사용하여 컴포넌트의 스타일을 정의했습니다.
  • 레이아웃 컴포넌트를 사용하여 전체적인 앱의 레이아웃을 설정했습니다.

코드 구조

  • Redux Reducer: memo.reducer.js
  • Redux Store: store.js
  • Main App: App.jsx, main.jsx
  • Routing: router.js
  • Layout Component: Layout.jsx
  • Memo Components: MemoSide.jsx, MemoArticle.jsx
  • Styling: Layout.styled.js, MemoSide.styled.js, MemoArticle.styled.js

주요 코드

Redux Reducer (memo.reducer.js)

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

// Action Types
const ADD_MEMO = "memo/ADD_MEMO";
const DELETE_MEMO = "memo/DELETE_MEMO";
const EDIT_MEMO = "memo/EDIT_MEMO";
const SELECT_MEMO = "memo/SELECT_MEMO";

// Action Creators
export const addMemo = () => ({
  type: ADD_MEMO,
  payload: { id: Date.now(), content: "" },
});

export const deleteMemo = (id) => ({
  type: DELETE_MEMO,
  payload: id,
});

export const editMemo = (id, content) => ({
  type: EDIT_MEMO,
  payload: { id, content },
});

export const selectMemo = (id) => ({
  type: SELECT_MEMO,
  payload: id,
});

const selectMemos = (state) => state.memo.memos;
const selectSelectedMemoId = (state) => state.memo.selectedMemoId;

export const selectMemosAndSelectedMemoId = createSelector(
  [selectMemos, selectSelectedMemoId],
  (memos, selectedMemoId) => ({ memos, selectedMemoId })
);

const initialMemoId = Date.now();
const localData = JSON.parse(localStorage.getItem("memo")) || {
  memos: [],
  selectedMemoId: null,
};

const initialState = {
  memos: localData.memos.length
    ? localData.memos
    : [
        {
          id: initialMemoId,
          content: "",
        },
      ],
  selectedMemoId: localData.memos.length
    ? localData.memos[0].id
    : initialMemoId,
};

const memoReducer = (prevState = initialState, action) => {
  switch (action.type) {
    case ADD_MEMO: {
      const newMemo = action.payload;
      const updatedMemos = [newMemo, ...prevState.memos];

      localStorage.setItem(
        "memo",
        JSON.stringify({ memos: updatedMemos, selectedMemoId: newMemo.id })
      );

      return {
        ...prevState,
        memos: updatedMemos,
        selectedMemoId: newMemo.id,
      };
    }
    case DELETE_MEMO: {
      if (prevState.memos.length <= 1) {
        alert("하나 이상의 메모는 남겨두어야 합니다.");
        return prevState;
      }
      const memosAfterDeletion = prevState.memos.filter(
        (memo) => memo.id !== action.payload
      );
      localStorage.setItem(
        "memo",
        JSON.stringify({
          memos: memosAfterDeletion,
          selectedMemoId: memosAfterDeletion[0].id,
        })
      );

      return {
        ...prevState,
        memos: memosAfterDeletion,
        selectedMemoId: memosAfterDeletion[0].id,
      };
    }
    case EDIT_MEMO: {
      const updatedMemos = prevState.memos.map((memo) =>
        memo.id === action.payload.id
          ? { ...memo, content: action.payload.content }
          : memo
      );

      localStorage.setItem(
        "memo",
        JSON.stringify({
          memos: updatedMemos,
          selectedMemoId: prevState.selectedMemoId,
        })
      );

      return {
        ...prevState,
        memos: updatedMemos,
      };
    }
    case SELECT_MEMO: {
      return {
        ...prevState,
        selectedMemoId: action.payload,
      };
    }
    default:
      return prevState;
  }
};

export default memoReducer;

 

Router 설정 (router.js)

import { createBrowserRouter } from "react-router-dom";
import Layout from "../components/Layout";
import MainPage from "../pages/MainPage";

const router = createBrowserRouter([
  {
    path: "/",
    element: <Layout />,
    children: [
      {
        path: "/",
        element: <MainPage />,
      },
      {
        path: "/memo/:memoId",
        element: <MainPage />,
      },
    ],
  },
]);

export default router;

 

 

Memo Components (MemoSide.jsx, MemoSide.jsx)

// MemoSide.jsx
import { useDispatch, useSelector } from "react-redux";
import { Link } from "react-router-dom";
import {
  addMemo,
  deleteMemo,
  selectMemo,
  selectMemosAndSelectedMemoId,
} from "../../../redux/reducers/memo.reducer";
import { NowTime } from "../NowTime/NowTime";
import {
  MemoAside,
  MemoItem,
  MemoList,
  MemoSideHeader,
} from "./MemoSide.styled";

function MemoSide() {
  console.log("MemoSide 리랜더링");
  const dispatch = useDispatch();
  const { memos, selectedMemoId } = useSelector(selectMemosAndSelectedMemoId);

  const handleSelectMemo = (id) => {
    dispatch(selectMemo(id));
  };

  const handleAddMemo = () => {
    dispatch(addMemo());
  };

  const handleDeleteMemo = () => {
    if (memos.length <= 1) {
      alert("하나 이상의 메모는 남겨두어야 합니다.");
      return;
    }
    dispatch(deleteMemo(selectedMemoId));
  };

  return (
    <MemoAside>
      <MemoSideHeader>
        <button onClick={handleAddMemo}>새 메모 작성하기</button>
        <button onClick={handleDeleteMemo}>삭제</button>
      </MemoSideHeader>
      <MemoList>
        {memos.map((memo) => (
          <Link key={memo.id} to={`/memo/${memo.id}`}>
            <MemoItem
              key={memo.id}
              onClick={() => handleSelectMemo(memo.id)}
              $isSelected={memo.id === selectedMemoId}
            >
              <h6>{memo.content || "새로운 메모"}</h6>
              <time>{NowTime("time", memo.id)}</time>
            </MemoItem>
          </Link>
        ))}
      </MemoList>
    </MemoAside>
  );
}

export default MemoSide;
// MemoArticle.jsx
import { useDispatch, useSelector } from "react-redux";
import {
  editMemo,
  selectMemosAndSelectedMemoId,
} from "../../../redux/reducers/memo.reducer";
import { NowTime } from "../NowTime/NowTime";
import { CurrentTime, StrMemoArticle } from "./MemoArticle.styled";

function MemoArticle() {
  const dispatch = useDispatch();
  const { memos, selectedMemoId } = useSelector(selectMemosAndSelectedMemoId);
  const memo = memos.find((memo) => memo.id === selectedMemoId) || {};

  const handleContentChange = (e) => {
    dispatch(editMemo(memo.id, e.target.value));
  };

  return (
    <StrMemoArticle>
      <CurrentTime>{NowTime("datetime")}</CurrentTime>
      <textarea
        value={memo.content}
        name="memo"
        id={memo.id}
        onChange={handleContentChange}
      ></textarea>
    </StrMemoArticle>
  );
}

export default MemoArticle;

마무리

오늘 구현한 메모 앱을 Redux와 React Router를  통해 구현 해봤습니다. Redux reducer에서 로컬 스토리지를 활용하여 CRUD 기능을 구현하고 조건부 렌더링 styled-components를 사용한 스타일링 기법을 연습 했습니다.