Pinia 정리

김도겸

Updated:


Intro

안녕하세요. 김도겸입니다.
vue의 새로운 상태관리 플러그인 Pinia에 대한 개념, Vuex와 비교, 설치 및 사용법을 정리해보았습니다.



1. Pinia란???

pinia start

pinia는 Vue3가 출시되며 변경된 Vue의 공식 상태관리 라이브러리입니다. pinia는 composition-api라는 틀을 이용해 상태관리를 보다 쉽게 만들 수 있고, 기존의 Vuex에 비해 굉장히 사용하기 쉽습니다.

그 밖에도 vue3에서 pinia를 권장하는 다른 이유들은 뭐가 있을 지 Vuex와 비교하며 설명드리도록 하겠습니다.

2. Pinia와 Vuex 비교

  • modules, namespaced 방식 미사용
    vuex에서는 modules와 namespaced를 활용하여 모듈화 작업을 하여 기능마다 분리하고, 분리한 모듈을 각각 중심이 되는 root store 파일에 모듈을 추가합니다.
    하지만 Pinia에서는 modules와 namespaced를 사용하지 않아도 됩니다. defineStore라는 함수를 활용하여 각각의 파일마다 별도의 store를 지정하여 module의 기능을 대신합니다.
    defineStore에서 반환된 hook을 이용하여 store에 아주 쉬운 접근이 가능합니다.

  • Typescript 사용 편의성 증가
    vuex에서도 Typescript를 사용할 수 있으나 상태와 매칭되는 타입 정의가 매우 까다롭습니다.
    흩어져 있는 module 파일들에 대한 state 타입을 추론하는 것이 어렵고, RootState를 얻기 위해 index.ts에서 모든 module을 연결지어줘야 하며, Vue 파일에서 store를 사용할 경우에 상황에 따라 타입 추론이 안된다는 단점이 있습니다.
    pinia는 typescript의 지원이 잘 되어 있어 보다 쉽게 작성이 가능합니다. (사용법 파트에서 설명)

  • mutations의 사라짐
    vuex에선 상태(state)를 변경하기 위해 mutations를 사용하는데, actions에서 state를 제어하기 위해서는 mutation을 일일히 만들어야 한다는 불편한 점이 있었습니다.
    이러한 불편한 점을 해결하기 위해 pinia에서는 mutation의 개념이 사라지고, actions에서 상태를 모두 제어합니다.

  • SFC 문법 지원
    Vuex에서는 SFC 문법을 지원하지 않았지만, pinia에서는 composition-api를 지원합니다.
    state, action, getters를 명시적으로 구분하지 않아도 ref, reactive, computed와 같은 Composition API Hooks를 활용하여 setup을 정의할 때와 동일하게 사용할 수 있습니다.

pinia는 Vue3와 Vue2 + Composition API 환경에서만 작동합니다.

3. 설치 및 사용법

설치 & vue 프로젝트 설정

프로젝트에 다음과 같이 명령어를 입력하여 설치합니다.

npm install pinia

설치가 되었다면, vue 프로젝트의 main.js에 pinia를 사용할 수 있도록 다음과 같은 코드를 추가해줍니다.

import { createPinia } from "pinia";
import { createApp } from "vue";
import App from "./App.vue";

const pinia = createPinia();

createApp(App).use(pinia).mount("#app");

createPinia() 를 import하여 호출하고 use()로 추가해주면 됩니다.

이제, 기본적인 설치와 기초 설정은 되었고, 사용만 하면 됩니다.

기본 사용법

pinia의 기본 사용법은 생각보다 간단합니다. (vue3 composition-api + javascript 기준) root store를 정의할 필요없이 그냥 필요한 store를 만들면 됩니다.

import { defineStore } from "pinia";

