영단어장 만들기

손윤주·2022년 6월 5일
0
post-thumbnail

리덕스, 파이어베이스와 함께한 한 주의 회고 ✍️

지난 주차에 리액트에서 state관리는 단순히 부모컴포넌트에서 자식컴포넌트로 props를 넘겨주는 형태였다. 그러나 이번 주차에는 리덕스로 state관리하는 법, 파이어베이스를 이용해서 데이터를 저장하고, CRUD하는 법을 알게되고 직접 적용해보면서 리액트에서의 데이터관리에 눈을 떴다! 👀


리덕스는 뭐고, 왜 필요할까?

부모 컴포넌트에서 자식컴포넌트로 데이터를 넘겨주는 형태로만 한다면 A, B, C, D 컴포넌트가 있을 때, D컴포넌트에서 A컴포넌트의 데이터를 받아오려면 B,C컴포넌트를 거쳐서 받아와야 하기 때문에 불필요한 작업이 반복된다. (이를 props drilling이라고 함)

때문에 전역 저장소 를 따로 만들어두고, 데이터가 필요한 컴포넌트에서 전역 저장소의 데이터를 바로 받아오게 된다면 훨씬 효율적이다. 리덕스가 바로 전역 저장소 역할을 하는 것!

파이어베이스는 뭐고, 왜 필요할까?

FE개발자의 경우 서버관리를 직접하기는 어려운데, 파이어 베이스는 이를 해결해주는 개발도구이다. 데이터관리 뿐 아니라 앱 통계, 분석 등 마케팅에 도움이 되는 데이터도 제공하는 유용한 도구인데, 이번 주 개인프로젝트를 진행하며 리덕스와 파이어베이스를 연결해서 내 앱의 데이터를 보관하고, 업데이트된 내용을 동기화해서 데이터가 항상 최신 상태로 유지되게끔 했다.


영단어장을 만들었어요 📘

영어 단어 암기를 위한 영단어장을 만들었다! 메인화면의 모습인데, 새로운 영단어를 추가하고 삭제하고 암기한 단어는 완료표시하고, 만들어놓은 단어 내용을 수정할 수 있는 기능을 담고 있다.

✔️ 생성 - 새로운 단어카드 추가하기
✔️ 업데이트 - 암기한 단어카드 완료 표시하기
✔️ 삭제 - 단어카드 삭제하기
✔️ 수정 - 기존 단어카드 내용 수정하기

가장 먼저 뷰를 짜고, 구현할 기능을 위 순서대로 나열한 후 하나씩 해결해나갔다.


1. 뷰 만들기

컴포넌트를 크게 나누면 4개로 나뉜다.

1. UI에 고정적으로 있는 헤더 + 배경(도트무늬)
2. 단어카드
3. 단어 추가하기 UI
4. 단어 수정하기 UI

사실 3, 4번은 UI구성이 동일하기 때문에 컴포넌트를 하나만 두고
새로 생성하는 데이터면 추가하기로, 기존 데이터면 수정하기로 타이틀이 바뀌도록 할수도 있었지만 이번 주는 리덕스와 친해지는 걸 최대목표로 했기 때문에 헷갈리지 않도록 나눠놨다.

메인화면 하단에 추가하기 플러스버튼은 마우스오버시 90도로 돌아가는 애니메이션을 넣었다.

const Plus = styled.img`
  width: 28px;
  height: 28px;
  transition: transform 0.3s ease-in-out;

  z-index: 1;
  &:hover {
    transform: rotate(90deg);
  }
`;

2. 단어카드 추가하기

기능구현에 앞서 리덕스+파이어베이스 뼈대를 만들었다.
단어카드들을 파이어베이스에서 불러오고(load), 새로운 단어를 추가(create)하는 함수가 들어있다.


// memo.js 

// 파이어베이스 기능 불러오기
import { db } from "../../firebase";
import {
  collection,
  doc,
  getDoc,
  getDocs,
  addDoc,
  updateDoc,
  deleteDoc,
} from "firebase/firestore";

