ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [React] Typescript와 Redux hooks를 이용하여 간단한 TODO LIST 만들기
    Frontend 2020. 2. 23. 16:39

    오늘은 Typescript, Redux hooks를 사용하여 간단한 React Todo List를 만들어보려고 한다.

    예전에 connect라는 Redux 라이브러리의 Higher order component를 사용해 Redux 프로젝트를 몇 번 만들어봤었다.

     

    그 때는 connect로 연결된 container component에서 비즈니스 로직 정의를 하고

    presentational component에서 container로부터 전달받은 state를 렌더해주는 구조였다.

     

    그러다보니 매 번 connect로 container와 component를 연결해주는 과정이 약간 번거로웠다.

    게다가 action과 reducer를 매번 생성하는 것도 아주 귀찮은 일이었다.

     

    그래서 오늘은 connect를 사용하는 방식보다 훨씬 간단하게 Redux를 사용할 수 있게 해주는

    Redux hooks와 Redux toolkit의 여러 메소드들을 활용해보려고 한다.

     

     

    번거로운 웹팩 설정 등을 피하기 위해 Create-react-app을 스캐폴딩하여 시작하기로 했다.

     

    npx create-react-app example-app --template typescript
    # or
    npx create-react-app example-app --typescript

     

    CRA에서 typescript를 지원하기 때문에 초기 스캐폴딩할 때

    명령어에 --typescript만 써주면 자동으로 typescript 기본 패키지들이 설치된다.

     

    "dependencies": {
      "@reduxjs/toolkit": "^1.2.5",
      "@types/node": "^12.0.0",
      "@types/randomstring": "^1.1.6",
      "@types/react": "^16.9.22",
      "@types/react-dom": "^16.9.5",
      "@types/react-redux": "^7.1.7",
      "randomstring": "^1.1.5",
      "react": "^16.12.0",
      "react-dom": "^16.12.0",
      "react-redux": "^7.2.0",
      "react-scripts": "3.4.0",
      "typescript": "~3.7.2"
    },

     

    그 외에 필요한 패키지들을 설치할건데, 일단 내가 사용한 패키지들은 위와 같다.

    초기에 설치되지 않은 패키지들을 추가로 설치해주면 된다.

     

     

    이제 src 폴더를 위와 같이 구성하였다.

    컴포넌트들이 담길 components 폴더와 actions와 reducers 함수들이 담길 features 폴더.

     

     

    features/index.ts

    import {
      combineReducers,
    } from '@reduxjs/toolkit';
    
    export const rootReducer = combineReducers({
    
    });
    

     

     먼저 features/index.ts에 combineReducers 함수를 실행한다.

    아직 아무것도 시작하지 않았기 때문에 빈 객체를 넣어서 실행한다.

     

    src/index.tsx

    import React from 'react';
    import ReactDOM from 'react-dom';
    import App from './components/App';
    import { createStore } from 'redux';
    import { rootReducer } from './features';
    import { Provider } from 'react-redux';
    
    const store = createStore(rootReducer);
    
    ReactDOM.render(
      <Provider store={ store }>
        <App />
      </Provider>
      , document.getElementById('root'));
    

     

    이제 src/index.tsx에서 아까 만들어둔 rootReducer를 import한 후,

    createStore 메소드에 인자로 넣어 store를 생성한다.

     

    Provider component를 import하여, App component를 감싼 다음,

    store prop을 넘겨주면 초기 설정은 끝이 난다.

     

     


    Store는 여러 상태값들을 저장하는 저장소이고,

    Provider component로 App을 감쌓기 때문에 App에서 store의 값들을 가져올 수 있게 된다.

     

    만약에 user가 어떤 액션을 실행시키면 dispatch 함수를 통해 reducer가 실행되고,

    reducer에 정의된대로 state값이 업데이트된 후 store에 저장된다.

    그리고 이 state값이 변화함에 따라 View가 다시 렌더링되게 된다.

     

    (이러한 일련의 과정이 이해가 잘 안된다면 다음 글을 읽어보길 바란다.)

    https://im-developer.tistory.com/158

     

    [React] 리덕스 (Redux) 이해하기

    10년 전까지만 해도 프론트엔드 개발의 트렌드는 MVC (Model - View - Controller) 패턴이었다. Model이란 어플리케이션의 데이터를 관리해주는 부분을 말한다. View란 어플리케이션이 사용자에게 어떻게 보여지..

    im-developer.tistory.com


     

     

    features/index.ts

    import {
      combineReducers,
      createAction,
      createSelector,
      createSlice,
      PayloadAction
    } from '@reduxjs/toolkit';
    import { generate as generateRandomStr } from 'randomstring';
    
    export interface Todo {
      id: string;
      text: string;
      isDone: boolean;
    }
    
    export interface TodoList {
      list: Todo[];
    }
    
    const initialState: TodoList = {
      list: [],
    };
    
    const actionPrefix = 'TODOS';
    const addTodos = createAction<object>(`${actionPrefix}/add`);
    
    const reducers = {
      add: ({ list }: TodoList, { payload: { text, isDone } }: PayloadAction<Todo>) => {
        const newTodo: Todo = {
          id: generateRandomStr(5),
          text: text.toString(),
          isDone
        };
    
        list.push(newTodo);
      },
    };
    
    const todoSlice = createSlice({
      reducers,
      initialState,
      name: actionPrefix,
    });
    
    export const selectTodoList = createSelector(
      (state: TodoList) => state.list,
      (list: Todo[]) => list,
    );
    
    export const actions = {
      addTodos,
    };
    
    export const rootReducer = combineReducers({
      todos: todoSlice.reducer,
    });
    
    export type RootState = ReturnType<typeof rootReducer>
    

     

    이제 어떤 값을 input text에 입력해서 엔터를 쳤을때 상태가 업데이트되도록 액션과 리듀서를 만들어주면 된다.

     

    features/index.ts 파일에 action과 reduce를 redux toolkit에서 지원하는 여러 메소드를 이용해 만들어주었는데,

    하나 하나 살펴보도록 하자.

     

     

    export interface Todo {
      id: string;
      text: string;
      isDone: boolean;
    }
    
    export interface TodoList {
      list: Todo[];
    }

     

    먼저 위와 같이 Todo와 TodoList의 interface를 정의해주었다.

    Todo는 id, text, isDone 속성들이 들어있고,

    TodoList는 이러한 Todo가 들어있는 배열, list 속성이 들어있다.

     

    const initialState: TodoList = {
      list: [],
    };

     

    가장 초기의 state는 위와 같은 모습일 것이다.

    이제 state가 업데이트되면 list 배열에 하나씩 새로운 todo가 추가되도록 설계해야한다.

     

     

    createAction

    const actionPrefix = 'TODOS';
    const addTodos = createAction<object>(`${actionPrefix}/add`);

     

    createAction을 이용하여 addTodos라는 액션 함수를 만들었다.

    createAction은 redux-actions라는 라이브러리에서 제공해주는 액션 생성 함수로 @reduxjs/toolkit에 포함되어 있다.

    https://redux-actions.js.org/api/createaction

     

    createAction(s)

     

    redux-actions.js.org

     

    createAction은 type을 인자로 받아 액션 함수를 만들어준다.

    그렇게 만들어진 액션 함수에 특정 값을 인자로 넣어 실행하면 그 값이 payload라는 속성으로 포함된 액션 객체가 생성된다.

     

    즉, 위에서 만든 addTodos 함수를 실행하면 다음과 같은 모양의 객체가 리턴된다.

     

    addTodos({
      id: 'abc1',
      text: 'hello',
    }); // { types: 'TODOS/add', payload: { id: 'abc1', text: 'hello' }}

     

    액션 type을 'TODOS/add'라고 설정한 이유는 아래에서 설명하겠다.

     

     

    createSlice

    이제 redux toolkit에 있는 createSlice라는 메소드를 이용하여 reducer를 생성할 것이다.

    https://redux-toolkit.js.org/api/createslice/

     

    Redux Toolkit

    # `createSlice`

    redux-toolkit.js.org

     

    function createSlice({
        // An object of "case reducers". Key names will be used to generate actions.
        reducers: Object<string, ReducerFunction | ReducerAndPrepareObject>
        // The initial state for the reducer
        initialState: any,
        // A name, used in action types
        name: string,
        // An additional object of "case reducers". Keys should be other action types.
        extraReducers?:
        | Object<string, ReducerFunction>
        | ((builder: ActionReducerMapBuilder<State>) => void)
    })

     

    createSlice는 위와 같이 reducers와 initialState, name, extraReducers라는 속성을 가진다.

    • reducers는 각 action type별로 바뀔 상태값을 정의할 reducer 객체들을 말한다.
    • initialState는 말 그대로 초기 상태 값이다.
    • name은 아까 위에서 정의한 actionPrefix 값이 들어가는데, 자동으로 action Type이 생성될 때 actionPrefix를 prefix로 사용하여 만들어진다. 즉, reducers 객체에 add라는 속성이 존재하면, 자동으로 `${actionPrefix}/add`('TODOS/add')라는 actionType이 생성된다.
    • extraReducers는 자동으로 actionType과 actionCreator가 생성되지 않는 reducer가 생성되며, 함수 내부에서 자동 생성되는 actionType이 아닌 사용자가 별도의 actionType을 사용할 수 있다. 이 속성은 꼭 사용하지 않아도 된다.

     

    const reducers = {
      add: ({ list }: TodoList, { payload: { text, isDone } }: PayloadAction<Todo>) => {
        const newTodo: Todo = {
          id: generateRandomStr(5),
          text: text.toString(),
          isDone
        };
    
        list.push(newTodo);
      },
    };
    
    const todoSlice = createSlice({
      reducers,
      initialState,
      name: actionPrefix,
    });
    
    export const rootReducer = combineReducers({
      todos: todoSlice.reducer,
    });

     

    다시 넘어와서 reducers 객체에 add라는 함수를 정의해주었고, 이 reducers 객체를 넣어 todoSlice를 생성했다.

    todoSlice.reducer를 아까 맨 처음 만들어준 rootReducers에 넣으면 이제 자동으로 reducer 함수들이 연결된다.

     

    reducers 객체에는 각각의 reducer 함수들을 정의할 수 있는데,

    번거롭게 switch 문으로 만들지 않고 객체 형태로 만들 수 있기 때문에 코드가 더 간결해보인다.

     

    add reducer 함수는 2개의 인자를 받는데, 왼쪽 인자는 기존 state 객체, 오른쪽 인자는 payload가 든 객체이다.

     

    createSliceimmer의 produce API를 내장하고 있기 때문에 mutable하게 상태값을 변경해도 된다.

    (이 부분이 굉장히 편리한 부분이다.)

     

    위에보면 list 배열에 mutable하게 새로운 todo 객체를 push하는데,

    이렇게 사용해도 immutable한 객체가 생성된다.

     

     

    createSelector

    reselect라는 라이브러리에서 제공하는 createSelector 함수는 selector를 생성하는 함수로 

    memoization을 활용해 state값을 효율적으로 가져올 수 있도록 도와준다.

     

    https://github.com/reduxjs/reselect

     

    reduxjs/reselect

    Selector library for Redux. Contribute to reduxjs/reselect development by creating an account on GitHub.

    github.com

     

    export const selectTodoList = createSelector(
      (state: TodoList) => state.list,
      (list: Todo[]) => list,
    );
    

     

    마치 mapStateToProps 함수를 사용하여 state를 prop으로 전달하기 전 내가 원하는 형태로 가공하여 전달하듯이

    selector를 생성할때에도 내가 원하는 state값만 가져오도록 가공할 수 있다.

     

    위에서는 인자가 하나뿐이라 헷갈릴 수 있는데,

    여러 개의 인자를 통해서 state의 각 속성들을 따로 따로 가공할 수 있고,

    맨 마지막 인자로 넣는 함수에는 그 전에 나열한 함수에서 리턴한 값들을 인자로 받아온다.

     

     

    export const actions = {
      addTodos,
    };
    

     

    이제 아까 선언한 addTodos 액션을 actions 객체에 넣어 export 해준다.

     

     


     

    components/App.tsx

    import React, { useState, useCallback, ChangeEvent, KeyboardEvent } from 'react';
    import { useSelector, useDispatch } from 'react-redux';
    import { selectTodoList, actions, RootState, Todo } from '../features';
    import './App.css';
    
    const TodoEditor = () => {
      const dispatch = useDispatch();
      const [inputText, setInputText] = useState<string>('');
    
      const handleText = useCallback((e: ChangeEvent<HTMLInputElement>) => {
        setInputText(e.target.value);
      }, []);
    
      const handleEnter = useCallback((e: KeyboardEvent<HTMLInputElement>) => {
        if (inputText && e.keyCode === 13) {
          dispatch(actions.addTodos({
            text: inputText,
            isDone: false
          }));
          setInputText('');
        }
      }, [dispatch, inputText]);
    
      return (
        <div>
          <input
            type='text'
            onChange={handleText}
            onKeyDown={handleEnter}
            value={inputText}
            className='txt-input'
            placeholder='write something here...'
          />
        </div>
      );
    };
    
    const TodoList = () => {
      const dispatch = useDispatch();
      const todoList = useSelector<RootState, Todo[]>(state => selectTodoList(state.todos));
    
      return (
        <ul>
          {todoList.map((item: Todo) => (
            <li key={ item.id }>
              <span className={ item.isDone ? 'txt-complete' : ''}>
                { item.text }
              </span>
            </li>
          ))}
        </ul>
      );
    };
    
    const App = () => {
      return (
        <div className='container'>
          <h1 className='title'>Todo List</h1>
          <TodoEditor />
          <TodoList />
        </div>
      );
    }
    
    export default App;
    

     

    이제 App 컴포넌트에서 위에서 만들어둔 action 함수들을 dispatch하고,

    selector 함수로 state를 가져와서 렌더링하기만 하면 된다!

     

     

    const TodoEditor = () => {
      const dispatch = useDispatch();
      const [inputText, setInputText] = useState<string>('');
    
      const handleText = useCallback((e: ChangeEvent<HTMLInputElement>) => {
        setInputText(e.target.value);
      }, []);
    
      const handleEnter = useCallback((e: KeyboardEvent<HTMLInputElement>) => {
        if (inputText && e.keyCode === 13) {
          dispatch(actions.addTodos({
            text: inputText,
            isDone: false
          }));
          setInputText('');
        }
      }, [dispatch, inputText]);
    
      return (
        <div>
          <input
            type='text'
            onChange={handleText}
            onKeyDown={handleEnter}
            value={inputText}
            className='txt-input'
            placeholder='write something here...'
          />
        </div>
      );
    };

     

    useDispatch를 실행하여 dispatch함수를 만들어주고,

    input에 텍스트를 쓰고 엔터를 쳤을때, action의 addTodos를 dispatch한다.

     

    이때 넣어줄 payload에는 input의 value값을 넣어주고,

    isDone은 초기값 false를 넣어준다.

     

     

    const TodoList = () => {
      const todoList = useSelector<RootState, Todo[]>(state => selectTodoList(state.todos));
    
      return (
        <ul>
          {todoList.map((item: Todo) => (
            <li key={ item.id }>
              <span className={ item.isDone ? 'txt-complete' : ''}>
                { item.text }
              </span>
            </li>
          ))}
        </ul>
      );
    };
    

     

    TodoList는 useSelector 함수를 사용하여 store에 저장된 state값을 가져오는데,

    이 때 아까 만들어둔 selectTodoList를 이용하여 state의 list를 바로 가져온다.

     

    끝!

     

     

    + 추가로 todoList의 체크박스를 클릭했을때, isDone이 true가 되어 화면에 표시되는 toggle 기능을 구현해보고 싶다면 아래 코드와 같이 구현하면 된다.

     

    아까 만들어둔 것과 동일하게 toggle action과 reducer를 정의해서

    사용자가 체크박스를 클릭할때 이벤트를 걸어준 후 toggle action을 dispatch하면 된다.

     

     

    features/index.ts

    import {
      combineReducers,
      createAction,
      createSelector,
      createSlice,
      PayloadAction
    } from '@reduxjs/toolkit';
    import { generate as generateRandomStr } from 'randomstring';
    
    export interface Todo {
      id: string;
      text: string;
      isDone: boolean;
    }
    
    export interface TodoList {
      list: Todo[];
    }
    
    const initialState: TodoList = {
      list: [],
    };
    
    const actionPrefix = 'TODOS';
    const addTodos = createAction<object>(`${actionPrefix}/add`);
    const toggleTodos = createAction<object>(`${actionPrefix}/toggle`);
    
    const reducers = {
      add: ({ list }: TodoList, { payload: { text, isDone } }: PayloadAction<Todo>) => {
        const newTodo: Todo = {
          id: generateRandomStr(5),
          text: text.toString(),
          isDone
        };
    
        list.push(newTodo);
      },
      toggle: ({ list }: TodoList, { payload: { id, isDone } }: PayloadAction<Todo>) => {
        const targetIndex = list.findIndex((item: Todo) => item.id === id);
    
        list[targetIndex].isDone = !isDone;
      },
    };
    const todoSlice = createSlice({
      reducers,
      initialState,
      name: actionPrefix,
    });
    
    export const selectTodoList = createSelector(
      (state: TodoList) => state.list,
      (list: Todo[]) => list,
    );
    
    export const actions = {
      addTodos,
      toggleTodos
    };
    
    export const rootReducer = combineReducers({
      todos: todoSlice.reducer,
    });
    
    console.log(todoSlice)
    
    export type RootState = ReturnType<typeof rootReducer>
    

     

     

    components/App.tsx

    import React, { useState, useCallback, ChangeEvent, KeyboardEvent } from 'react';
    import { useSelector, useDispatch } from 'react-redux';
    import { selectTodoList, actions, RootState, Todo } from '../features';
    import './App.css';
    
    const TodoEditor = () => {
      const dispatch = useDispatch();
      const [inputText, setInputText] = useState<string>('');
    
      const handleText = useCallback((e: ChangeEvent<HTMLInputElement>) => {
        setInputText(e.target.value);
      }, []);
    
      const handleEnter = useCallback((e: KeyboardEvent<HTMLInputElement>) => {
        if (inputText && e.keyCode === 13) {
          dispatch(actions.addTodos({
            text: inputText,
            isDone: false
          }));
          setInputText('');
        }
      }, [dispatch, inputText]);
    
      return (
        <div>
          <input
            type='text'
            onChange={handleText}
            onKeyDown={handleEnter}
            value={inputText}
            className='txt-input'
            placeholder='write something here...'
          />
        </div>
      );
    };
    
    const TodoList = () => {
      const dispatch = useDispatch();
      const todoList = useSelector<RootState, Todo[]>(state => selectTodoList(state.todos));
      const handleChkbox = useCallback((item: Todo) => {
        dispatch(actions.toggleTodos(item));
      }, [dispatch]);
    
      console.log(todoList)
    
      return (
        <ul>
          {todoList.map((item: Todo) => (
            <li key={ item.id }>
              <label>
                <input
                  type="checkbox"
                  checked={ item.isDone }
                  onChange={ handleChkbox.bind({}, item) }
                  className='chk-input'
                />
                <span className={ item.isDone ? 'txt-complete' : ''}>
                  { item.text }
                </span>
              </label>
            </li>
          ))}
        </ul>
      );
    };
    
    const App = () => {
      return (
        <div className='container'>
          <h1 className='title'>Todo List</h1>
          <TodoEditor />
          <TodoList />
        </div>
      );
    }
    
    export default App;
    
    반응형

    COMMENT