// defineStore() 의 첫번째 인자는 스토어의 고유한 ID
export const useRootStore = defineStore("root", {

  //첫번째 인자를 설정하지 않고 id를 추가해도 됨
  id: "root",

  // state
  state: () => ({
    count: 0
    testList: [{name: 'a', age: 20, isAdmin: false,},{name: 'b', age: 21, isAdmin: false },{name: 'c', age: 22, isAdmin: false }]
  }),

  // getters
  getters: {
    getCount(state) {
      return state.count;
    },

    getDoubleCount() {
      // 다른 getter 를 참조
      return this.getCount * 2;
    }
  },

  // actions
  actions: {
    increaseCountAction() {
      this.count++;
    },

    decreaseCountAciton() {
      this.count--;
    },

    initCountAction() {
      this.count = 0;
    }
  }
});

defineStore() 함수를 활용하여 고유 id 설정 및 사용할 state, getters, actions를 다음과 같이 설정합니다.

<template>
  <img alt="Vue logo" src="./assets/logo.png" />
  <!-- <HelloWorld msg="Hello Vue 3 in CodeSandbox!" /> -->

  <div>
    <p> {{ store.getCount }} </p>
    <p> {{ store.getDoubleCount }} </p>
    <button @click="store.increaseCountAction">+</button>
    <button @click="store.decreaseCountAciton">-</button>
    <button @click="store.initCountAction">카운터 초기화</button>
  </div>

  <table>
    <tr>
      <td>이름</td>
      <td>나이</td>
      <td>관리자 권한</td>
    </tr>
    <tr v-for="(item,idx) in tList" v-bind:key="idx">
      <td> {{ item.name }} </td>
      <td> {{ item.age }} </td>
      <td> {{ item.isAdmin }} </td>
    </tr>
  </table>
</template>

<script>
  import { defineComponent } from "vue";
  import { useRootStore } from "./store/index.js";

  export default defineComponent({
    name: "App",
    setup() {
      //store 전체를 가져옴
      const store = useRootStore();

      // store 내부의 state의 값을 computed로 가져오는 것도 가능
      const tList = computed(() => store.testList);

      // 구조분해할당하여 사용하고자 한다면, storeToRefs()를 활용해 줘야 합니다.
      const { count } = storeToRefs(store); // O
      count.value = 20; // O

      return {
        store,
        tList,
      };
    },
  });
</script>

위와 같이 store 파일에서 useRootStore를 import하고, 변수로 store 전체를 가져와 component에서 사용할 수도 있고, store를 선언하고, 필요한 것만 호출하여 사용할 수도 있습니다. ( store. [state name / action name / getter name] )
또한 구조분해할당을 사용하게되면 반응성을 잃어버리기 때문에 storeToRefs()를 활용해야 반응성을 유지할 수 있습니다.

객체와 배열의 경우 통째로 변경할 경우에 storeToRefs를 사용해야 하고 객체에서 내부 속성이 변경되거나 속성을 추가하는 것과 배열에서 요소를 추가, 제거하거나, 값을 변경하는 것은 가능합니다. storeToRefs를 사용하지 않아도 배열과 객체는 예외적인 부분이 있으나, 웬만하면 storeToRefs()를 사용하는 것을 권장합니다.

2번째 파트에서 설명드렸듯이 pinia에선 일반적인 형식 말고 SFC 문법으로도 제작할 수 있습니다.

// stores/list.js
import { defineStore } from "pinia";
import { ref, computed } from "vue";

export const useListStore = defineStore("list", () => {
  //state
  const list = ref([]);

  //actions
  function addList(param) {
    list.value.push(param);
  }

  //getters
  const getDataAll = computed(() => list.value);

  return { list, addList, getDataAll };
});

typescript를 사용할 때는?

pinia의 장점이자 주요 특징 중 하나가 typescript 지원이 굉장히 잘 되어있다는 것입니다.

export interface TodoItem {
  id: number;
  title: string;
  status: "active" | "clear";
}
import { defineStore } from "pinia";
import { TodoItem } from "../index.interface";