// Actions 액션
const LOAD = "memo/LOAD";
const CREATE = "memo/CREATE";

// Reducer 액션에 따른 명령을 보내고, 데이터 수정요청을 받는 곳
export default function reducer(state = initalState, action = {}) {
  switch (action.type) {
   case "memo/LOAD": {
      console.log("이제 값을 불러올거야");
      console.log(action.memo.sort());

      return { list: action.memo, is_loaded: true };
    }
      
    case "memo/CREATE": {
      console.log("이제 값을 만들거야");
      const new_memo_list = [...state.list];
      return { ...state, list: new_memo_list };
    }
        default:
      return state;
  }
}

// initalState 초기값
const initalState = {
  list: [
    {
      word: "Hello",
      pinyin: "həˈloʊ",
      def: "안녕",
      ExEn: "Hello John, how are you? ",
      ExKo: "안녕하세요, 존. 어떻게 지내세요?",
      completed: 0,
    },
    {
      word: "snack",
      pinyin: "snæk",
      def: "간단한 식사",
      ExEn: "a mid-morning snack",
      ExKo: "오전 중간의 간식",
      completed: 0,
    },
    {
      word: "foolishly",
      pinyin: "ˈfuːlɪʃli",
      def: "바보",
      ExEn: "Are you a fool?",
      ExKo: "당신 바보야?",
      completed: 0,
    },
  ],
};


// Action Creators 액션을 생성하는 함수
export function loadMemo(memo) {
  return { type: LOAD, memo };
}

export function createMemo(memo) {
  console.log("액션을 생성할거야!");
  return { type: CREATE, memo };
}

/// middlewares(파이어베이스랑 통신하는 부분)
export const loadMemoFB = () => {
  return async function (dispatch) {
    const memo_data = await getDocs(collection(db, "memo"));
    let memo_list = [];
    memo_data.forEach((memo) => {
      memo_list.push({ id: memo.id, ...memo.data() });
    });
    dispatch(loadMemo(memo_list));
  };
};

export const createMemoFB = (memo) => {
  return async function (dispatch) {
    const docRef = await addDoc(collection(db, "memo"), memo);
    const memo_data = { id: docRef.id, ...memo };
    dispatch(createMemo(memo_data));
  };
};

initalState 에는 더미데이터 객체 3개를 넣어놓고 카드를 추가하는 createMemoFB 함수를 만든 후 Write.js 컴포넌트에 해당 함수를 import 하고 아래 저장하기 버튼 클릭 시 단어카드에 새로운 단어가 추가되도록 했다.


//Write.js

// 파이어베이스와 통신하는 함수 불러오고
  import { createMemoFB } from "./redux/modules/memo";
  
const Write = (props) => {
  const history = useHistory();
  const dispatch = useDispatch();

  //Ref 변수 선언
  const word = React.useRef(null);
  const pinyin = React.useRef(null);
  const def = React.useRef(null);
  const ExEn = React.useRef(null);
  const ExKo = React.useRef(null);

  //온클릭 발생 시 실행할 함수 선언 = 인풋에 입력한 데이터를 받아온다.
  const addMemoList = () => {
    dispatch(
      createMemoFB({
        word: word.current.value,
        pinyin: pinyin.current.value,
        def: def.current.value,
        ExEn: ExEn.current.value,
        ExKo: ExKo.current.value,
        completed: 0,
      })
    );
  };

  return (
    <>
      <Wrap>
        <Title>단어 추가하기</Title>
        <Form>
          <P>단어</P>
          <Input
            type="text"
            ref={word}
            title="단어"
            idText="input-word"
            required
          />
          <P>병음</P>
          <Input
            type="text"
            ref={pinyin}
            title="병음"
            idText="input-pinyin"
            required
          />
          <P>의미</P>
          <Input
            type="text"
            ref={def}
            title="의미"
            idText="input-def"
            required
          />
          <P>예문</P>
          <Input
            type="text"
            ref={ExEn}
            title="예문"
            idText="input-ex-en"
            required
          />
          <P>해석</P>
          <Input
            type="text"
            ref={ExKo}
            title="해석"
            idText="input-ex-ko"
            required
          />
          <br />
              
          //저장하기 버튼 클릭 시 위에 선언해두었던 단어추가 함수 실행!
          <Btn
            type="submit"
            onClick={async () => {
              addMemoList();
              history.push("/");
            }}
          >
            저장하기
          </Btn>
        </Form>
      </Wrap>
    </>
  );
};

