Skip to content

플러그인

Pinia 스토어는 저수준 API 덕분에 완전히 확장할 수 있습니다. 할 수 있는 일은 다음과 같습니다:

  • 스토어에 새로운 속성 추가
  • 스토어 정의 시 새로운 옵션 추가
  • 스토어에 새로운 메서드 추가
  • 기존 메서드 감싸기
  • 액션과 그 결과 가로채기
  • Local Storage 같은 부수 효과 구현
  • 특정 스토어에만 선택적으로 적용

플러그인은 pinia.use()로 pinia 인스턴스에 추가합니다. 가장 단순한 예시는 객체를 반환해 모든 스토어에 정적 속성을 추가하는 것입니다:

js
import { createPinia } from 'pinia'

// 이 플러그인이 설치된 뒤에 생성되는 모든 store에
// `secret`이라는 속성을 추가합니다. 이 함수는 다른 파일에 있어도 됩니다
function SecretPiniaPlugin() {
  return { secret: 'the cake is a lie' }
}

const pinia = createPinia()
// pinia에 플러그인을 전달합니다
pinia.use(SecretPiniaPlugin)

// 다른 파일에서
const store = useStore()
store.secret // 'the cake is a lie'

이것은 router, modal, toast manager 같은 전역 객체를 추가할 때 유용합니다.

소개

Pinia 플러그인은 선택적으로 스토어에 추가할 속성을 반환하는 함수입니다. 이 함수는 선택적 인수인 _context_를 받습니다:

js
export function myPiniaPlugin(context) {
  context.pinia // `createPinia()`로 생성한 pinia
  context.app // `createApp()`으로 생성한 현재 앱
  context.store // 플러그인이 확장 중인 스토어
  context.options // `defineStore()`에 전달된 스토어 정의 옵션 객체
  // ...
}

이 함수는 그다음 pinia.use()를 통해 pinia에 전달됩니다:

js
pinia.use(myPiniaPlugin)

플러그인은 플러그인 자체가 등록된 후에 생성된 스토어에만, 그리고 pinia가 앱에 전달된 이후에만 적용됩니다. 그렇지 않으면 적용되지 않습니다.

스토어 확장하기

플러그인에서 객체를 반환하기만 해도 모든 스토어에 속성을 추가할 수 있습니다:

js
pinia.use(() => ({ hello: 'world' }))

속성을 store에 직접 설정할 수도 있지만, 가능하다면 devtools가 자동 추적할 수 있도록 반환 방식 사용을 권장합니다:

js
pinia.use(({ store }) => {
  store.hello = 'world'
})

플러그인이 반환한 속성은 devtools에서 자동 추적됩니다. 따라서 hello를 devtools에 보이게 하려면, devtools에서 디버깅하려는 경우 개발 모드에서만 store._customProperties에 추가해야 합니다:

js
// 위 예제에서 이어집니다
pinia.use(({ store }) => {
  store.hello = 'world'
  // 번들러가 이 코드를 처리하는지 확인하세요. webpack과 vite는 기본적으로 됩니다
  if (process.env.NODE_ENV === 'development') {
    // 스토어에 설정한 키를 추가합니다
    store._customProperties.add('hello')
  }
})

모든 스토어는 reactive로 감싸져 있으므로, 내부에 있는 모든 Ref(ref(), computed(), ...)를 자동으로 언래핑한다는 점에 유의하세요:

js
const sharedRef = ref('shared')
pinia.use(({ store }) => {
  // 각 스토어는 자신만의 `hello` 속성을 가집니다
  store.hello = ref('secret')
  // 자동으로 언래핑됩니다
  store.hello // 'secret'

  // 모든 스토어가 `shared` 속성 값을 공유합니다
  store.shared = sharedRef
  store.shared // 'shared'
})

이것이 .value 없이 모든 computed 속성에 접근할 수 있고, 그것들이 반응형인 이유입니다.

새로운 state 추가하기

스토어에 새로운 state 속성이나 하이드레이션 중에 사용될 속성을 추가하려면 두 곳에 추가해야 합니다:

  • store.myState로 접근할 수 있도록 store
  • devtools에서 사용되고 SSR 중 직렬화될 수 있도록 store.$state

게다가 서로 다른 접근 사이에서 값을 공유하려면, 거의 확실히 ref()(또는 다른 반응형 API)를 사용해야 합니다:

js
import { toRef, ref } from 'vue'

