]> git.ipfire.org Git - thirdparty/vuejs/pinia.git/commitdiff
feat: access the state and getters through `this` (#190)
authorEduardo San Martin Morote <posva@users.noreply.github.com>
Tue, 22 Sep 2020 08:08:12 +0000 (10:08 +0200)
committerEduardo San Martin Morote <posva13@gmail.com>
Tue, 22 Sep 2020 09:28:31 +0000 (11:28 +0200)
BREAKING CHANGE: there is no longer a `state` property on the store,
you need to directly access it. `getters` no longer receive parameters,
directly call `this.myState` to read state and other getters.

README.md
__tests__/actions.spec.ts
__tests__/getters.spec.ts
__tests__/rootState.spec.ts
__tests__/state.spec.ts [new file with mode: 0644]
src/store.ts
src/types.ts

index 6b1d7204aa29a797ec5fff708b65864a8bae44f5..158c645f3e68e44b40b3747def12a502fdc8b362 100644 (file)
--- a/README.md
+++ b/README.md
@@ -16,7 +16,8 @@ There are the core principles that I try to achieve with this experiment:
 
 - Flat modular structure ๐Ÿ No nesting, only stores, compose them as needed
 - Light layer on top of Vue ๐Ÿ’จ keep it very lightweight
-- Only `state`, `getters` ๐Ÿ‘ `patch` is the new _mutation_
+- Only `state`, `getters`
+- No more verbose mutations, ๐Ÿ‘ `patch` is _the mutation_
 - Actions are like _methods_ โš—๏ธ Group your business there
 - Import what you need, let webpack code split ๐Ÿ“ฆ No need for dynamically registered modules
 - SSR support โš™๏ธ
@@ -89,15 +90,19 @@ export const useMainStore = createStore({
   }),
   // optional getters
   getters: {
-    doubleCount: (state, getters) => state.counter * 2,
+    doubleCount() {
+      return this.counter * 2,
+    },
     // use getters in other getters
-    doubleCountPlusOne: (state, { doubleCount }) => doubleCount.value * 2,
+    doubleCountPlusOne() {
+      return this.doubleCount * 2
+    }
   },
   // optional actions
   actions: {
     reset() {
       // `this` is the store instance
-      this.state.counter = 0
+      this.counter = 0
     },
   },
 })
@@ -115,9 +120,10 @@ export default defineComponent({
     return {
       // gives access to the whole store
       main,
-      // gives access to the state
-      state: main.state,
-      // gives access to specific getter,
+      // gives access only to specific state
+      state: computed(() => main.counter),
+      // gives access to specific getter; like `computed` properties
+      doubleCount: computed(() => main.doubleCount),
     }
   },
 })