3. 단어카드 업데이트하기

암기가 완료된 단어카드의 '완료' 버튼을 누르면 카드 배경색이 변하도록 하는 기능이다.
완료 버튼을 누를 때마다 해당 카드의 completed 값을 +1 해서 값이 홀수일 경우 완료색으로 변한다.(토글버튼)
업데이트도 마찬가지로 저장소 파일에 액션 생성함수를 만들어두고 해당 컴포넌트 파일에 import 시켜 사용했다.


// memo.js 

// Actions
const UPDATE = "memo/UPDATE";

// Reducer 여기에서 completed 값을 +1 시켜줌!
export default function reducer(state = initalState, action = {}) {
  switch (action.type) {
          case "memo/UPDATE": {
      const new_memo_list = state.list.map((l, idx) => {
        if (parseInt(action.memo_index) === idx) {
          return { ...l, completed: l.completed + 1 };
        } else {
          return l;
        }
      });
      return { ...state, list: new_memo_list };
    }
          default:
      return state;
  }
}

// Action Creators
export function updateMemo(memo_index) {
  console.log("암기를 완료할거야!");
  return { type: UPDATE, memo_index };
}

/// middlewares(파이어베이스랑 통신하는 부분)
export const updateMemoFB = (memo_id, memo_completed) => {
  return async function (dispatch, getState) {
    const docRef = doc(db, "memo", memo_id);
    await updateDoc(docRef, { completed: memo_completed + 1 });

    const _memo_list = getState().memo.list;
    const memo_index = _memo_list.findIndex((m) => {
      return m.id === memo_id;
    });

    dispatch(updateMemo(memo_index));
  };
};

그리고 Card 컴포넌트에 만든 함수를 넣어주고, styled componentscompleted 값이 홀수일 때마다 배경색이 완료색(회색)으로 바뀌도록 했다.


// Card.js 
import React from "react";
import styled from "styled-components";
import { useHistory } from "react-router-dom";
import { useSelector } from "react-redux";

import { useDispatch } from "react-redux";
import { loadMemoFB, updateMemoFB } from "./redux/modules/memo";


const Card = (props) => {
  const my_lists = useSelector((state) => state.memo.list);
  const dispatch = useDispatch();
  const history = useHistory();

  React.useEffect(() => {
    dispatch(loadMemoFB());
  }, []);

  return (
    <>
      {my_lists.map((list, index) => {
        return (
          <CardBox
            key={index}
            // 여기에서 completed를 짝수로 선언함! 짝수가 아닐 경우, 배경색이 변하도록 함.
            completed={list.completed % 2 === 0}
          >
            <Ul>
              <Li1>{list.word}</Li1>
              <Li>[{list.pinyin}]</Li>
              <Li3>{list.def}</Li3>
              <Li style={{ color: "#1554FF" }}>{list.ExEn}</Li>
              <Li style={{ color: "#1554FF" }}>{list.ExKo}</Li>
            </Ul>
            <Btns>
              <Btn
                onClick={() => {
                  dispatch(updateMemoFB(list.id, list.completed));
                }}
              > 
                // 완료가 되면 버튼글자가 ✔️로 바뀌게 함!
                {list.completed % 2 === 0 ? "완료" : "✔️"}
              </Btn>
              <Btn
                onClick={() => {
                  history.push(`/write/modifi/${(list.word, index)}`);
                }}
              >
                수정
              </Btn>
              <Btn
                onClick={() => {
                  history.push("/");
                }}
              >
                삭제
              </Btn>
            </Btns>
          </CardBox>
        );
      })}
    </>
  );
};

