웹 풀스택
[React / ts] Task App 만들기 - ① board 만들기
kevinmj12
2025. 4. 15. 16:59
React / TypeScript를 활용하여 간단한 Task를 관리해주는 웹페이지를 만들어보자.
먼저 위 사진에서 + 버튼을 눌러 게시물을 여러 개 만들 수 있고,
게시물을 선택하여 게시물에 해당하는 Task를 볼 수 있도록 할 것이다.
Redux 사전 세팅
먼저 디렉토리 구조는 다음과 같다.
// store/slices/boardsSlice.ts
import { createSlice } from "@reduxjs/toolkit";
import { IBoard } from "../../types";
type TBoardState = {
modalActive: boolean;
boardArray: IBoard[];
};
const initialState: TBoardState = {
modalActive: false,
boardArray: [
{
boardId: "board-0",
boardName: "첫 번째 게시물",
lists: [
{
listId: "list-0",
listName: "List 0",
tasks: [
{
taskId: "task-0",
taskName: "Task 0",
taskDescription: "Description",
taskOwner: "Minje",
},
{
taskId: "task-1",
taskName: "Task 1",
taskDescription: "Description",
taskOwner: "Minje",
},
],
},
{
listId: "list-1",
listName: "List 1",
tasks: [
{
taskId: "task-2",
taskName: "Task 2",
taskDescription: "Description",
taskOwner: "Minje",
},
{
taskId: "task-3",
taskName: "Task 3",
taskDescription: "Description",
taskOwner: "Minje",
},
],
},
],
},
],
};
const boardsSlice = createSlice({
name: "boards",
initialState,
reducers: {},
});
export const boardsReducer = boardsSlice.reducer;
먼저 board 상태를 관리하기 위한 redux를 정의하자.
boardsSlice라는 이름으로 createSlice()를 한 뒤, boardsSlice의 reducer를 export 해준다.
// store/reducer/reducer.ts
import { boardsReducer } from "../slices/boardsSlice";
const reducer = {
boards: boardsReducer,
};
export default reducer;
export해준 boardsSlice의 reducer는 reducer.ts에서 관리해준다.
이후에 reducer.ts에는 다른 reducer들이 추가될 것이고, reducer.ts의 reducer를 통해 모든 reducer를 관리할 수 있게 된다.
// store/index.ts
import { configureStore } from "@reduxjs/toolkit";
import reducer from "./reducer/reducer";
const store = configureStore({
reducer: reducer,
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
export default store;
이제 reducer를 store에 등록해준다.
그리고 store로부터 상태를 가져올 때 타입을 편리하게 사용할 수 있도록 RootState와 AppDispatch 타입을 정의해주었다.
// hooks/redux.ts
import { TypedUseSelectorHook, useDispatch } from "react-redux";
import { useSelector } from "react-redux";
import { RootState, AppDispatch } from "../store";
export const useTypedSelector: TypedUseSelectorHook<RootState> = useSelector;
export const useTypedDispatch = () => useDispatch<AppDispatch>();
store로부터 상태를 가져오거나 상태를 업데이트할 때 사용할 useTypedSelector와 useTypedDispatch이다.
위에서 RootState와 AppDispatch 타입을 정의해두었기 때문에 이를 사용하여 타입을 정의해주었다.
// main.tsx
import { createRoot } from "react-dom/client";
import "./index.css";
import App from "./App.tsx";
import store from "./store/index.ts";
import { Provider } from "react-redux";
createRoot(document.getElementById("root")!).render(
<Provider store={store}>
<App />
</Provider>
);
마지막으로 main에서 <App>을 <Provider store={store}>로 감싸주면 board 관련 redux를 사용할 준비는 끝났다!
기능 구현
// BoardList.tsx
import { FiPlusCircle } from "react-icons/fi";
import { useRef, useState } from "react";
import { useTypedSelector } from "../../hooks/redux";
import { IBoard } from "../../types";
import {
addButton,
addSection,
boardItem,
boardItemActive,
container,
title,
} from "./BoardList.css";
import clsx from "clsx";
import SideForm from "./SideForm/SideForm";
type TBoardListProps = {
activeBoardId: string;
setActiveBoardId: React.Dispatch<React.SetStateAction<string>>;
};
const BoardList: React.FC<TBoardListProps> = ({
activeBoardId,
setActiveBoardId,
}) => {
const boardArray = useTypedSelector((state) => state.boards.boardArray);
const [isFormOpen, setIsFormOpen] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const handleClick = () => {
setIsFormOpen(!isFormOpen);
inputRef.current?.focus();
};
return (
<div className={container}>
<div className={title}>게시판:</div>
{boardArray.map((board: IBoard, index) => (
<div
key={board.boardId}
onClick={() => setActiveBoardId(boardArray[index].boardId)}
className={clsx(
{
[boardItemActive]:
boardArray.findIndex((b) => b.boardId === activeBoardId) ===
index,
},
{
[boardItem]:
boardArray.findIndex((b) => b.boardId === activeBoardId) !==
index,
}
)}
>
<div>{board.boardName}</div>
</div>
))}
<div className={addSection}>
{isFormOpen ? (
<SideForm setIsFormOpen={setIsFormOpen} />
) : (
<FiPlusCircle className={addButton} onClick={handleClick} />
)}
</div>
</div>
);
};
export default BoardList;
import { ChangeEvent, useState } from "react";
import { icon, input, sideForm } from "./SideForm.css";
import { FiCheck } from "react-icons/fi";
import { useTypedDispatch } from "../../../hooks/redux";
import { v4 as uuidv4 } from "uuid";
import { addBoard } from "../../../store/slices/boardsSlice";
import { addLog } from "../../../store/slices/loggerSlice";
type TSideFormProps = {
setIsFormOpen: React.Dispatch<React.SetStateAction<boolean>>;
};
const SideForm: React.FC<TSideFormProps> = ({ setIsFormOpen }) => {
const [inputText, setInputText] = useState("");
const dispatch = useTypedDispatch();
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
setInputText(e.target.value);
};
const handleOnBlur = () => {
// 화면 외부 클릭 시 닫힘
setIsFormOpen(false);
};
const handleClick = () => {
if (inputText) {
dispatch(
addBoard({
// 새로운 Board 추가
board: { boardId: uuidv4(), boardName: inputText, lists: [] },
})
);
dispatch(
addLog({
// 새로운 Log 추가
logId: uuidv4(),
logMessage: "게시판 등록, ${inpuText}",
logAuthor: "User",
logTimestamp: String(Date.now()),
})
);
}
};
return (
<div className={sideForm}>
<input
className={input}
autoFocus // + 버튼 클릭 시 input에 focus
type="Text"
placeholder="새로운 게시판 등록하기"
value={inputText}
onChange={(e) => {
handleChange(e);
}}
onBlur={handleOnBlur}
/>
{/* onClick을 사용하면 onBlur가 먼저 실행되어 onMouseDouwn 사용 */}
<FiCheck className={icon} onMouseDown={handleClick} />
</div>
);
};
export default SideForm;
// boardsSlice.ts
const boardsSlice = createSlice({
name: "boards",
initialState,
reducers: {
addBoard: (state, { payload }: PayloadAction<TAddBoardAction>) => {
state.boardArray.push(payload.board);
},
},
});
// loggerSlice.ts
const loggerSlice = createSlice({
name: "logger",
initialState: initialState,
reducers: {
addLog: (state, { payload }: PayloadAction<ILogItem>) => {
state.logArray.push(payload);
},
},
});