본문 바로가기

FrontEnd/React

[React] 리액트 상태 관리

리액트 상태 관리 

오늘은 리액트 상태 관리 할 수 있는 방법에 관한 글 입니다.

리액트에서는 어떤 방식을 통해 상태관리를 할 수 있을까요 ? 

 

리액트는 기본적으로 지역 상태 관리와 전역 상태관리로 나뉘게 됩니다. 

 

지역 상태 관리로는 useState()가 있고, 전역 상태 관리로는 useContext(), 및 recoil,zustand등 여러가지 라이브러리가 있습니다.

지역 상태 관리

1. useState 

useState는 컴포넌트 내에서 상태를 관리할 수 있는 기본적인 방법 입니다.

useState 상태를 사용할 수 있는 공간을 한정적으로 정해놓고 사용할 수 있으므로 독립적인 상태를 활용해서 다른 코드에 영향을 주지 않을 수 있습니다. 

주로 토글 상태, 버튼 상태, 폼 상태 관리를 할 때 주로 사용할 수 있습니다. 

 

예를 들어서 아래와 같이 지역 상태를 관리하는데 사용할 수 있습니다. 

그래서 컴포넌트 내에서 isOn인지 상태를 활용해서 버튼 텍스트를 바꿀 수 있습니다.

import React, { useState } from 'react';

const ToggleButton = () => {
  const [isOn, setIsOn] = useState(false);

  const toggle = () => {
    setIsOn(!isOn);
  };

  return (
    <div>
      <button onClick={toggle}>
        {isOn ? 'ON' : 'OFF'}
      </button>
    </div>
  );
};

export default ToggleButton;

전역 상태 관리 

전역 상태 관리는 사실 꼭 필요하지 않습니다. 

루트 컴포넌트에 useState를 선언하고 하위 props로 상태관리 코드를 넘겨줄 경우 전역적으로 상태를 사용할 수 있습니다.

하지만 이렇게 할 경우 전역 상태 관리는 이제 주로 하위 컴포넌트로 상태를 내려줘야 할 경우 무분별 하게 많은 props를 내림으로서 컴포넌트 복잡성이 올라가기 때문에 이 때 useContext를 사용하거나 다른 상태관리 라이브러리를 사용해서 props 복잡성을 낮춰줄 수 있습니다.

 

주로 제가 전역 상태관리를 사용할 때는 모달,토스트,다이얼로그 등 언제든지 화면에 특정 컴포넌트를 보여줘야 할 때 사용하거나, 아니면 유저 정보 등 각 페이지별로 자주 사용되는 유저 정보를 사용할 때 사용했습니다.

 

전역 상태 관리를 사용 할 수 있는 방법은 기본적으로 외부 라이브러리를 사용하지 않고 리액트 내부에 있는 useContext를 사용해서 상태관리를 할 수 있습니다.

1. useContext 

외부 라이브러리 사용 없이 리액트에서 제공하는 전역 상태관리 입니다. 

collectAllGames란 프로젝트를 진행할 때 useContext로 상태관리를 사용했었는데 그 이유는 

프로젝트 규모가 크지 않아서 다른 라이브 러리 사용이 필요 없다 생각해서 사용했었습니다. 

 

밑에는 useContext를 사용한 예시 입니다.

import { createContext, useCallback, useContext, useState } from "react";
import Dialog from "src/components/Common/Dialog";

interface DialogContextValue {
  open(config: DialogConfig): void;
}

interface DialogConfig {
  title: string;
  content: string;
  onClose(): void;
  onConfirm(): void;
}

const DialogContext = createContext<DialogContextValue | null>(null);

interface Props {
  children: React.ReactNode;
}

export function DialogProvider({ children }: Props) {
  const [visible, setVisible] = useState(false);
  const [config, setConfig] = useState<DialogConfig | null>(null);

  const open = useCallback((config: DialogConfig) => {
    setVisible(true);
    setConfig(config);
  }, []);

  const close = useCallback(() => {
    config?.onClose();
    setVisible(false);
  }, [config]);

  const confirm = useCallback(() => {
    config?.onConfirm();
    setVisible(false);
  }, [config]);

  const value = { open };
  return (
    <DialogContext.Provider value={value}>
      {children}
      <Dialog
        visible={visible}
        title={config?.title ?? ""}
        content={config?.content ?? ""}
        onConfirm={confirm}
        onClose={close}
      />
    </DialogContext.Provider>
  );
}

export function useDialog() {
  const context = useContext(DialogContext);
  if (!context) {
    throw new Error("useDialog must be used within a DialogProvider");
  }
  return context;
}

1. 먼저 useContext를 사용할 때는 createContext로 사용할 Context를 만들어 줍니다. 

그리고 타입스크립트를 사용한다면 받을 타입을 제네릭 안에 넣어줍니다. 

 

2. useContext를 사용하기 위해서는 createContext로 만든 context를 사용해서 사용할 컴포넌트를 contextProvider로 감싸 줍니다.

그리고 그 안에서 useState를 사용해서 상태를 선언해 줍니다. 

 

3. useDialog 훅을 만들어서 useContext를 사용해서 createContext로 만든 context를 넣어주고, 
useContext로 받은 context를 리턴해 줍니다.

 

이제 위에처럼 useContext를 활용해서 Provider, context 사용 훅을 만들었다면 아래 처럼 사용할 수 있습니다.

const { open } = useDialog();

장점으로는 다른 서드 파티 라이브러리에 의존하지 않을 수 있다는 점 입니다.

단점으로는 개발자가 신경써줘 작성해야 할 코드량이 증가합니다. 

위에만 보더라고 useContext를 만들고 Provider를 만들어 주고 사용할 Hook을 따로 만들어야 합니다. 