const CardBox = styled.div`
  border: 1px solid black;
  border-radius: 8px;

  display: flex;
  flex-direction: row;
  justify-content: space-between;
  padding: 20px;

/* 여기에서 배경색을 바꿔줌! */
  background-color: ${(props) => (props.completed ? "transparent" : "#ddd")};
  &:hover {
    box-shadow: 5px 5px 20px #e6e6e6;
  }
`;

export default Card;

4. 단어카드 삭제하기

삭제 버튼을 클릭한 단어의 id값을 deleteDoc 을 통해 리스트에서 제거한다.
아래 영상은 삭제 버튼 클릭하면 해당 카드가 사라지고 다음 카드가 넘어오는 모습이다.


// memo.js 

// Actions
const DELETE = "memo/DELETE";

// Reducer
export default function reducer(state = initalState, action = {}) {
  switch (action.type) {
    case "memo/DELETE": {
      const new_memo_list = state.list.filter((l, idx) => {
        return parseInt(action.memo_index) !== idx;
      });
      return { ...state, list: new_memo_list };
    }
    default:
      return state;
  }
}

// Action Creators
export function deleteMemo(memo_index) {
  console.log("지울 인덱스", memo_index);
  return { type: DELETE, memo_index };
}

// middlewares(파이어베이스랑 통신하는 부분)
export const deleteMemoFB = (memo_id) => {
  return async function (dispatch, getState) {
    if (!memo_id) {
      window.alert("아이디가 없네요!");
      return;
    }
    const docRef = doc(db, "memo", memo_id);
    await deleteDoc(docRef);

    const _memo_list = getState().memo.list;
    const memo_index = _memo_list.findIndex((m) => {
      return m.id === memo_id;
    });

    dispatch(deleteMemo(memo_index));
  };
};

마찬가지로 Card.js 삭제 버튼에 저장소에서 생성한 deleteMemoFB 함수를 import 한 후 사용한다.


// Card.js 
import { deleteMemoFB } from "./redux/modules/memo";

