React와 Redux를 사용해서 CRUD를 구현한 단어장 웹 페이지 만들어보기
(D는 구현 못함)
메인 화면단. 카드 컴포넌트를 갖고와서 뿌려준다.
import styled from "styled-components";
import { Route, Routes } from 'react-router-dom';
import { useSelector } from 'react-redux';
import Card from './Card';
import AddWord from './AddWord';
import Modify from './Modify';
// 로딩 시간중 화면을 가려준다.
import Spinner from './Spinner';
function App() {
// 리덕스의 상태중에 is_loaded의 값을 갖고온다.
// 초깃값에 is_loaded = false; 값을 주면 데이터를 받아서 화면에 뿌려지기 전까지는
// is_loaded 값이 false값이다.
const is_loaded = useSelector(state => state.word.is_loaded);
return (
<>
<TitleStyled>나만의 단어장</TitleStyled>
<ContainerStyled>
{/* react-router-dom v6라서 Swich가 아니라 Routes로 감싸준다. */}
<Routes>
<Route path="/" element={<Card />} />
<Route path="/add" element={<AddWord />} />
<Route path="/modify/:index" element={<Modify />} />
</Routes>
</ContainerStyled>
{/* is_loaded의 값이 false일 때는 아직 데이터를 모두 받아온 게 아니다.
!is_loaded면 true가 되므로 Spinner 컴포넌트 호출해서 화면 가리기 */}
{!is_loaded && <Spinner />}
</>
);
}
const TitleStyled = styled.h1`
...
`;
const ContainerStyled = styled.div`
...
`;
export default App;
카드 하나에 대한 컴포넌트
import React from 'react';
import styled from 'styled-components';
import { Link } from 'react-router-dom';
import { useSelector, useDispatch } from 'react-redux';
import { loadWordFB, completedWordFB } from './redux/modules/word';
import Button from '@mui/material/Button';
import SendIcon from '@mui/icons-material/Send';
import SaveIcon from '@mui/icons-material/Save';
const Card = () => {
// dic_list 라는 이름으로 리덕스에서 데이터 리스트를 받아온다.
const dic_list = useSelector((state) => state.word.list);
const dispatch = useDispatch();
// 처음 페이지를 열었을 시 dispatch로 loadWordFB()를 실행시킨다.
// 파이어스토어에서 데이터를 받아온 다음에 화면에 뿌려준다.
// load를 하면서 is_loaded값도 true로 변경해준다.
React.useEffect(() => {
dispatch(loadWordFB());
}, [dispatch]);
// completed 값을 true, false 변경하는 함수
const completedWord = async (dic) => {
await dispatch(completedWordFB(dic))
}
return (
<>
{dic_list &&
// 리덕스에서 받아온 데이터 리스트를 map을 돌려서 dic이란 이름으로 하나씩 꺼내온다.
dic_list.map((dic, idx) => {
return (
// 삼항연산자를 이용해서 데이터에서 completed 값이 true일 경우와
// false일 경우에 따라 스타일을 다르게 준다.
dic.completed === true
?
<CardStyled key={dic.id}>
<span>단어</span>
<p className="word">{dic.word}</p>
<span>설명</span>
<p className="content">{dic.content}</p>
<span>예문</span>
<p className="explain">{dic.explain}</p>
{/* 버튼 클릭 시 completedWord 함수에 dic 데이터를 파라미터로 넘겨준다.*/}
<Button variant="contained" color="success" onClick={() => {
completedWord(dic)
}}>공부함</Button>
{/* Link를 이용해서 해당 카드의 인덱스값을 기준으로 이동시킨다. */}
<Link to={`/modify/${idx}`} style={{ textDecoration: 'none' }}>
<Button variant="contained" endIcon={<SendIcon />}>
수정하기
</Button>
</Link>
</CardStyled>
:
<CompletedCardStyled key={dic.id}>
<span>단어</span>
<p className="word">{dic.word}</p>
<span>설명</span>
<p className="content">{dic.content}</p>
<span>예문</span>
<p className="explain">{dic.explain}</p>
<Button variant="contained" onClick={() => {
completedWord(dic)
}}>생각해보니 공부 안함</Button>
<Link to={`/modify/${idx}`} style={{ textDecoration: "none" }}>
<Button variant="contained" endIcon={<SendIcon />} >
수정하기
</Button>
</Link>
</CompletedCardStyled>
)
})
}
{/* 해당 버튼 클릭 시 단어 추가 페이지로 이동한다. */}
<Link to="/add" style={{ textDecoration: "none" }}>
<AddBtnStyled>
<Button color="secondary" endIcon={<SaveIcon />} variant="contained">
단어 추가하기
</Button>
</AddBtnStyled>
</Link>
</>
)
}
const CardStyled = styled.div`
...
`;
const CompletedCardStyled = styled.div`
...
`;
const AddBtnStyled = styled.div`
...
`;
export default Card;
카드를 추가하는 페이지 컴포넌트
import React from 'react';
import styled from 'styled-components';
import { createWordFB } from './redux/modules/word';
import { useDispatch } from 'react-redux';
import { useNavigate } from "react-router-dom";
const AddWord = () => {
const dispatch = useDispatch();
const inputWord = React.useRef(null);
const inputContent = React.useRef(null);
const inputExplain = React.useRef(null);
const navigate = useNavigate();
const addWordList = () => {
dispatch(createWordFB(
{
word: inputWord.current.value,
content: inputContent.current.value,
explain: inputExplain.current.value,
completed: false,
}
));
window.alert("단어가 추가되었습니다.");
// 주소 "/" 로 이동한다.
navigate("/");
}
return (
<>
<AddWordStyled>
<span>단어</span>
<input className="word" ref={inputWord}></input>
<span>설명</span>
<input className="content" ref={inputContent}></input>
<span>예문</span>
<input className="explain" ref={inputExplain}></input>
<button onClick={addWordList}>추가하기</button>
</AddWordStyled>
</>
)
}
const AddWordStyled = styled.div`
...
`;
export default AddWord;
해당 카드의 내용을 수정하는 페이지 컴포넌트
import React from 'react';
import styled from 'styled-components';
import { useNavigate, useParams } from 'react-router-dom';
import { useSelector } from 'react-redux';
import { useDispatch } from 'react-redux';
import { updateWordFB } from './redux/modules/word';
const Modify = () => {
const dispatch = useDispatch();
const navigate = useNavigate();
const { index } = useParams();
const { id, word, content, explain } = useSelector(state => state.word.list[index])
const inputWord = React.useRef(null);
const inputContent = React.useRef(null);
const inputExplain = React.useRef(null);
const ModifyWordList = () => {
dispatch(updateWordFB({
id: id,
word: inputWord.current.value,
content: inputContent.current.value,
explain: inputExplain.current.value,
}))
window.alert("단어를 수정했습니다.");
navigate("/");
}
return (
<>
<ModifyStyled>
<span>단어</span>
<input className="word" ref={inputWord} defaultValue={word}></input>
<span>설명</span>
<input className="content" ref={inputContent} defaultValue={content}></input>
<span>예문</span>
<input className="explain" ref={inputExplain} defaultValue={explain}></input>
<button onClick={ModifyWordList}>수정하기</button>
</ModifyStyled>
</>
)
}
const ModifyStyled = styled.div`
...
`;
export default Modify;
리덕스의 action, action create function, reducer, middleware에 대한 부분들을 모아놓은 파일
import { doc, collection, getDocs, addDoc, updateDoc } from "firebase/firestore";
import { db } from '../../firebase';
const initialState = {
// 맨 처음에 is_loaded를 false로 줘서 Spinner 컴포넌트가 화면을 가리게 해준다.
is_loaded: false,
list: []
}
// Actions
const LOAD = 'words/LOAD';
const CREATE = 'words/CREATE';
const COMPLETED = 'words/COMPLETED';
const UPDATE = 'words/UPDATE';
// Action Creators
export function loadWord(word_list) {
return { type: LOAD, word_list };
}
export function createWord(word_data) {
return { type: CREATE, word_data };
}
export function completedWord(word_index) {
return { type: COMPLETED, word_index };
}
export function updateWord(word) {
return { type: UPDATE, word };
}
// middlewares
export function loadWordFB() {
return async function (dispatch) {
const word_data = await getDocs(collection(db, "words"));
let word_list = [];
word_data.forEach((doc) => {
word_list.push({ id: doc.id, ...doc.data() });
})
dispatch(loadWord(word_list));
}
}
export function createWordFB(word) {
return async function (dispatch) {
const docRef = await addDoc(collection(db, "words"), word);
const word_data = { id: docRef.id, ...word };
dispatch(createWord(word_data));
}
}
export function completedWordFB(word) {
return async function (dispatch, getState) {
const docRef = doc(db, "words", word.id);
await updateDoc(docRef, { completed: !word.completed });
const word_list = getState().word.list;
const word_index = word_list.findIndex((w) => {
return w.id === word.id;
})
dispatch(completedWord(word_index));
dispatch(loadWordFB());
}
}
export function updateWordFB(word, id) {
return async function (dispatch) {
const docRef = doc(db, "words", word.id);
await updateDoc(docRef, {
word: word.word,
content: word.content,
explain: word.explain,
});
const new_word = { ...word, id };
dispatch(updateWord(new_word));
}
}
// Reducer
export default function reducer(state = initialState, action = {}) {
switch (action.type) {
case "words/LOAD":
return { list: action.word_list, is_loaded: true };
case "words/CREATE": {
const new_word_list = [...state.list, action.word_data];
console.log(new_word_list)
return { list: new_word_list, ...state };
}
case "words/COMPLETED": {
const new_word_list = state.list.map((word) => {
return word.id === action.word_index
? { ...word, completed: !word.completed }
: word
});
return { ...state, list: new_word_list };
}
case "words/UPDATE": {
const new_word_list = state.list.map((word) => {
return word.id === action.word.id
? { ...word, ...action.word }
: word
})
return { ...state, list: new_word_list };
}
default: return state;
}
}