또한 Provider로 감싼 하위 컴포넌트에 불필요한 렌더링을 유발할 수 있습니다. 

2. zustand

zustand는 리덕스와 비슷하게 flux 패턴을 따르는 전역 상태관리 라이브러리 입니다.

하지만 리덕스랑 차이점이라고 하면 좀 더 사용하기 간편하게 전역상태 관리를 할 수 있습니다. 

 

아래는 zustand 사용해서 Dialog 상태관리를 한 예시 입니다. 

import {ReactElement} from 'react';
import {create} from 'zustand';

interface GlobalDialog {
  title?: string;
  content?: string | ReactElement;
  visible: boolean;
  leftButtonText?: string;
  rightButtonText?: string;
  leftButtonEvent?: () => void;
  rightButtonEvent?: () => void;
  showDialog: () => void;
  hideDialog: () => void;
  setGlobalDialogConfig: (config: GlobalDialogConfig) => void;
}

interface GlobalDialogConfig {
  title?: string;
  content?: string | ReactElement;
  visible?: boolean;
  leftButtonText?: string;
  rightButtonText?: string;
  leftButtonEvent?: () => void;
  rightButtonEvent?: () => void;
}

const useGlobalDialogStore = create<GlobalDialog>(set => ({
  title: '',
  content: '',
  leftButtonText: '',
  rightButtonText: '',
  visible: false,
  showDialog: () => set({visible: true}),
  hideDialog: () => set({visible: false}),
  setGlobalDialogConfig: ({
    title,
    content,
    leftButtonText = '',
    rightButtonText = '',
    visible = true,
    leftButtonEvent,
    rightButtonEvent,
  }: GlobalDialogConfig) => {
    set({
      title,
      content,
      visible,
      leftButtonText,
      rightButtonText,
      leftButtonEvent,
      rightButtonEvent,
    });
  },
}));

export {useGlobalDialogStore};

zustand는 contextAPI와는 다르게 그냥 처음부터 create 메서드를 사용해서 상태를 만들 수 있습니다. 

또한 상태를 바꿀 때는 create 안에 set을 사용해서 상태를 변경시킬 수 있습니다.

타입스크립트 타입 때문에 코드가 길어보이긴 하지만 순수 코드만 놓고 보면 훨씬 간단하게 만들 수 있습니다. 


사용할 때는 아래와 같이 사용할 수 있습니다.

 const {setGlobalDialogConfig} = useGlobalDialogStore();

const handleGoBack = () => {
    setGlobalDialogConfig({
      title: '일기작성을 취소하시겠어요?',
      leftButtonText: '작성 유지',
      rightButtonText: '작성 취소',
      rightButtonEvent: () => navigate.goBack(),
    });
  };

3. recoil

recoil은 아톰이라는 상태 단위를 만들고 아톰을 사용해서 상태를 읽거나 변경 시킬 수 있습니다.

recoil은 페이스북에서 만든 라이브러리라 그런지 useState를 사용해서 상태관리를 하는 것과 비슷하게 사용해서 
라이브러리를 학습하는 비용이 적어서 리액트를 사용하는 개발자라면 편하게 사용할 수 있습니다.

 

아래는 recoil을 사용해서 Dialog 상태관리를 한 예시 입니다.

 

import { atom, useRecoilState, RecoilRoot } from 'recoil';
import React, { ReactElement } from 'react';

interface GlobalDialog {
  title?: string;
  content?: string | ReactElement;
  visible: boolean;
  leftButtonText?: string;
  rightButtonText?: string;
  leftButtonEvent?: () => void;
  rightButtonEvent?: () => void;
  showDialog: () => void;
  hideDialog: () => void;
  setGlobalDialogConfig: (config: GlobalDialogConfig) => void;
}

interface GlobalDialogConfig {
  title?: string;
  content?: string | ReactElement;
  visible?: boolean;
  leftButtonText?: string;
  rightButtonText?: string;
  leftButtonEvent?: () => void;
  rightButtonEvent?: () => void;
}

const globalDialogState = atom<GlobalDialog>({
  key: 'globalDialogState',
  default: {
    title: '',
    content: '',
    leftButtonText: '',
    rightButtonText: '',
    visible: false,
    showDialog: () => {},
    hideDialog: () => {},
    setGlobalDialogConfig: () => {},
  },
});

리코일은 앞에 말한 것 처럼 atom이라는 단위로 상태를 관리하게 됩니다.

그리고 고유한 atom에 key값을 넣어줘야 합니다.
고유한 키값을 넣지 않고 중복될 경우에는 중복된 키가 존재한다는 에러메세지를 보여주게 됩니다.


상태를 사용할 때는 아래와 같습니다.

const [globalDialog, setGlobalDialog] = useRecoilState(globalDialogState);

useRecoilState를 사용해서 상태를 사용하거나, 변경시킬 수 있습니다. 

또한 값을 읽기만 하거나, 변경 시키기만 할 거라면 아래와 같은 메서드를 사용할 수 있습니다.

읽기 : useRecoilValue

쓰기 : useSetRecoilState 
위에 훅을 사용하면 편리하게 사용할 수 있습니다.

 

recoil과 zustand 두 라이브러리를 사용했을 때 결국 전역 상태관리를 한다는 점에서 다른 특별한 점이 없었지만 

recoil 같은 경우 useResetRecoilState를 사용해서 편하게 전역 상태를 초기화 시킬 수 있는 반면에 zustand같은 경우에는 직접 reset 메서드를 만들어서 사용해야 한다는 차이점이 존재했습니다. 

 

앞에서는 두 가지 전역 상태 라이브러리를 보여줬는데 실제로는 mobx,redux,jotai 등 여러가지 상태관리 라이브러리 들이 존재합니다.