...
 {my_lists.map((list, index) => {
        return (
...
          
  <Btn
  onClick={() => {
    history.push("/");
    dispatch(deleteMemoFB(list.id));
  }}
    >
      삭제
  </Btn>

...

  );
};

5. 단어카드 수정하기

수정할 단어카드의 수정버튼 클릭 시 수정하기 컴포넌트로 넘어가고 기존 데이터를 보여주고 수정할 수 있게끔 했다. 위 기능들 중에서는 가장 복잡해 보이지만 사실 구조는 동일하다.


// memo.js 

// Actions
const MODIFI = "memo/MODIFI";

// Reducer
export default function reducer(state = initalState, action = {}) {
  switch (action.type) {
          case "memo/MODIFI": {
      console.log("값을 수정할거야!");

      const new_memo_list = state.list.map((l, idx) => {
        if (parseInt(action.memo_index) === idx) {
          return { ...l };
        } else {
          return l;
        }
      });
      return { ...state, list: new_memo_list };
    }
          default:
      return state;
  }
}

// Action Creators
export function modifiMemo(memo_index) {
  console.log("수정할 인덱스", memo_index);
  return { type: MODIFI, memo_index };
}

// middlewares(파이어베이스랑 통신하는 부분)
export const modifiMemoFB = (modifi, memo_id) => {
  return async function (dispatch, getState) {
    const docRef = doc(db, "memo", memo_id);
    await updateDoc(docRef, {
      word: modifi.word,
      pinyin: modifi.pinyin,
      def: modifi.def,
      ExEn: modifi.ExEn,
      ExKo: modifi.ExKo,
      completed: modifi.completed,
    });

    const _memo_list = getState().memo.list;
    _memo_list.findIndex((m) => {
      return m.id === memo_id;
    });

    dispatch(updateMemo(memo_id));
  };
};

수정 버튼 클릭 시 Modifi.js 파일로 넘어가고, 여기에 저장소에서 생성해둔 modifiMemoFB 함수를 import 했다.

input에 value 를 넣으면 기존 값 수정이 불가하지만, defaultValue를 넣으면 기존 값 수정이 가능하다.


// Modifi.js 

import { modifiMemoFB } from "./redux/modules/memo";

const Modifi = (props) => {
  const history = useHistory();
  const dispatch = useDispatch();
  const words = useParams();

  const my_lists = useSelector((state) => state.memo.list);

  const word = React.useRef(null);
  const pinyin = React.useRef(null);
  const def = React.useRef(null);
  const ExEn = React.useRef(null);
  const ExKo = React.useRef(null);

  const list = my_lists[words.words];
  const memo_id = list.id;
  
  // 새로운 데이터를 넣고, modifiMemoFB를 dispatch할 새로운 함수 생성!
  const modifiMemo = (memo_id) => {
    const modifi = {
      word: word.current.value,
      pinyin: pinyin.current.value,
      def: def.current.value,
      ExEn: ExEn.current.value,
      ExKo: ExKo.current.value,
      completed: 0,
    };
    // 여기에 저장소에서 생성해둔 modifiMemoFB 넣음!
    dispatch(modifiMemoFB(modifi, memo_id));
  };

  return (
    <>
      <Wrap>
        <Title>단어 수정하기</Title>
        <Form>
          <P>단어</P>
          <Input
            type="text"
            ref={word}
            title="단어"
            // defaultValue 를 통해 input에 기존 값을 보여줌!
            defaultValue={list.word}
            idText="input-word"
            required
          ></Input>
          <P>병음</P>
          <Input
            type="text"
            ref={pinyin}
            title="병음"
            defaultValue={list.pinyin}
            idText="input-pinyin"
            required
          />
          <P>의미</P>
          <Input
            type="text"
            ref={def}
            title="의미"
            defaultValue={list.def}
            idText="input-def"
            required
          />
          <P>예문</P>
          <Input
            type="text"
            ref={ExEn}
            title="예문"
            defaultValue={list.ExEn}
            idText="input-ex-en"
            required
          />
          <P>해석</P>
          <Input
            type="text"
            ref={ExKo}
            title="해석"
            defaultValue={list.ExKo}
            idText="input-ex-ko"
            required
          />
          <br />
          // 수정완료 버튼 클릭 시 modifiMemo 함수 실행 후 메인페이지로 이동!
          <Btn
            type="submit"
            onClick={() => {
              modifiMemo(memo_id);
              history.push("/");
            }}
          >
            수정완료
          </Btn>
        </Form>
      </Wrap>
    </>
  );
};

export default Modifi;

완성된 앱은 여기서 확인 가능해요 👀

영단어장 깃헙 링크


이번 주 WIL 키워드

라이프사이클(클래스형 vs 함수형)

클래스형

생성될 때 (마운트 될 때 - Mounting) : constructor, render, componentDidMount
업데이트할 때(Updating) : render, componentDidUpdate
제거할 때 (마운트 해제 될 때 - Unmounting) : componentWillUnmount

함수형

함수형 컴포넌트에는 "리액트 훅"이 있는데, 훅으로 함수형 컴포넌트들을 관리합니다.클래스형 라이프사이클에 나왔던 모든 phases( componentDidMount, componentDidUpdate, componentWillUnmount)는 useEffect Hook에 의해 실행됩니다.


react hooks

함수 컴포넌트도 클래스 컴포넌트처럼 사용할 수 있도록 하기위해 탄생!
여기에서 다양한 훅을 살펴보자

0개의 댓글