pinia.use(({ store }) => {
  // SSR을 올바르게 처리하려면, 기존 값을 덮어쓰지 않도록
  // 보장해야 합니다
  if (!Object.hasOwn(store.$state, 'hasError')) {
    // hasError는 플러그인 안에서 정의되므로 각 스토어는 자신만의
    // state 속성을 갖습니다
    const hasError = ref(false)
    // `$state`에 값을 설정하면 SSR 중 직렬화될 수 있습니다
    store.$state.hasError = hasError
  }
  // state의 ref를 store로 옮겨야 합니다. 이렇게 해야
  // store.hasError와 store.$state.hasError 두 접근 모두 동작하고
  // 같은 변수를 공유합니다
  // https://vuejs.org/api/reactivity-utilities.html#toref 참고
  store.hasError = toRef(store.$state, 'hasError')

  // 이 경우 `hasError`를 반환하지 않는 편이 좋습니다. 그렇지 않으면
  // devtools의 `state` 섹션에 어차피 표시되는데,
  // 반환까지 하면 devtools에 두 번 보이게 됩니다.
})

플러그인 안에서 일어나는 state 변경이나 추가(store.$patch() 호출 포함)는 스토어가 활성화되기 전에 발생하므로 어떤 subscription도 트리거하지 않습니다.

플러그인에서 추가한 state 초기화하기

기본적으로 $reset()은 플러그인이 추가한 state를 초기화하지 않지만, 오버라이드해서 추가한 state도 초기화하게 만들 수 있습니다:

js
import { toRef, ref } from 'vue'

pinia.use(({ store }) => {
  // 위와 동일한 코드이며, 참고용입니다
  if (!Object.hasOwn(store.$state, 'hasError')) {
    const hasError = ref(false)
    store.$state.hasError = hasError
  }
  store.hasError = toRef(store.$state, 'hasError')

  // context(`this`)가 store가 되도록 설정해야 합니다
  const originalReset = store.$reset.bind(store)

  // $reset 함수를 오버라이드합니다
  return {
    $reset() {
      originalReset()
      store.hasError = false
    },
  }
})

새로운 외부 속성 추가하기

외부 속성, 다른 라이브러리에서 온 클래스 인스턴스, 혹은 단순히 반응형이 아닌 것을 추가할 때는 pinia에 전달하기 전에 markRaw()로 객체를 감싸야 합니다. 다음은 모든 스토어에 router를 추가하는 예제입니다:

js
import { markRaw } from 'vue'
// router의 위치에 맞게 경로를 조정하세요
import { router } from './router'

pinia.use(({ store }) => {
  store.router = markRaw(router)
})

플러그인 안에서 $subscribe 호출하기

플러그인 안에서도 store.$subscribestore.$onAction를 사용할 수 있습니다:

ts
pinia.use(({ store }) => {
  store.$subscribe(() => {
    // store 변경에 반응합니다
  })
  store.$onAction(() => {
    // store action에 반응합니다
  })
})

새로운 옵션 추가하기

스토어를 정의할 때 새로운 옵션을 만들어 두고, 이후 플러그인에서 소비하는 것도 가능합니다. 예를 들어 어떤 action이든 디바운스할 수 있게 해 주는 debounce 옵션을 만들 수 있습니다:

js
defineStore('search', {
  actions: {
    searchContacts() {
      // ...
    },
  },

  // 이것은 나중에 플러그인이 읽습니다
  debounce: {
    // searchContacts action을 300ms 동안 디바운스합니다
    searchContacts: 300,
  },
})

그러면 플러그인은 그 옵션을 읽어서 action을 감싸고 원래 action을 대체할 수 있습니다:

js
// 어떤 debounce 라이브러리든 사용하세요
import debounce from 'lodash/debounce'

pinia.use(({ options, store }) => {
  if (options.debounce) {
    // 기존 action을 새로운 것으로 덮어씁니다
    return Object.keys(options.debounce).reduce((debouncedActions, action) => {
      debouncedActions[action] = debounce(
        store[action],
        options.debounce[action]
      )
      return debouncedActions
    }, {})
  }
})

setup 문법을 사용할 때는 사용자 정의 옵션이 세 번째 인수로 전달된다는 점에 유의하세요:

js
defineStore(
  'search',
  () => {
    // ...
  },
  {
    // 이것은 나중에 플러그인이 읽습니다
    debounce: {
      // searchContacts action을 300ms 동안 디바운스합니다
      searchContacts: 300,
    },
  }
)

TypeScript

위에서 보여준 모든 것은 타입 지원과 함께 할 수 있으므로, any@ts-ignore를 쓸 필요가 전혀 없습니다.

플러그인 타입 지정

Pinia 플러그인은 다음과 같이 타입을 지정할 수 있습니다:

ts
import { PiniaPluginContext } from 'pinia'

export function myPiniaPlugin(context: PiniaPluginContext) {
  // ...
}

