웹 풀스택

[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);
    },
  },
});