vuejs / rfcs

RFCs for substantial changes / feature additions to Vue core
4.87k stars 546 forks source link

Vuex Functional API #72

Closed skyrpex closed 2 months ago

skyrpex commented 5 years ago

Do you think we could have a functional approach for Vuex, like this? I think it should focus store modules, mainly.

Basically, would love to benefit from typehinting and have a similar approach to the upcoming functional API, if possible.

// store/post.ts
import { createModule, mutations, reactive, toBindings, computed } from "vuex";

// The symbol will allow injecting the store into a component.
export default createModule(Symbol("post"), () => {
    // Defining all state in a single call allows
    // the VueDevtools to show the variable names.
    const state = reactive({
        post: null,
    });

    // Defining all mutations in a single call allows
    // using the keys as the mutation ID (setPost).
    const mtts = mutations({
        setPost(post) {
            state.post = post;
        },
    });

    // Getters / Computeds.
    const hasPost = () => state.post !== null;
    const title = computed(() => state.post ? state.post.title : "");

    // Actions.
    const fetchPost = async () => {
        const response = await fetch("/api/post");
        mtts.setPost(await response.json());
    };

    // We could use submodules this way:
    // const users = useUsers();

    // Exported context.
    return {
        ...mtts,
        ...toBindings(state),
        hasPost,
        title,
        fetchPost,
    };
});
// components/post.ts
import { createComponent } from "vue";
import usePosts from "../store/post";

export default createComponent({
    setup() {
        // The store would be lazily created the first time the usePosts() is called.
        const posts = usePosts();
        return () => (
            <div>
                <button onClick={posts.fetchPost}>Fetch Post</button>
                {posts.hasPost() && <div>Post: {posts.post.value}</div>}
            </div>
        );
    },
});
aztalbot commented 5 years ago

I like this pattern. A few thoughts:

  1. I wouldn’t want to return bindings of state because then it can be mutated from elsewhere. I would expose with some readonly helper that creates a computed with no setter from each key

  2. I would probably also group getters into an object of getter functions and pass that into a helper. This would prevent anyone from trying to define setters on the computed values.

  3. I like that you have actions there as plain Async functions. I think that’s fine. But I hope we can also have Async mutations (the helper can proxy each function and wait for completion). This way we can simplify/consolidate actions and mutations when it makes sense.

  4. I like the idea of keeping the returned object flat by spreading everything rather than keeping everything in nested fields like mutations or getters.

andredewaard commented 5 years ago

I like this pattern. A few thoughts:

  1. I wouldn’t want to return bindings of state because then it can be mutated from elsewhere. I would expose with some readonly helper that creates a computed with no setter from each key
  2. I would probably also group getters into an object of getter functions and pass that into a helper. This would prevent anyone from trying to define setters on the computed values.
  3. I like that you have actions there as plain Async functions. I think that’s fine. But I hope we can also have Async mutations (the helper can proxy each function and wait for completion). This way we can simplify/consolidate actions and mutations when it makes sense.
  4. I like the idea of keeping the returned object flat by spreading everything rather than keeping everything in nested fields like mutations or getters.

I'm fine with point 1, 2 and 4 but from my perspective an mutation should always and only mutate the state and nothing else in this case an mutation would never be an async functions. Instead you should handle the async functions inside the action.

I would love creating Vuex modules like this.

TerenceZ commented 5 years ago

Based on this post, I experiment some Vuex Functional API based on Vue2 recently, and I've added some extra features due to my use cases.

