Programming/TS

[Typescript] React with Typescript 2 (useReducer)

w00se 2021. 7. 10. 19:29

https://pixabay.com/ko/photos/오래-된-마-건물-거리-황혼-6300696/

 

React에서는 컴포넌트(component) 외부에서 상태(state)를 관리하기 위한 방법으로 useReducer가 제공됩니다.

useState와 useReducer 모두 상태를 관리하기 위한 Hook으로 어느 것을 사용해도 문제가 없지만 프로젝트 규모가 큰 경우 useReducer를 사용하는 것이 편리할 거 같습니다😊

 

오늘은 typescript로 useReducer를 사용하는 예제를 정리했습니다.

 

useReducer에 대한 자세한 설명은 아래 공식 문서에서 확인할 수 있습니다.

https://reactjs.org/docs/hooks-reference.html#usereducer

 

Hooks API Reference – React

A JavaScript library for building user interfaces

reactjs.org

 

reducer의 type 지정

reduce의 type 지정은 아래와 같은 형식으로 할 수 있습니다.

function reducer(state: stateType, action: actionType): stateType {}

 

이에 대한 코드 예시는 아래와 같습니다.

 

CounterWithReducer.tsx

import React, { useReducer } from 'react';

interface CounterProps {

}

interface Action {
    type: string,
}

// state와 action에 모두 타입을 선언
// reducer의 반환 값은 state 이므로 반환 값 타입은 state와 일치하도록 지정
function reducer (state: number, action: Action): number {  
    switch(action.type) {
        case "INCREMENT":
            return state+1;
        case "DECREMENT":
            return state-1;   
        default:
            return state;
    }
}

function Counter(props: CounterProps) {
    const [ number, dispatch ] = useReducer(reducer, 0);

    const onIncrease: () => void = () => {
        dispatch({ type: "INCREMENT" });
    }

    const onDecrease: () => void = () => {
        dispatch({ type: "DECREMENT" });
    }

    return (
        <>
            <h1>{number}</h1>
            <button onClick={onIncrease}>+1</button>
            <button onClick={onDecrease}>-1</button>
        </>
    );
}

export default Counter;

 

예시 - useReducer로 crud 기능 구현

useReducer를 제대로 연습하려면 action tpye을 여러 개 정의하고 action에 payload(state 관리에 필요한 data)를 정의해봐야 할 거 같았습니다.

그래서 useReducer를 사용하여 메뉴를 추가, 수정, 삭제를 할 수 있는 간단한 메뉴 관리 시스템을 만들었습니다.

 

interface.ts

menuItem(메뉴 정보), state, action의 interface를 정의한 파일

// 각 menu의 interface
interface MenuItemInterface {
    id: number,
    name: string,
    price: number|string,
    isSelected: boolean
}

// reducer에서 사용할 state의 interface를 정의
interface StateInterface {
    input: {
        name: string,
        price: string| number
    },
    menuItems: MenuItemInterface[],
    isEditMode: boolean,
    selectedId: number,
}

// Action의 interface를 정의
interface Action {
    type: string,
    payload?: any,
}

export type { MenuItemInterface, StateInterface, Action }

 

menuPageActions.ts

reducer에서 사용되는 action을 정의한 파일

// Action의 각 type을 상수로 정의
const CHANGE_INPUT: string = "CHANGE_INPUT"; // 입력 action
const CREATE_MENU: string = "CREATE_MENU"; // menu 생성 action
const SELECT_EQUAL_MENU: string = "SELECT_EQUAL_MENU"; // menu 선택 action(edit mode off)
const SELECT_DIFFERENT_MENU: string = "SELECT_DIFFERENT_MENU"; // menu 선택 action(edit mode on)
const DELETE_MENU: string = "DELETE_MENU"; // menu 삭제 action
const UPDATE_MENU: string = "UPDATE_MENU"; // menu 수정 action

export { CHANGE_INPUT, CREATE_MENU, SELECT_EQUAL_MENU, SELECT_DIFFERENT_MENU, DELETE_MENU, UPDATE_MENU }

 

MenuPageWithReducer.tsx

메뉴를 입력하는 입력 부분과 저장된 메뉴를 보여주는 출력 부분으로 구성된 컴포넌트

- 메뉴 입력 부분: MenuInput component