@@ -125,20 +131,73 @@ export default defineComponent({
 
 Note: the SSR implementation is yet to be decided on Pinia, but if you intend having SSR on your application, you should avoid using `useStore` functions at the root level of a file to make sure the correct store is retrieved for your request.
 
-Once you have access to the store, you can access the `state` through `store.state` and any getter directly on the `store` itself as a _computed_ property (meaning you need to use `.value` to read the actual value on the JavaScript but not in the template):
+Or:
+
+```ts
+import { createRouter } from 'vue-router'
+const router = createRouter({
+  // ...
+})
+
+// โŒ Depending on where you do this it will fail
+const main = useMainStore()
+
+router.beforeEach((to, from, next) => {
+  if (main.state.isLoggedIn) next()
+  else next('/login')
+})
+```
+
+It must be called **after the Composition API plugin is installed**. That's why calling `useStore` inside functions is usually safe, because they are called after the plugin being installed:
+
+```ts
+export default defineComponent({
+  setup() {
+    // โœ… This will work
+    const main = useMainStore()
+
+    return {}
+  },
+})
+
+// In a different file...
+
+router.beforeEach((to, from, next) => {
+  // โœ… This will work (requires an extra param for SSR, see below)
+  const main = useMainStore()
+
+  if (main.state.isLoggedIn) next()
+  else next('/login')
+})
+```
+
+โš ๏ธ: Note that if you are developing an SSR application, [you will need to do a bit more](#ssr).
+
+You can access any property defined in `state` and `getters` directly on the store, similar to `data` and `computed` properties in a Vue component.
 
 ```ts
 export default defineComponent({
   setup() {
     const main = useMainStore()
-    const text = main.state.name
-    const doubleCount = main.doubleCount.value // notice the `.value` at the end
+    const text = main.name
+    const doubleCount = main.doubleCount
     return {}
   },
 })
 ```
 
-`state` is the result of a `ref` while every getter is the result of a `computed`.
+The `main` store in an object wrapped with `reactive`, meaning there is no need to write `.value` after getters but, like `props` in `setup`, we cannot destructure it:
+
+```ts
+export default defineComponent({
+  setup() {
+    // โŒ This won't work because it breaks reactivity
+    // it's the same as destructuring from `props`
+    const { name, doubleCount } = useMainStore()
+    return { name, doubleCount }
+  },
+})
+```
 
 Actions are invoked like methods:
 
@@ -159,7 +218,7 @@ export default defineComponent({
 To mutate the state you can either directly change something:
 
 ```ts
-main.state.counter++
+main.counter++
 ```
 
 or call the method `patch` that allows you apply multiple changes at the same time with a partial `state` object:
@@ -210,7 +269,7 @@ export const useSharedStore = createStore({
       const user = useUserStore()
       const cart = useCartStore()
 
-      return `Hi ${user.state.name}, you have ${cart.state.list.length} items in your cart. It costs ${cart.price}.`
+      return `Hi ${user.name}, you have ${cart.list.length} items in your cart. It costs ${cart.price}.`
     },
   },
 })
@@ -234,7 +293,7 @@ export const useSharedStore = createStore({
       const cart = useCartStore()
 
       try {
-        await apiOrderCart(user.state.token, cart.state.items)
+        await apiOrderCart(user.token, cart.items)
         cart.emptyCart()
       } catch (err) {
         displayError(err)
@@ -262,13 +321,14 @@ export const useCartUserStore = pinia(
   },
   {
     getters: {
-      combinedGetter: ({ user, cart }) =>
-        `Hi ${user.state.name}, you have ${cart.state.list.length} items in your cart. It costs ${cart.price}.`,
+      combinedGetter () {
+        return `Hi ${this.user.name}, you have ${this.cart.list.length} items in your cart. It costs ${this.cart.price}.`,
+      }
     },
     actions: {
       async orderCart() {
         try {
-          await apiOrderCart(this.user.state.token, this.cart.state.items)
+          await apiOrderCart(this.user.token, this.cart.items)
           this.cart.emptyCart()
         } catch (err) {
           displayError(err)
index c2fd6c511d6055522870b584ca6125e1f17ed520..fb7aee81687633b93a9b1b58c16578f6b3cc5f29 100644 (file)
@@ -1,6 +1,6 @@
 import { createStore, setActiveReq } from '../src'
 
-describe('Store', () => {
+describe('Actions', () => {
   const useStore = () => {
     // create a new store
     setActiveReq({})
@@ -13,9 +13,20 @@ describe('Store', () => {
           a: { b: 'string' },
         },
       }),
+      getters: {
+        nonA(): boolean {
+          return !this.a
+        },
+        otherComputed() {
+          return this.nonA
+        },
+      },
       actions: {
+        async getNonA() {
+          return this.nonA
+        },
         toggle() {
-          this.state.a = !this.state.a
+          return (this.a = !this.a)
         },
 
         setFoo(foo: string) {
index 7380b474b593e324e925bb910246dea4ccbd8ddf..3ef37374c8ee892a54df78efb866a69a8f5f91e9 100644 (file)
@@ -1,6 +1,6 @@
 import { createStore, setActiveReq } from '../src'
 
-describe('Store', () => {
+describe('Getters', () => {
   const useStore = () => {
     // create a new store
     setActiveReq({})
@@ -10,9 +10,18 @@ describe('Store', () => {
         name: 'Eduardo',
       }),
       getters: {
-        upperCaseName: ({ name }) => name.toUpperCase(),
-        composed: (state, { upperCaseName }) =>
-          (upperCaseName.value as string) + ': ok',
+        upperCaseName() {
+          return this.name.toUpperCase()
+        },
+        doubleName() {
+          return this.upperCaseName
+        },
+        composed() {
+          return this.upperCaseName + ': ok'
+        },
+        // TODO: I can't figure out how to pass `this` as an argument. Not sure
+        // it is possible in this specific scenario
+        // upperCaseNameArrow: store => store.name,
       },
     })()
   }
@@ -26,24 +35,24 @@ describe('Store', () => {
     id: 'A',
     state: () => ({ a: 'a' }),
     getters: {
-      fromB(state) {
+      fromB() {
         const bStore = useB()
-        return state.a + ' ' + bStore.state.b
+        return this.a + ' ' + bStore.b
       },
     },
   })
 
   it('adds getters to the store', () => {
     const store = useStore()
-    expect(store.upperCaseName.value).toBe('EDUARDO')
-    store.state.name = 'Ed'
-    expect(store.upperCaseName.value).toBe('ED')
+    expect(store.upperCaseName).toBe('EDUARDO')
+    store.name = 'Ed'
+    expect(store.upperCaseName).toBe('ED')
   })
 
   it('updates the value', () => {
     const store = useStore()
-    store.state.name = 'Ed'
-    expect(store.upperCaseName.value).toBe('ED')
+    store.name = 'Ed'
+    expect(store.upperCaseName).toBe('ED')
   })
 
   it('supports changing between requests', () => {
@@ -55,16 +64,16 @@ describe('Store', () => {
     // simulate a different request
     setActiveReq(req2)
     const bStore = useB()
-    bStore.state.b = 'c'
+    bStore.b = 'c'
 
-    aStore.state.a = 'b'
-    expect(aStore.fromB.value).toBe('b b')
+    aStore.a = 'b'
+    expect(aStore.fromB).toBe('b b')
   })
 
   it('can use other getters', () => {
     const store = useStore()
-    expect(store.composed.value).toBe('EDUARDO: ok')
-    store.state.name = 'Ed'
-    expect(store.composed.value).toBe('ED: ok')
+    expect(store.composed).toBe('EDUARDO: ok')
+    store.name = 'Ed'
+    expect(store.composed).toBe('ED: ok')
   })
 })
index ce3881dc527aeb9fef728f725dae23dc1d3a87d6..d3ee12f79a8ca6cc35b52da33176213a4c229156 100644 (file)
@@ -1,6 +1,6 @@
 import { createStore, getRootState } from '../src'
 
-describe('Store', () => {
+describe('Root State', () => {
   const useA = createStore({
     id: 'a',
     state: () => ({ a: 'a' }),
diff --git a/__tests__/state.spec.ts b/__tests__/state.spec.ts
new file mode 100644 (file)
index 0000000..0737ac9
--- /dev/null
@@ -0,0 +1,31 @@
+import { createStore, setActiveReq } from '../src'
+import { computed } from '@vue/composition-api'
+
+describe('State', () => {
+  const useStore = () => {
+    // create a new store
+    setActiveReq({})
+    return createStore({
+      id: 'main',
+      state: () => ({
+        name: 'Eduardo',
+        counter: 0,
+      }),
+    })()
+  }
+
+  it('can directly access state at the store level', () => {
+    const store = useStore()
+    expect(store.name).toBe('Eduardo')
+    store.name = 'Ed'
+    expect(store.name).toBe('Ed')
+  })
+
+  it('state is reactive', () => {
+    const store = useStore()
+    const upperCased = computed(() => store.name.toUpperCase())
+    expect(upperCased.value).toBe('EDUARDO')
+    store.name = 'Ed'
+    expect(upperCased.value).toBe('ED')
+  })
+})
index cc5e5a697eb394c045ac5f4c8b307a1c604a0115..d87b4d185ba3cbdc139ec8805d735bda95f2fc09 100644 (file)
@@ -1,4 +1,4 @@
-import { ref, watch, computed, Ref } from 'vue'
+import { ref, watch, computed, Ref, reactive } from 'vue'
 import {
   StateTree,
   StoreWithState,
@@ -6,10 +6,9 @@ import {
   DeepPartial,
   isPlainObject,
   StoreWithGetters,
-  StoreGetter,
-  StoreAction,
   Store,
   StoreWithActions,
+  Method,
 } from './types'
 import {
   getActiveReq,
@@ -37,6 +36,22 @@ function innerPatch<T extends StateTree>(
   return target
 }
 
+function toComputed<T>(refObject: Ref<T>) {
+  // let asComputed = computed<T>()
+  const reactiveObject = {} as {
+    [k in keyof T]: Ref<T[k]>
+  }
+  for (const key in refObject.value) {
+    // @ts-ignore: the key matches
+    reactiveObject[key] = computed({
+      get: () => refObject.value[key as keyof T],
+      set: value => (refObject.value[key as keyof T] = value),
+    })
+  }
+
+  return reactiveObject
+}
+
 /**
  * Creates a store instance
  * @param id - unique identifier of the store, like a name. eg: main, cart, user
@@ -45,8 +60,8 @@ function innerPatch<T extends StateTree>(
 export function buildStore<
   Id extends string,
   S extends StateTree,
-  G extends Record<string, StoreGetter<S>>,
-  A extends Record<string, StoreAction>
+  G extends Record<string, Method>,
+  A extends Record<string, Method>
 >(
   id: Id,
   buildState: () => S = () => ({} as S),
@@ -82,6 +97,7 @@ export function buildStore<
     // @ts-ignore FIXME: why is this even failing on TS
     innerPatch(state.value, partialState)
     isListening = true
+    // because we paused the watcher, we need to manually call the subscriptions
     subscriptions.forEach(callback => {
       callback(
         { storeName: id, type: 'โคต๏ธ patch', payload: partialState },
@@ -110,22 +126,28 @@ export function buildStore<
   const storeWithState: StoreWithState<Id, S> = {
     id,
     _r,
-    // it is replaced below by a getter
-    // @ts-ignore FIXME: why is this even failing on TS
-    state: state.value,
+    // @ts-ignore, `reactive` unwraps this making it of type S
+    state: computed<S>({
+      get: () => state.value,
+      set: newState => {
+        isListening = false
+        state.value = newState
+        isListening = true
+      },
+    }),
 
     patch,
     subscribe,
     reset,
   }
 
-  const computedGetters: StoreWithGetters<S, G> = {} as StoreWithGetters<S, G>
+  const computedGetters: StoreWithGetters<G> = {} as StoreWithGetters<G>
   for (const getterName in getters) {
     computedGetters[getterName] = computed(() => {
       setActiveReq(_r)
       // eslint-disable-next-line @typescript-eslint/no-use-before-define
-      return getters[getterName](state.value as S, computedGetters)
-    }) as any
+      return getters[getterName].call(store, store)
+    }) as StoreWithGetters<G>[typeof getterName]
   }
 
   // const reactiveGetters = reactive(computedGetters)
@@ -139,22 +161,13 @@ export function buildStore<
     } as StoreWithActions<A>[typeof actionName]
   }
 
-  const store: Store<Id, S, G, A> = {
+  const store: Store<Id, S, G, A> = reactive({
     ...storeWithState,
+    // using this means no new properties can be added as state
+    ...toComputed(state),
     ...computedGetters,
     ...wrappedActions,
-  }
-
-  // make state access invisible
-  Object.defineProperty(store, 'state', {
-    get: () => state.value,
-    set: (newState: S) => {
-      isListening = false
-      // @ts-ignore FIXME: why is this even failing on TS
-      state.value = newState
-      isListening = true
-    },
-  })
+  }) as Store<Id, S, G, A>
 
   return store
 }
@@ -166,14 +179,14 @@ export function buildStore<
 export function createStore<
   Id extends string,
   S extends StateTree,
-  G extends Record<string, StoreGetter<S>>,
-  A extends Record<string, StoreAction>
+  G /* extends Record<string, StoreGetterThis> */,
+  A /* extends Record<string, StoreAction> */
 >(options: {
   id: Id
   state?: () => S
-  getters?: G
+  getters?: G & ThisType<S & StoreWithGetters<G>>
   // allow actions use other actions
-  actions?: A & ThisType<A & StoreWithState<Id, S> & StoreWithGetters<S, G>>
+  actions?: A & ThisType<A & S & StoreWithState<Id, S> & StoreWithGetters<G>>
 }) {
   const { id, state, getters, actions } = options
 
@@ -187,6 +200,7 @@ export function createStore<
     if (!store) {
       stores.set(
         id,
+        // @ts-ignore
         (store = buildStore(id, state, getters, actions, getInitialState(id)))
       )
 
index 809567a4d7581bf76b94837feb738099d880cd9d..93c7a9c882967a3b4f4984a45b3b1532eef08cef 100644 (file)
@@ -28,13 +28,6 @@ export type SubscriptionCallback<S> = (
   state: S
 ) => void
 
-export type StoreWithGetters<
-  S extends StateTree,
-  G extends Record<string, StoreGetter<S>>
-> = {
-  [k in keyof G]: G[k] extends StoreGetter<S, infer V> ? Ref<V> : never
-}
-
 export interface StoreWithState<Id extends string, S extends StateTree> {
   /**
    * Unique identifier of the store
@@ -42,7 +35,7 @@ export interface StoreWithState<Id extends string, S extends StateTree> {
   id: Id
 
   /**
-   * State of the Store
+   * State of the Store. Setting it will replace the whole state.
    */
   state: S
 
@@ -71,30 +64,52 @@ export interface StoreWithState<Id extends string, S extends StateTree> {
   subscribe(callback: SubscriptionCallback<S>): () => void
 }
 
-export interface StoreAction {
-  (...args: any[]): any
-}
+export type Method = (...args: any[]) => any
+
+// export type StoreAction<P extends any[], R> = (...args: P) => R
+// export interface StoreAction<P, R> {
+//   (...args: P[]): R
+// }
 
 // in this type we forget about this because otherwise the type is recursive
-export type StoreWithActions<A extends Record<string, StoreAction>> = {
-  [k in keyof A]: A[k] extends (this: infer This, ...args: infer P) => infer R
-    ? (this: This, ...args: P) => R
+export type StoreWithActions<A> = {
+  [k in keyof A]: A[k] extends (...args: infer P) => infer R
+    ? (...args: P) => R
     : never
 }
 
+// export interface StoreGetter<S extends StateTree, T = any> {
+//   // TODO: would be nice to be able to define the getters here
+//   (state: S, getters: Record<string, Ref<any>>): T
+// }
+
+export type StoreWithGetters<G> = {
+  [k in keyof G]: G[k] extends (this: infer This, store?: any) => infer R
+    ? R
+    : never
+}
+
+// // in this type we forget about this because otherwise the type is recursive
+// export type StoreWithThisGetters<G> = {
+//   // TODO: does the infer this as the second argument work?
+//   [k in keyof G]: G[k] extends (this: infer This, store?: any) => infer R
+//     ? (this: This, store?: This) => R
+//     : never
+// }
+
 // has the actions without the context (this) for typings
 export type Store<
   Id extends string,
   S extends StateTree,
-  G extends Record<string, StoreGetter<S>>,
-  A extends Record<string, StoreAction>
-> = StoreWithState<Id, S> & StoreWithGetters<S, G> & StoreWithActions<A>
+  G,
+  A
+> = StoreWithState<Id, S> & S & StoreWithGetters<G> & StoreWithActions<A>
 
 export type GenericStore = Store<
   string,
   StateTree,
-  Record<string, StoreGetter<StateTree>>,
-  Record<string, StoreAction>
+  Record<string, Method>,
+  Record<string, Method>
 >
 
 export interface DevtoolHook {