// Just use function to define module, with custom context injection
// from init or its parent module.
function SubModule(context: CustomContext) {
  const someState = state({ a: 0 /* ... */ })

  const someValue = value(0)

  const someGetter = getter(() => someValue.value + someState.a)

  const someMutation = mutation((payload: number) => {
    someValue.value += payload
  })

  const someAction = action((payload: number) => {
    // dispatch action to trigger mutation.
    someMutation(payload + someGetter.value)
  })

  const someComputed = computed(
    () => someValue.value,
    value => {
      someMutation(321)
    },
  )

  const someActionWithPayloadCreator = action(
    payload => {
      someMutation(payload + someComputed.value)
    },
    (a: number, b: number) => a + b,
  )

  const someAsyncAction = action(async (payload: number) => {
    await new Promise(resolve => setTimeout(resolve, 1000))
    someAction(payload)
    return payload
  })

  // just simple wrap for action(), and used in services and communicate
  // with other modules.
  const someEventAction = event()

  // `someEventActionWithPayloadCreator(1, 2)` will dispatch an action with
  // `payload=3` and unique action type.
  const someEventActionWithPayloadCreator = event(
    (a: number, b: number) => a + b,
  )

  // Subscriptions are invoked after init().
  subscribe(() => {
    somoeAsyncAction(123).then(result => {
      console.log(result) // 123
    })
  })

  // Watchers are invoked after init() if no lazy specified.
  // watch(wrappedValue, callback)
  watch(someValue, value => {
    console.log(value) // value will be unwrapped.
  })

  // watch(fn, callback)
  // unwatch() will be auto invoked when store stops.
  const unwtach = watch(
    () => someState,
    value => {
      console.log(value)
    },
    { lazy: true },
  )

  // watch(mixed fns and wrapped values, callback)
  watch(
    // without `as const`, the callback arg types are not accurated.
    [someValue, someGetter, () => someState] as const,
    ([value0, value1, value2], prevValue_, onCleanup_) => {
      console.log(value0, value1, value2)
    },
  )

  // Services are invoked through `runSaga()` after init().
  service(function*() {
    while (1) {
      // dispatcher.action is a function to create action (not dispatch),
      // and due to `toString()` redefined on it, we can use it in `take()`.
      const { payload } = yield take(someAction.action)
      yield delay(1000)
      // create an action to put.
      yield put(someEventAction.action(payload * 2))
    }
  })

  // export what to expose.
  return {
    // You can only expose what you want, no needs for all.
    someAction,
    someAsyncAction,

    someState,
    someComputed,

    // You should explcit unwrap nested props with value,
    // because init() and module() will only unwrap exports' own props.
    deeperProps: unwrap({
      someEvent,
      someValue,
    }),
  }
}

function RootModule(context: CustomContext) {
  // import things from SubModule.
  // module() is just as `unwrap(SubModule(context))`
  const sub = module(SubModule, context)

  // You can use module more than once.
  const aotherSub = module(SubModule, { a: 567 })

  const someRootState = state({
    /* ... */
  })

  subscribe(() => {
    console.log(sub.someValue)
  })

  service(function*() {
    while (1) {
      const { payload } = yield take(sub.deeperProps.someEvent)
      console.log(payload)
    }
  })

  return {
    someRootState,
    anotherSub,
    // if you want to spread modules, you should
    // wrap it back firstly, otherwise, the spread
    // values are not reactive.
    ...wrap(sub),
  }
}

// Create a store instance,.
// Actually, we use Vuex just as action dispatcher and expose things for devtools.
// So in production, nothing (except actions  and mutations) will be saved in Vuex.
const store = init(() => RootModule({ a: 123 }), {
  plugins: [
    /* Vuex Store Plugins */
  ],
  strict: true, // restrict state / value updates only in mutation.
  saga: {
    /* runSagaOptions, see redux-saga document. */
  },
})

const App = Vue.extend({
  render(h) {
    const someRootState = store.someRootState
    // do something...
    return h(
      'div',
      {
        on: {
          click() {
           store.someAction(123)
          },
        },
      },
      [store.someValue, store.anotherSub.someValue],
    )
  },
})
donnysim commented 5 years ago

This is my opinion when working with current VueX so far, but I honestly really dislike the way you work with it as it is, remembering how to access namespaced state, getters, etc., refactoring problems, all these constant mapState, mapMethods... it's just a really painful experience.

