From: Eduardo San Martin Morote Date: Tue, 22 Sep 2020 08:08:12 +0000 (+0200) Subject: feat: access the state and getters through `this` (#190) X-Git-Tag: v2.0.0-alpha.1~9 X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=6df18ef49472b0348b09cb84801c9c69ae79b3d9;p=thirdparty%2Fvuejs%2Fpinia.git feat: access the state and getters through `this` (#190) 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. --- diff --git a/README.md b/README.md index 6b1d7204..158c645f 100644 --- 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) diff --git a/__tests__/actions.spec.ts b/__tests__/actions.spec.ts index c2fd6c51..fb7aee81 100644 --- a/__tests__/actions.spec.ts +++ b/__tests__/actions.spec.ts @@ -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) { diff --git a/__tests__/getters.spec.ts b/__tests__/getters.spec.ts index 7380b474..3ef37374 100644 --- a/__tests__/getters.spec.ts +++ b/__tests__/getters.spec.ts @@ -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') }) }) diff --git a/__tests__/rootState.spec.ts b/__tests__/rootState.spec.ts index ce3881dc..d3ee12f7 100644 --- a/__tests__/rootState.spec.ts +++ b/__tests__/rootState.spec.ts @@ -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 index 00000000..0737ac96 --- /dev/null +++ b/__tests__/state.spec.ts @@ -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') + }) +}) diff --git a/src/store.ts b/src/store.ts index cc5e5a69..d87b4d18 100644 --- a/src/store.ts +++ b/src/store.ts @@ -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( return target } +function toComputed(refObject: Ref) { + // let asComputed = computed() + const reactiveObject = {} as { + [k in keyof T]: Ref + } + 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( export function buildStore< Id extends string, S extends StateTree, - G extends Record>, - A extends Record + G extends Record, + A extends Record >( 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, _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({ + get: () => state.value, + set: newState => { + isListening = false + state.value = newState + isListening = true + }, + }), patch, subscribe, reset, } - const computedGetters: StoreWithGetters = {} as StoreWithGetters + const computedGetters: StoreWithGetters = {} as StoreWithGetters 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[typeof getterName] } // const reactiveGetters = reactive(computedGetters) @@ -139,22 +161,13 @@ export function buildStore< } as StoreWithActions[typeof actionName] } - const store: Store = { + const store: Store = 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 return store } @@ -166,14 +179,14 @@ export function buildStore< export function createStore< Id extends string, S extends StateTree, - G extends Record>, - A extends Record + G /* extends Record */, + A /* extends Record */ >(options: { id: Id state?: () => S - getters?: G + getters?: G & ThisType> // allow actions use other actions - actions?: A & ThisType & StoreWithGetters> + actions?: A & ThisType & StoreWithGetters> }) { 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))) ) diff --git a/src/types.ts b/src/types.ts index 809567a4..93c7a9c8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -28,13 +28,6 @@ export type SubscriptionCallback = ( state: S ) => void -export type StoreWithGetters< - S extends StateTree, - G extends Record> -> = { - [k in keyof G]: G[k] extends StoreGetter ? Ref : never -} - export interface StoreWithState { /** * Unique identifier of the store @@ -42,7 +35,7 @@ export interface StoreWithState { 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 { subscribe(callback: SubscriptionCallback): () => void } -export interface StoreAction { - (...args: any[]): any -} +export type Method = (...args: any[]) => any + +// export type StoreAction

= (...args: P) => R +// export interface StoreAction { +// (...args: P[]): R +// } // in this type we forget about this because otherwise the type is recursive -export type StoreWithActions> = { - [k in keyof A]: A[k] extends (this: infer This, ...args: infer P) => infer R - ? (this: This, ...args: P) => R +export type StoreWithActions = { + [k in keyof A]: A[k] extends (...args: infer P) => infer R + ? (...args: P) => R : never } +// export interface StoreGetter { +// // TODO: would be nice to be able to define the getters here +// (state: S, getters: Record>): T +// } + +export type StoreWithGetters = { + [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 = { +// // 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>, - A extends Record -> = StoreWithState & StoreWithGetters & StoreWithActions + G, + A +> = StoreWithState & S & StoreWithGetters & StoreWithActions export type GenericStore = Store< string, StateTree, - Record>, - Record + Record, + Record > export interface DevtoolHook {