- 메뉴 출력 부분: MenuList component

import React, { useReducer, useRef, useCallback } from 'react';
import MenuInput from '../components/MenuInput';
import MenuList from '../components/MenuList';
import { MenuItemInterface, StateInterface, Action } from '../lib/interface/index';
import { CHANGE_INPUT, CREATE_MENU, SELECT_EQUAL_MENU, SELECT_DIFFERENT_MENU, DELETE_MENU, UPDATE_MENU } from '../lib/actions/menuPageActions';

interface MenuPageWithReducerProps {

}

const initialState: StateInterface = {
    input: {
        name: "",
        price: "",
    },
    menuItems: [
        {
            id: 1,
            name: "Lemon",
            price: 1000,
            isSelected: false,
        },
        {
            id: 2,
            name: "Melon",
            price: 2000,
            isSelected: false,
        },
        {
            id: 3,
            name: "Apple",
            price: 3000,
            isSelected: false,
        }
    ],
    isEditMode: false,
    selectedId: -1,
}

function reducer(state: StateInterface, action: Action): StateInterface { // reducer에서 사용된 state와 action에도 type을 명시했다.
    switch(action.type) {
        case CHANGE_INPUT: // 입력창에 입력이 됐을 때 발생하는 action
            return { 
                    ...state,
                    input: {
                        ...state.input,
                        [action.payload.name]: action.payload.value
                    }
                }
        case CREATE_MENU: // menu를 추가할 때 발생하는 action
            return {
                ...state,
                menuItems: [ ...state.menuItems, action.payload.newMenu ],
                input: {
                    name: "",
                    price: "",
                }
            }
            
        case SELECT_EQUAL_MENU: // 동일한 menu를 선택했을 때 발생하는 action(turn off edit mode)
            return {
                ...state,
                input: {
                    name: "",
                    price: "",
                },
                menuItems: state.menuItems.map((item, index) => ({...item, isSelected: false})),
                selectedId: -1,
                isEditMode: false,
            }

        case SELECT_DIFFERENT_MENU: // 다른 menu를 선택했을 때 발생하는 action(turn on edit mode)
            return {
                ...state,
                input: {
                    name: action.payload.menuItem.name,
                    price: action.payload.menuItem.price,
                },
                menuItems: state.menuItems.map((item, index) => ({...item, isSelected: action.payload.menuItem.id === item.id})),
                selectedId: action.payload.menuItem.id,
                isEditMode: true,
            }
        
        case DELETE_MENU: // menu를 삭제할 때 발생하는 action
            return {
                ...state,
                input: {
                    name: "",
                    price: ""
                },
                menuItems: state.menuItems.filter((item, index) => { return item.id !== action.payload.menuItemId }),
                selectedId: -1,
                isEditMode: false,
            }

        case UPDATE_MENU: // menu를 수정할 때 발생하는 action
            return {
                ...state,
                input: {
                    name: "",
                    price: "",
                },
                menuItems: state.menuItems.map((item, index) => {
                    return ({ 
                        ...item, 
                        name: state.selectedId === item.id? state.input.name: item.name,
                        price: state.selectedId === item.id? state.input.price: item.price,
                        isSelected: false,
                    })
                }),
                selectedId: -1,
                isEditMode: false,
            }
        default:
            return state
    }
}