I do like the approach of functional store, but as this rfc proposes it still feels off for me. I really love modular stores, no single god store/module object, just separate value objects (which can be grouped into state), getters, effects, events that you import when needed (which with IDE's is a breeze), and it keeps it tree shakable.

I'll share other state manager example I'm enjoying at the moment (don't know if I should post the name here so I'll omit it for the time being), but it has very simple principles:

// Events can act as mutators and events at the same time.
export const setUser = createEvent('set auth user');

// Effects - async actions
export const login = createEffect('login', {
  async handler(payload) {
    const response = await httpService.post('/api/auth/login', payload);
    setUser(response.data);

    return response;
  },
});

// Stores - single value holder
export const user = createStore(null)
  // Listen for `setUser` event and update the store user
  .on(setUser, (state, payload) => payload);

// Computed
export const isLoggedIn = user.map(value => Boolean(value));

Now all you need to do is just import anything you need, refactoring is simple as it's just imports and can be handled by most IDE's, no need to remember what namespace it's in etc., and the usage is super simple, can be said it's kind of inline with Vue v3 state - if you want to get the user store value you call user.getState(), if you want to set the user, super simple, just call the event - setUser(myUser), need to listen for setUser event in other module - setUser.watch(user => console.log('user changed', user));, need to mock the login effect in tests - login.use(() => promiseMock) and bunch of more goodies.

I'd really love if VueX became something in line as Vue is not really friendly with any other state management library that does not rely on Vue reactivity.

Timkor commented 5 years ago

Wouldn't it not make more sense to just don't use Vuex anymore at all? Because you can now use the function API; state() and value() as Vuex state and computed() as Vuex getters.

For instance, you could have the following module:

import {value, computed} form 'vue';

export const items = value([]);

export const size = computed(() => items.value.length);

export function addItem(content) {
  items.value.push(content);
}

You can just import this, in any component. I don't know about Vue Dev Tools. But this makes much more sense to me than Vuex at all.

andredewaard commented 5 years ago

Would be great if you can do something like this:

import { useModule } from 'vuex'
import { value, computed } form 'vue';

export const itemsModule = () => {

    const items = value([])
    const size = computed(() => items.value.length);

    const addItem = (content) => {
        items.value.push(content)
    }

    return {
        items,
        size,
        addItem
    }

}

useModule(itemsModule)

Not sure about the useModule function but will be very helpful to tell vue-devtools about the state so you can keep track of it.

skyrpex commented 5 years ago

I think the key points of Vuex is to be able to rewind the mutations, and store them in an array and send them as bug reports or something similar. For that, we need a reference to the module (a name) and to name all the mutations somehow. That's why I suggested that we should declare the mutations using some sort of dictionary:

    // Defining all mutations in a single call allows
    // using the keys as the mutation ID (setPost).
    const mtts = mutations({
        setPost(post) {
            state.post = post;
        },
    });
donnysim commented 5 years ago

I wonder how many users actually use the rewind function. Personally I never used it in development, never used it in production because the errors are never about the state or data flow, but rather everyday bugs you miss out like a null check. I never even looked at mutation log as it doesn't really tell me anything I didn't know already, mutation does not mean correct data update, you can still make errors with it. The only thing I constantly looks at is the state tab to see if it updated correctly. I feel the most problematic use cases are dealing with forms, but I never found use for forms with vuex, it just makes them way overcomplicated than they need to be so I rather store them in local state and deal with necessary actions on route leave or window unload. I know this is just my personal use case and there's plenty of different ways everyone's working with it, so don't take it as me bashing it.

Is the rewind really such a big feature? I've never seen someone write about how they used it or even mention using it (does not imly anything).

Also the history can be a really big, very big memory problem depending on what you store in VueX.

boonyasukd commented 5 years ago

A friendly reminder worth noting here is that, even the current vuex itself is being implemented with a regular vue component. And that's the main reason why the concepts used in vuex is almost exactly the same as things we would define in a regular vue component: (datastate, computedgetters, etc.). As such, the choice to NOT using vuex for state management is actually available to us all these years, not just now. As a matter of fact, even vue documentation itself has a dedicated section describing how you can set up "state management from scratch" (with a regular vue component) should your app requirements are simple. Once you go beyond a simple store to become a multi-module store, and you need to centrally manage all mutations/actions in one place, vuex becomes a necessity.

I'll let the closing remark on Vue doc section itself does the talk, since it actually sums up the very reasons why vuex needs to exist:

As we continue developing the convention where components are never allowed to directly mutate state that belongs to a store, but should instead dispatch events that notify the store to perform actions, we eventually arrive at the Flux architecture. The benefit of this convention is we can record all state mutations happening to the store and implement advanced debugging helpers such as mutation logs, snapshots, and history re-rolls / time travel.

This brings us full circle back to vuex, so if you’ve read this far it’s probably time to try it out!

ghost commented 4 years ago

And that history/time traveling comes in useful once in a while, but there's no reason we can't create something simpler to use, especially when it comes to modules, and I think a more functional approach works well. The biggest reason I haven't done what @Timkor suggested is because of its lack of connection to the dev tools and because it's harder to inject for testing purposes, though not really all that difficult. It shouldn't be difficult to do what this rfc suggested and hook it into dev tools.

lbssousa commented 4 years ago

There's already an unofficial work in progress: https://github.com/PatrykWalach/vuex-composition-api

mesqueeb commented 4 years ago

@yyx990803 @ktsn With Vue3 coming and its easy-to-use reactivity API including: reactive, computed etc..., how do you see the future of Vuex?

Is it going to stay as some kind of convention that people can use for central state management? Or do you think it should be progressively abandoned for just using Vue 3's reactivity without the templates?

I'm working on tools for state management and would like some heads up on the future. 😉

It's this post that really got me thinking:

Wouldn't it not make more sense to just don't use Vuex anymore at all? Because you can now use the function API; state() and value() as Vuex state and computed() as Vuex getters.

For instance, you could have the following module:

import {value, computed} form 'vue';

export const items = value([]);

export const size = computed(() => items.value.length);

export function addItem(content) {
  items.value.push(content);
}

You can just import this, in any component. I don't know about Vue Dev Tools. But this makes much more sense to me than Vuex at all.

ghost commented 4 years ago

Honestly, the biggest reason to keep Vuex is because of the dev tools. Sure we can import all the state into the App component so we can just inspect that component to see the state, but seeing the timeline and being able to rewind is extremely powerful in many circumstances.

cawa-93 commented 4 years ago

Wouldn't it not make more sense to just don't use Vuex anymore at all? Because you can now use the function API; state() and value() as Vuex state and computed() as Vuex getters.

For instance, you could have the following module:

import {value, computed} form 'vue';

export const items = value([]);

export const size = computed(() => items.value.length);

export function addItem(content) {
  items.value.push(content);
}

You can just import this, in any component. I don't know about Vue Dev Tools. But this makes much more sense to me than Vuex at all.

This offer is very interesting. But I don't see any places for plugins here.

Suppose I need to synchronize any state changes with the file system or with some external storage.

Now I can do it easily:

  store.subscribe((mutation, state) => {
    fsPromises.writeFile('state.json', JSON.stringify(state))
  });

But I do not see a similar solution in your proposal.

Or even more. Let's say I have several such modules, some of which are set as nm dependencies. Without plugins, I can't keep track of and somehow respond to changes in the state.

I believe that vyuks should be a constructor for such modules and provide api for external plugins

Let's say this:

// vuex-module.js
import { createModule } from 'vuex'
import {value, computed} from 'vue';

export default createModule(() => {
  const items = value([]);

  const size = computed(() => items.value.length);

  function addItem(content) {
    items.value.push(content);
  }

  return {
    items,
    size,
    addItem,
  }
})

// vuex-plugins.js
import MyModule from 'vuex-module.js'

MyModule.addPlugin({
  onChange(state) {
    fsPromises.writeFile('state.json', JSON.stringify(state))
  }
})
posva commented 4 years ago

If you are interested, I implemented a store version that works well with the Composition API. It's still open in terms of API design and has devtools, typescript, and plugin support, it's called Pinia.

sl1673495 commented 4 years ago

We can just define a model like this by using provide api:

// context/book.ts
import { provide, inject, computed, ref, Ref } from '@vue/composition-api';
import { Book, Books } from '@/types';

type BookContext = {
  books: Ref<Books>;
  setBooks: (value: Books) => void;
};

const BookSymbol = Symbol();

export const useBookListProvide = () => {
  const books = ref<Books>([]);
  const setBooks = (value: Books) => (books.value = value);

  provide(BookSymbol, {
    books,
    setBooks,
  });
};

export const useBookListInject = () => {
  const booksContext = inject<BookContext>(BookSymbol);

  if (!booksContext) {
    throw new Error(`useBookListInject must be used after useBookListProvide`);
  }

  return booksContext;
};

We can export all Providers by context/index.ts

// context/index.ts
import { useBookListProvide, useBookListInject } from './books';

export { useBookListInject };

export const useProvider = () => {
  useBookListProvide();
};

Then we registe it:

new Vue({
  router,
  setup() {
    useProvider();
    return {};
  },
  render: h => h(App),
}).$mount('#app');

Use in component:

<template>
  <Books :books="books" :loading="loading" />
</template>

<script lang="ts">
import { createComponent } from '@vue/composition-api';
import Books from '@/components/Books.vue';
import { useAsync } from '@/hooks';
import { getBooks } from '@/hacks/fetch';
import { useBookListInject } from '@/context';

export default createComponent({
  name: 'books',
  setup() {
    const { books, setBooks } = useBookListInject();

    const loading = useAsync(async () => {
      const requestBooks = await getBooks();
      setBooks(requestBooks);
    });

    return { books, loading };
  },
  components: {
    Books,
  },
});
</script>

without vuex, it's cool already.

jacekkarczmarczyk commented 4 years ago

@cawa-93 How is that:

// vuex-plugins.js
import MyModule from 'vuex-module.js'

MyModule.addPlugin({
  onChange(state) {
    fsPromises.writeFile('state.json', JSON.stringify(state))
  }
})

better than this (assuming that it's correct, maybe it needs deep option or returning state as reactive({...}) instead of just {...}, but that doesn't change the general idea):

// some-file.js
import state from 'vuex-module.js';

watch(() => {
  fsPromises.writeFile('state.json', JSON.stringify(state))
})
posva commented 2 months ago

Closing as this functional api can be achieved with setup stores in pinia