export const useStoreTodo = defineStore("todo", {
  state: () => ({
    todoList: [
      {
        id: 0,
        title: "청소하기",
        status: "active",
      },
      {
        id: 1,
        title: "공부하기",
        status: "active",
      },
      {
        id: 2,
        title: "운동하기",
        status: "clear",
      },
    ] as TodoItem[],
  }),
  getters: {},
  actions: {
    addTodoItem(item: TodoItem) {
      this.todoList.push(item);
    },
    removeTodoItem(id: number) {
      this.todoList.splice(id, 1);
    },
  },
});

위와 같이 state 각각에 as를 활용해 타입을 지정할 수 있지만, 아래와 같이 작성하는 방법도 있습니다.

interface BoardState {

  formatBoardList: BoardItemIf[];
  maxBoardLength: number;
  mstrId: number;
  listPage: number;
  isDoneBoardList: boolean;
  errorBoardList: string | null;

  comments: CommentInterface[];
  commentsPage: number;
  maxCommentsLength: number;
  isDoneGetComments: boolean;
  errorGetComments: string | null;

  formatSearchPosts: BoardItemIf[];
  maxSearchPostsLength: number;
  searchPostsPage: number;
  isDoneSearchPosts: boolean;
  errorSearchPosts: string | null;
  srchType: string;
  srchText: string;
  
}

export const boardStore = defineStore({
  id: 'board',
  state: (): BoardState => ({

    formatBoardList: [],
    maxBoardLength: 0,
    mstrId: 0,
    listPage: 0,
    isDoneBoardList: false,
    errorBoardList: null,

    comments: [],
    commentsPage: 0,
    maxCommentsLength: 0,
    isDoneGetComments: false,
    errorGetComments: null,

    formatSearchPosts: [],
    maxSearchPostsLength: 0,
    searchPostsPage: 0,
    isDoneSearchPosts: false,
    errorSearchPosts: null,
    srchType: '',
    srchText: '',

  }),
  getters: {...},
  actions: {...},
});

사용할 state의 타입들을 하나의 interface에 추가하고 state의 return type을 해당 interface로 설정하여도 문제없습니다.
주의할 점은 상태를 추가할 때 무조건 interface에 해당 상태의 type을 정의해야 합니다.

options api에서의 사용 방법

pinia는 composition-api에서 사용하는 것이 가장 편리하지만, options-api에서도 꽤나 편리하게 사용 가능합니다.
컴포넌트에서 사용하는 방식에만 변화가 있고, store 파일은 방식에 변화가 없습니다.

import { defineStore } from 'pinia'

const useCounterStore = defineStore('counterStore', {
  state: () => ({
    counter: 0,
  }),
  getters: {
    doubleCounter(state) {
      return state.counter * 2
    }
  },
  actions: {
    increment() {
      this.counter++
    }
  }
})
import { mapState, mapActions } from 'pinia'
import { useCounterStore } from '../stores/counterStore'

export default {
  computed: {
    // this.counter로 사용 가능
    ...mapState(useCounterStore, ['counter'])
    // this.myOwnName으로 사용 가능
    ...mapState(useCounterStore, {
      myOwnName: 'counter',
      // 함수 작성 가능
      zero: store => store.counter * 0,
    
      // 함수 작성으로 getter 매핑 가능
      double: store => store.doubleCount,
    }),
  },

  methods: {
    // gives access to this.increment() inside the component
    // same as calling from store.increment()
    ...mapActions(useCounterStore, ['increment'])
    // same as above but registers it as this.myOwnName()
    ...mapActions(useCounterStore, { myOwnName: 'doubleCounter' }),
  },
}

위와 같이 state와 actions의 경우는 각각 mapState, mapActions를 활용하여 매핑하면 되고, getter의 경우 mapState의 함수 기능 사용하여 매핑하면 됩니다.

Tags:

Categories:

Updated:

Leave a comment