새로운 스토어 속성 타입 지정

스토어에 새 속성을 추가할 때는 PiniaCustomProperties 인터페이스도 확장해야 합니다.

ts
import 'pinia'
import type { Router } from 'vue-router'

declare module 'pinia' {
  export interface PiniaCustomProperties {
    // setter를 사용하면 문자열과 ref 둘 다 허용할 수 있습니다
    set hello(value: string | Ref<string>)
    get hello(): string

    // 더 단순한 값도 정의할 수 있습니다
    simpleNumber: number

    // 위 플러그인이 추가한 router 타입 지정 (#adding-new-external-properties)
    router: Router
  }
}

이제 안전하게 읽고 쓸 수 있습니다:

ts
pinia.use(({ store }) => {
  store.hello = 'Hola'
  store.hello = ref('Hola')

  store.simpleNumber = Math.random()
  // @ts-expect-error: 아직 이것을 올바르게 타입 지정하지 않았습니다
  store.simpleNumber = ref(Math.random())
})

PiniaCustomProperties는 스토어의 속성을 참조할 수 있게 해 주는 제네릭 타입입니다. 예를 들어 초기 옵션을 $options로 복사한다고 가정해 봅시다(이것은 option store에서만 동작합니다):

ts
pinia.use(({ options }) => ({ $options: options }))

PiniaCustomProperties의 4개 제네릭 타입을 사용하면 이것을 올바르게 타입 지정할 수 있습니다:

ts
import 'pinia'

declare module 'pinia' {
  export interface PiniaCustomProperties<Id, S, G, A> {
    $options: {
      id: Id
      state?: () => S
      getters?: G
      actions?: A
    }
  }
}

TIP

제네릭 안에서 타입을 확장할 때는 반드시 원본 코드와 정확히 같은 이름을 사용해야 합니다. IdidI라고 부를 수 없고, SState라고 부를 수도 없습니다. 각 글자가 의미하는 바는 다음과 같습니다:

  • S: State
  • G: Getters
  • A: Actions
  • SS: Setup Store / Store

새로운 state 타입 지정

새 state 속성을(storestore.$state 둘 다에) 추가할 때는 대신 PiniaCustomStateProperties에 타입을 추가해야 합니다. PiniaCustomProperties와 달리 이것은 State 제네릭 하나만 받습니다:

ts
import 'pinia'

declare module 'pinia' {
  export interface PiniaCustomStateProperties<S> {
    hello: string
  }
}

새로운 생성 옵션 타입 지정

defineStore()를 위한 새로운 옵션을 만들 때는 DefineStoreOptionsBase를 확장해야 합니다. PiniaCustomProperties와 달리 여기서는 State와 Store 타입 두 개의 제네릭만 노출되므로, 무엇을 정의할 수 있을지 더 잘 제한할 수 있습니다. 예를 들어 action 이름을 사용할 수 있습니다:

ts
import 'pinia'

declare module 'pinia' {
  export interface DefineStoreOptionsBase<S, Store> {
    // 어떤 action이든 ms 단위 숫자를 정의할 수 있도록 허용합니다
    debounce?: Partial<Record<keyof StoreActions<Store>, number>>
  }
}

TIP

Store 타입에서 _getters_를 추출하는 StoreGetters 타입도 있습니다. 또한 DefineStoreOptionsDefineSetupStoreOptions 타입을 각각 확장하면 setup stores 또는 _option stores_의 옵션만 선택적으로 확장할 수도 있습니다.

Nuxt

pinia를 Nuxt와 함께 사용할 때는 먼저 Nuxt plugin을 만들어야 합니다. 그러면 pinia 인스턴스에 접근할 수 있습니다:

ts
// plugins/myPiniaPlugin.ts
import { PiniaPluginContext } from 'pinia'

function MyPiniaPlugin({ store }: PiniaPluginContext) {
  store.$subscribe((mutation) => {
    // store 변경에 반응합니다
    console.log(`[🍍 ${mutation.storeId}]: ${mutation.type}.`)
  })

  // TS를 사용 중이라면 여기는 타입 지정이 필요합니다
  return { creationTime: new Date() }
}

export default defineNuxtPlugin(({ $pinia }) => {
  $pinia.use(MyPiniaPlugin)
})

INFO

위 예제는 TypeScript를 사용합니다. .js 파일을 사용 중이라면 타입 주석 PiniaPluginContextPlugin, 그리고 그 import들을 제거해야 합니다.

기존 플러그인

GitHub에서 pinia-plugin 토픽으로 기존 Pinia 플러그인을 확인할 수 있습니다.

모두를 위한 문서 한글화