function MenuPageWithReducer (props: MenuPageWithReducerProps) {
    const [ state, dispatch ] = useReducer(reducer, initialState);
    const { menuItems, isEditMode, selectedId } = state;
    const { name, price } = state.input;
    const menuId = useRef(4);
    
    const onChange = (event: React.ChangeEvent<HTMLInputElement>): void => { // 입력할 때 호출되는 함수
        const { name, value } = event.target;

        dispatch({
            type: CHANGE_INPUT,
            payload: {
                name,
                value,
            }
        });
    }

    const onCreate = (): void => { // menu를 생성할 때 호출되는 함수
        if(name !== "" && price !== "") {
            const newMenu = {
                id: menuId.current,
                name: name,
                price: price,
                isSelected: false,
            }

            dispatch({
                type: CREATE_MENU,
                payload: {
                    newMenu,
                }
            });

            menuId.current += 1;
        }
    }

    const onUpdate = (): void => { // menu를 수정할 때 호출되는 함수
        if(name !== "" && price !== "") {
            dispatch({
                type: UPDATE_MENU,
            })
        }
    }

    const onSelect = useCallback((data: MenuItemInterface): void => { // menu를 선택할 때 호출되는 함수
        if(selectedId === data.id) {
            dispatch({
                type: SELECT_EQUAL_MENU,
            });
        } else {
            dispatch({
                type: SELECT_DIFFERENT_MENU,
                payload: {
                    menuItem: data,
                }
            });
        }
    }, [ selectedId ]);

    const onDelete = useCallback((id: number): void => { // menu를 삭제할 떄 호출되는 함수
        dispatch({
            type: DELETE_MENU,
            payload: {
                menuItemId: id,
            }
        })
    }, []);

    return (
        <>
            <MenuInput name={name} price={price} isEditMode={isEditMode} onChange={onChange} onCreate={onCreate} onUpdate={onUpdate}/>
            <MenuList menuItems={menuItems} onSelect={onSelect} onDelete={onDelete}/>
        </>
    );
}

export default MenuPageWithReducer;

 

MenuInput.tsx

입력창을 통해 메뉴를 추가, 삭제할 수 있는 기능을 가진 component

import React from 'react';

interface MenuInputProps {
    name: string,
    price: number | string,
    isEditMode: boolean,
    onChange: (event: React.ChangeEvent<HTMLInputElement>) => void,
    onCreate: () => void,
    onUpdate: () => void,
}

function MenuInput(props: MenuInputProps) {
    const { onCreate, onUpdate } = props;

    const onPress = () => {
        if(props.isEditMode) {
            onUpdate();
        } else {
            onCreate();
        }
    }
    return (
        <>
            <div>
                <div>
                    <span>
                        메뉴 이름
                    </span>
                    <input placeholder="이름을 입력해주세요." name="name" value={props.name} onChange={props.onChange}/>
                </div>
                <div>
                    <span>
                        메뉴 가격
                    </span>
                    <input placeholder="가격을 입력해주세요." name="price" value={props.price} onChange={props.onChange}/>
                </div>
                <div>
                    <button onClick={onPress}>
                        {
                            props.isEditMode? "수정": "추가"
                        }
                    </button>
                </div>
            </div>
        </>
    )
}

export default MenuInput;

 

MenuList.tsx

저장된 메뉴 목록을 보여주는 컴포넌트

import React from 'react';
import { MenuItemInterface } from '../lib/interface';
import MenuItem from './MenuItem';

interface MenuListProps {
    menuItems: MenuItemInterface[],
    onSelect: (data: MenuItemInterface) => void,
    onDelete: (id: number) => void,
}

function MenuList (props: MenuListProps) {
    return (
        <>
            {
                props.menuItems.map((item, index) => {
                    return(<MenuItem {...item} key={item.id} onSelect={props.onSelect} onDelete={props.onDelete}/>)
                })
            }
        </>
    )
}

export default React.memo(MenuList);

 

MenuItem.tsx

import React from 'react';
import { MenuItemInterface } from '../lib/interface';

interface MenuItemProps extends MenuItemInterface {
    onSelect: (data: MenuItemInterface) => void,
    onDelete: (id: number) => void,
}

function MenuItem(props: MenuItemProps) {
    const { id, name, price, isSelected, onSelect, onDelete } = props;

    return (
        <div>
            <span onClick={() => { onSelect({id, name, price, isSelected}); }}>
                <b style={{ color: isSelected? "pink": "black" }}>{name}</b><span> - {price}</span>
            </span>
            <span>
                {
                    isSelected?
                    <button onClick={() => { onDelete(id); }}>삭제</button>:
                    null
                }
            </span>
        </div>
    )
}

export default React.memo(MenuItem)

 

간단한 실행 예시는 아래와 같습니다.

실행 예시


- 참고 자료

https://reactjs.org/docs/hooks-reference.html#usereducer - React 공식문서(useReducer)

https://react.vlpt.us/basic/20-useReducer.html - 벨로퍼트와 함께하는 모던 리액트(useReducer를 사용하여 상태 업데이트 로직 분리하기)

 

 

읽어 주셔서 감사합니다 :)

틀린 부분이 있다면 댓글로 편히 알려주세요😊